@marktiderman/genesis-forms 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +386 -8
- package/dist/destinations.cjs +856 -53
- package/dist/destinations.cjs.map +1 -1
- package/dist/destinations.d.cts +452 -18
- package/dist/destinations.d.ts +452 -18
- package/dist/destinations.js +826 -54
- package/dist/destinations.js.map +1 -1
- package/dist/{engine-C5tb3mkf.d.cts → engine-BaHPC2-1.d.cts} +2 -2
- package/dist/{engine-DcNQ-6oJ.d.ts → engine-Da99Tv-E.d.ts} +2 -2
- package/dist/index.cjs +996 -167
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +288 -11
- package/dist/index.d.ts +288 -11
- package/dist/index.js +975 -168
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +563 -173
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +3 -474
- package/dist/server.d.ts +3 -474
- package/dist/server.js +558 -174
- package/dist/server.js.map +1 -1
- package/dist/submit-protocol-CFi-0Qd0.d.ts +840 -0
- package/dist/submit-protocol-D0cOckmK.d.cts +840 -0
- package/dist/testing.cjs +250 -154
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +4 -4
- package/dist/testing.d.ts +4 -4
- package/dist/testing.js +250 -154
- package/dist/testing.js.map +1 -1
- package/dist/{types-oAckGR_Z.d.cts → types-C0pUfew3.d.cts} +8 -2
- package/dist/{types-oAckGR_Z.d.ts → types-C0pUfew3.d.ts} +8 -2
- package/dist/{validate-BFpNJbjf.d.cts → validate-DpmBssFk.d.ts} +258 -140
- package/dist/{validate-qwkdcr3H.d.ts → validate-U5LoW3eR.d.cts} +258 -140
- package/package.json +2 -2
- package/dist/upload-protocol-Bw0CWRJc.d.cts +0 -122
- package/dist/upload-protocol-PX0jroTV.d.ts +0 -122
package/dist/destinations.js
CHANGED
|
@@ -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.
|
|
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 : "";
|
|
@@ -72,10 +76,10 @@ async function timedFetch(deps, url, init, signal) {
|
|
|
72
76
|
throw error;
|
|
73
77
|
}
|
|
74
78
|
}
|
|
75
|
-
async function resolveOption(what, option, config) {
|
|
79
|
+
async function resolveOption(what, option, config, context = {}) {
|
|
76
80
|
if (typeof option !== "function") return option;
|
|
77
81
|
try {
|
|
78
|
-
return await option(config);
|
|
82
|
+
return await option(config, context);
|
|
79
83
|
} catch (error) {
|
|
80
84
|
if (error instanceof DeliveryError) throw error;
|
|
81
85
|
throw new DeliveryError(`${what} could not be resolved`);
|
|
@@ -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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
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
|
-
|
|
629
|
-
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
663
|
-
|
|
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,12 +910,59 @@ 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])
|
|
913
|
+
for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType, photoFieldType]) registerBuiltIn(manifest);
|
|
770
914
|
|
|
771
915
|
// src/logic.ts
|
|
772
916
|
function own(record, key) {
|
|
773
917
|
return Object.prototype.hasOwnProperty.call(record, key) ? record[key] : void 0;
|
|
774
918
|
}
|
|
919
|
+
function resolve(operand, scope) {
|
|
920
|
+
if ("field" in operand) return own(scope.values, operand.field);
|
|
921
|
+
if ("hidden" in operand) return own(scope.hidden, operand.hidden);
|
|
922
|
+
return operand.value;
|
|
923
|
+
}
|
|
924
|
+
function deepEqual(a, b) {
|
|
925
|
+
if (a === b) return true;
|
|
926
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
927
|
+
return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
|
|
928
|
+
}
|
|
929
|
+
return false;
|
|
930
|
+
}
|
|
931
|
+
function evaluate(condition, scope) {
|
|
932
|
+
if ("all" in condition) return condition.all.every((c) => evaluate(c, scope));
|
|
933
|
+
if ("any" in condition) return condition.any.some((c) => evaluate(c, scope));
|
|
934
|
+
if ("not" in condition) return !evaluate(condition.not, scope);
|
|
935
|
+
const left = resolve(condition.left, scope);
|
|
936
|
+
const right = condition.right ? resolve(condition.right, scope) : void 0;
|
|
937
|
+
const field = "field" in condition.left ? scope.fields.get(condition.left.field) : void 0;
|
|
938
|
+
const unanswered = isEmptyValue(left, field);
|
|
939
|
+
switch (condition.op) {
|
|
940
|
+
case "answered":
|
|
941
|
+
return !unanswered;
|
|
942
|
+
case "empty":
|
|
943
|
+
return unanswered;
|
|
944
|
+
case "eq":
|
|
945
|
+
return !unanswered && deepEqual(left, right);
|
|
946
|
+
case "neq":
|
|
947
|
+
return unanswered || !deepEqual(left, right);
|
|
948
|
+
case "lt":
|
|
949
|
+
case "lte":
|
|
950
|
+
case "gt":
|
|
951
|
+
case "gte": {
|
|
952
|
+
if (typeof left !== "number" || typeof right !== "number") return false;
|
|
953
|
+
if (condition.op === "lt") return left < right;
|
|
954
|
+
if (condition.op === "lte") return left <= right;
|
|
955
|
+
if (condition.op === "gt") return left > right;
|
|
956
|
+
return left >= right;
|
|
957
|
+
}
|
|
958
|
+
case "in":
|
|
959
|
+
return !unanswered && Array.isArray(right) && right.some((r) => deepEqual(r, left));
|
|
960
|
+
case "contains":
|
|
961
|
+
if (unanswered) return false;
|
|
962
|
+
if (Array.isArray(left)) return left.some((l) => deepEqual(l, right));
|
|
963
|
+
return typeof left === "string" && typeof right === "string" && left.toLowerCase().includes(right.toLowerCase());
|
|
964
|
+
}
|
|
965
|
+
}
|
|
775
966
|
function withheld(def, field) {
|
|
776
967
|
return def.settings?.anonymous === true && field?.identifying === true;
|
|
777
968
|
}
|
|
@@ -1139,9 +1330,20 @@ function sheetsDestination(options) {
|
|
|
1139
1330
|
async function run(items, config, deliveryOptions) {
|
|
1140
1331
|
if (!items.length) return { ok: true };
|
|
1141
1332
|
const { spreadsheetId, sheetName } = configOf(config);
|
|
1142
|
-
const account = await resolveOption("The sheet destination's account", options.account, config);
|
|
1143
1333
|
const signal = deliveryOptions.signal;
|
|
1144
|
-
const
|
|
1334
|
+
const context = { destinationId: deliveryOptions.destinationId };
|
|
1335
|
+
let account = null;
|
|
1336
|
+
let token;
|
|
1337
|
+
if (options.accessToken) {
|
|
1338
|
+
token = await resolveOption("The sheet destination's access token", options.accessToken, config, context);
|
|
1339
|
+
if (typeof token !== "string" || !token) throw new DeliveryError("The sheet destination has no access token");
|
|
1340
|
+
} else if (options.account) {
|
|
1341
|
+
account = await resolveOption("The sheet destination's account", options.account, config, context);
|
|
1342
|
+
token = await googleAccessToken(account, { ...options, signal });
|
|
1343
|
+
} else {
|
|
1344
|
+
throw new DeliveryError("The sheet destination has neither an account nor an access token");
|
|
1345
|
+
}
|
|
1346
|
+
const sharedWith = account ? `is the sheet shared with ${account.clientEmail}?` : "is the sheet still connected to its Google account?";
|
|
1145
1347
|
const base = `${SHEETS_API}/${encodeURIComponent(spreadsheetId)}`;
|
|
1146
1348
|
const escape = options.escapeFormulas === true;
|
|
1147
1349
|
const call = async (what, url, init = {}) => {
|
|
@@ -1153,12 +1355,12 @@ function sheetsDestination(options) {
|
|
|
1153
1355
|
);
|
|
1154
1356
|
if (!response.ok) {
|
|
1155
1357
|
discard(response);
|
|
1156
|
-
if (what === "append" && (response.status === 400 || response.status === 404)) {
|
|
1358
|
+
if ((what === "append" || what === "update rows") && (response.status === 400 || response.status === 404)) {
|
|
1157
1359
|
throw new StaleSheetError(`Google Sheets append answered ${response.status}`, { status: response.status });
|
|
1158
1360
|
}
|
|
1159
1361
|
if (response.status === 403 || response.status === 404) {
|
|
1160
1362
|
throw new DeliveryError(
|
|
1161
|
-
`Google Sheets ${what} answered ${response.status}:
|
|
1363
|
+
`Google Sheets ${what} answered ${response.status}: ${sharedWith}`,
|
|
1162
1364
|
{ status: response.status }
|
|
1163
1365
|
);
|
|
1164
1366
|
}
|
|
@@ -1254,14 +1456,45 @@ function sheetsDestination(options) {
|
|
|
1254
1456
|
}
|
|
1255
1457
|
let skipped = [];
|
|
1256
1458
|
async function append(state2) {
|
|
1257
|
-
|
|
1258
|
-
|
|
1459
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1460
|
+
items.forEach((item, i) => {
|
|
1461
|
+
const id = item.submission.id;
|
|
1462
|
+
const update = (item.update ?? deliveryOptions.update) === true || byId.get(id)?.update === true;
|
|
1463
|
+
byId.delete(id);
|
|
1464
|
+
byId.set(id, { id, row: rows[i].row, update });
|
|
1465
|
+
});
|
|
1466
|
+
let pending = [...byId.values()];
|
|
1467
|
+
const updating = pending.some((p) => p.update);
|
|
1468
|
+
const overwrite = [];
|
|
1469
|
+
skipped = [];
|
|
1470
|
+
if (deliveryOptions.redelivery || updating) {
|
|
1259
1471
|
const column = columnLetter(state2.header.indexOf("id"));
|
|
1260
|
-
const from = state2.last_row > 0 ? Math.max(2, state2.last_row - DEDUPE_WINDOW) : 2;
|
|
1472
|
+
const from = !updating && state2.last_row > 0 ? Math.max(2, state2.last_row - DEDUPE_WINDOW) : 2;
|
|
1261
1473
|
const ids = await call("read ids", range(`${column}${from}:${column}`));
|
|
1262
|
-
const
|
|
1263
|
-
|
|
1264
|
-
|
|
1474
|
+
const rowOf = /* @__PURE__ */ new Map();
|
|
1475
|
+
(ids.values ?? []).forEach((r, i) => {
|
|
1476
|
+
const id = String(r?.[0] ?? "");
|
|
1477
|
+
if (id && !rowOf.has(id)) rowOf.set(id, from + i);
|
|
1478
|
+
});
|
|
1479
|
+
pending = pending.filter((p) => {
|
|
1480
|
+
const at = rowOf.get(p.id);
|
|
1481
|
+
if (at === void 0) return true;
|
|
1482
|
+
if (p.update) overwrite.push({ at, row: p.row });
|
|
1483
|
+
else skipped.push(p.id);
|
|
1484
|
+
return false;
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
if (overwrite.length) {
|
|
1488
|
+
await call("update rows", `${base}/values:batchUpdate`, {
|
|
1489
|
+
method: "POST",
|
|
1490
|
+
body: JSON.stringify({
|
|
1491
|
+
valueInputOption: "RAW",
|
|
1492
|
+
data: overwrite.map((o) => ({
|
|
1493
|
+
range: `${quoteSheet(sheetName)}!A${o.at}`,
|
|
1494
|
+
values: [rowFor(state2.header, o.row, escape)]
|
|
1495
|
+
}))
|
|
1496
|
+
})
|
|
1497
|
+
});
|
|
1265
1498
|
}
|
|
1266
1499
|
if (!pending.length) return state2;
|
|
1267
1500
|
const result = await call(
|
|
@@ -1296,13 +1529,149 @@ function sheetsDestination(options) {
|
|
|
1296
1529
|
};
|
|
1297
1530
|
return {
|
|
1298
1531
|
kind: "sheet",
|
|
1299
|
-
deliver: (submission, definition, config, deliveryOptions = {}) => guarded(
|
|
1532
|
+
deliver: (submission, definition, config, deliveryOptions = {}) => guarded(
|
|
1533
|
+
[{ submission, definition, deliveryId: deliveryOptions.deliveryId, update: deliveryOptions.update }],
|
|
1534
|
+
config,
|
|
1535
|
+
deliveryOptions
|
|
1536
|
+
),
|
|
1300
1537
|
deliverBatch: (items, config, deliveryOptions = {}) => guarded(items, config, deliveryOptions)
|
|
1301
1538
|
};
|
|
1302
1539
|
}
|
|
1303
1540
|
|
|
1541
|
+
// src/destinations/hook-url.ts
|
|
1542
|
+
var MAX_HOOK_URL_LENGTH = 2048;
|
|
1543
|
+
var SLACK_HOSTS = /* @__PURE__ */ new Set(["hooks.slack.com"]);
|
|
1544
|
+
var HookUrlError = class extends DeliveryError {
|
|
1545
|
+
name = "HookUrlError";
|
|
1546
|
+
};
|
|
1547
|
+
var NOT_PUBLIC = "The address must be on the public internet.";
|
|
1548
|
+
var INTERNAL_SUFFIXES = [".localhost", ".local", ".internal", ".intranet", ".lan", ".home.arpa"];
|
|
1549
|
+
function ipv4Bytes(ip) {
|
|
1550
|
+
const parts = ip.split(".");
|
|
1551
|
+
if (parts.length !== 4) return null;
|
|
1552
|
+
const bytes = parts.map((p) => /^\d{1,3}$/.test(p) ? Number(p) : NaN);
|
|
1553
|
+
return bytes.every((b) => Number.isInteger(b) && b >= 0 && b <= 255) ? bytes : null;
|
|
1554
|
+
}
|
|
1555
|
+
function publicIpv4(b) {
|
|
1556
|
+
const [a, c] = [b[0], b[1]];
|
|
1557
|
+
if (a === 0 || a === 10 || a === 127) return false;
|
|
1558
|
+
if (a === 100 && c >= 64 && c <= 127) return false;
|
|
1559
|
+
if (a === 169 && c === 254) return false;
|
|
1560
|
+
if (a === 172 && c >= 16 && c <= 31) return false;
|
|
1561
|
+
if (a === 192 && c === 168) return false;
|
|
1562
|
+
if (a === 192 && c === 0 && (b[2] === 0 || b[2] === 2)) return false;
|
|
1563
|
+
if (a === 198 && (c === 18 || c === 19)) return false;
|
|
1564
|
+
if (a === 198 && c === 51 && b[2] === 100) return false;
|
|
1565
|
+
if (a === 203 && c === 0 && b[2] === 113) return false;
|
|
1566
|
+
if (a >= 224) return false;
|
|
1567
|
+
return true;
|
|
1568
|
+
}
|
|
1569
|
+
function ipv6Bytes(ip) {
|
|
1570
|
+
let text = ip.replace(/^\[|\]$/g, "").toLowerCase();
|
|
1571
|
+
const zone = text.indexOf("%");
|
|
1572
|
+
if (zone >= 0) text = text.slice(0, zone);
|
|
1573
|
+
let tail = [];
|
|
1574
|
+
const lastColon = text.lastIndexOf(":");
|
|
1575
|
+
if (text.slice(lastColon + 1).includes(".")) {
|
|
1576
|
+
const v4 = ipv4Bytes(text.slice(lastColon + 1));
|
|
1577
|
+
if (!v4) return null;
|
|
1578
|
+
tail = v4;
|
|
1579
|
+
text = text.slice(0, lastColon + 1) + "0:0";
|
|
1580
|
+
}
|
|
1581
|
+
const halves = text.split("::");
|
|
1582
|
+
if (halves.length > 2) return null;
|
|
1583
|
+
const groups = (s) => s === "" ? [] : s.split(":");
|
|
1584
|
+
const head = groups(halves[0]);
|
|
1585
|
+
const rest = halves.length === 2 ? groups(halves[1]) : [];
|
|
1586
|
+
const missing = 8 - head.length - rest.length;
|
|
1587
|
+
if (halves.length === 1 ? missing !== 0 : missing < 1) return null;
|
|
1588
|
+
const all = [...head, ...Array(halves.length === 2 ? missing : 0).fill("0"), ...rest];
|
|
1589
|
+
const bytes = [];
|
|
1590
|
+
for (const g of all) {
|
|
1591
|
+
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
|
|
1592
|
+
const n = parseInt(g, 16);
|
|
1593
|
+
bytes.push(n >> 8, n & 255);
|
|
1594
|
+
}
|
|
1595
|
+
if (tail.length) bytes.splice(12, 4, ...tail);
|
|
1596
|
+
return bytes;
|
|
1597
|
+
}
|
|
1598
|
+
function publicIpv6(b) {
|
|
1599
|
+
const zeros = (from, to) => b.slice(from, to).every((x) => x === 0);
|
|
1600
|
+
if (zeros(0, 16)) return false;
|
|
1601
|
+
if (zeros(0, 15) && b[15] === 1) return false;
|
|
1602
|
+
if (zeros(0, 10) && b[10] === 255 && b[11] === 255) return publicIpv4(b.slice(12));
|
|
1603
|
+
if (zeros(0, 12)) return publicIpv4(b.slice(12));
|
|
1604
|
+
if (b[0] === 0 && b[1] === 100 && b[2] === 255 && b[3] === 155 && zeros(4, 12)) return publicIpv4(b.slice(12));
|
|
1605
|
+
if ((b[0] & 254) === 252) return false;
|
|
1606
|
+
if (b[0] === 254 && (b[1] & 192) === 128) return false;
|
|
1607
|
+
if (b[0] === 254 && (b[1] & 192) === 192) return false;
|
|
1608
|
+
if (b[0] === 255) return false;
|
|
1609
|
+
if (b[0] === 32 && b[1] === 1 && b[2] === 13 && b[3] === 184) return false;
|
|
1610
|
+
if (b[0] === 32 && b[1] === 1 && b[2] === 0 && b[3] === 0) return false;
|
|
1611
|
+
if (b[0] === 32 && b[1] === 2) return publicIpv4(b.slice(2, 6));
|
|
1612
|
+
if (b[0] === 1 && zeros(1, 8)) return false;
|
|
1613
|
+
return true;
|
|
1614
|
+
}
|
|
1615
|
+
function isPublicAddress(ip) {
|
|
1616
|
+
const v4 = ipv4Bytes(ip);
|
|
1617
|
+
if (v4) return publicIpv4(v4);
|
|
1618
|
+
const v6 = ipv6Bytes(ip);
|
|
1619
|
+
return v6 ? publicIpv6(v6) : false;
|
|
1620
|
+
}
|
|
1621
|
+
function isIpLiteral(host) {
|
|
1622
|
+
return ipv4Bytes(host) !== null || host.startsWith("[");
|
|
1623
|
+
}
|
|
1624
|
+
function parseHookUrl(raw, kind = "webhook") {
|
|
1625
|
+
const text = raw.trim();
|
|
1626
|
+
if (!text) throw new HookUrlError("Paste the address to send responses to.");
|
|
1627
|
+
if (text.length > MAX_HOOK_URL_LENGTH) throw new HookUrlError("That address is too long.");
|
|
1628
|
+
let url;
|
|
1629
|
+
try {
|
|
1630
|
+
url = new URL(text);
|
|
1631
|
+
} catch {
|
|
1632
|
+
throw new HookUrlError("That doesn't look like a web address.");
|
|
1633
|
+
}
|
|
1634
|
+
if (url.protocol !== "https:") throw new HookUrlError("The address must start with https://.");
|
|
1635
|
+
if (url.username || url.password) throw new HookUrlError("The address can't include a user name or password.");
|
|
1636
|
+
if (url.port && url.port !== "443") throw new HookUrlError("The address must use the standard https port.");
|
|
1637
|
+
url.hash = "";
|
|
1638
|
+
const host = url.hostname.replace(/\.$/, "");
|
|
1639
|
+
if (kind === "slack") {
|
|
1640
|
+
if (!SLACK_HOSTS.has(host) || !url.pathname.startsWith("/services/")) {
|
|
1641
|
+
throw new HookUrlError("Use a Slack incoming webhook address, which starts with https://hooks.slack.com/services/.");
|
|
1642
|
+
}
|
|
1643
|
+
return url;
|
|
1644
|
+
}
|
|
1645
|
+
if (isIpLiteral(host)) {
|
|
1646
|
+
if (!isPublicAddress(host)) throw new HookUrlError(NOT_PUBLIC);
|
|
1647
|
+
return url;
|
|
1648
|
+
}
|
|
1649
|
+
if (!host.includes(".") || host === "localhost" || INTERNAL_SUFFIXES.some((suffix) => host.endsWith(suffix))) {
|
|
1650
|
+
throw new HookUrlError(NOT_PUBLIC);
|
|
1651
|
+
}
|
|
1652
|
+
return url;
|
|
1653
|
+
}
|
|
1654
|
+
async function checkHookHost(url, resolve2) {
|
|
1655
|
+
const host = url.hostname.replace(/\.$/, "");
|
|
1656
|
+
if (isIpLiteral(host)) {
|
|
1657
|
+
if (!isPublicAddress(host)) throw new HookUrlError(NOT_PUBLIC);
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
let addresses;
|
|
1661
|
+
try {
|
|
1662
|
+
addresses = await resolve2(host);
|
|
1663
|
+
} catch {
|
|
1664
|
+
throw new HookUrlError(`${host} couldn't be found`, { retryable: true });
|
|
1665
|
+
}
|
|
1666
|
+
if (!addresses.length) throw new HookUrlError(`${host} couldn't be found`, { retryable: true });
|
|
1667
|
+
if (!addresses.every(isPublicAddress)) throw new HookUrlError(NOT_PUBLIC);
|
|
1668
|
+
}
|
|
1669
|
+
function hookHost(url) {
|
|
1670
|
+
return url.hostname.replace(/\.$/, "").toLowerCase();
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1304
1673
|
// src/destinations/webhook.ts
|
|
1305
|
-
var
|
|
1674
|
+
var MIN_WEBHOOK_SECRET_BYTES = 24;
|
|
1306
1675
|
var STRICT_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
1307
1676
|
function secretKey(secret) {
|
|
1308
1677
|
const encoded = secret.startsWith("whsec_") ? secret.slice(6) : secret;
|
|
@@ -1312,16 +1681,27 @@ function secretKey(secret) {
|
|
|
1312
1681
|
);
|
|
1313
1682
|
}
|
|
1314
1683
|
const bytes = fromBase64(encoded);
|
|
1315
|
-
if (bytes.length <
|
|
1684
|
+
if (bytes.length < MIN_WEBHOOK_SECRET_BYTES) {
|
|
1685
|
+
throw new DeliveryError(`The webhook secret is shorter than ${MIN_WEBHOOK_SECRET_BYTES} bytes`);
|
|
1686
|
+
}
|
|
1316
1687
|
return bytes;
|
|
1317
1688
|
}
|
|
1318
1689
|
async function hmacKey(secret, usage) {
|
|
1319
1690
|
return crypto.subtle.importKey("raw", secretKey(secret), { name: "HMAC", hash: "SHA-256" }, false, [usage]);
|
|
1320
1691
|
}
|
|
1692
|
+
function newWebhookSecret() {
|
|
1693
|
+
return `whsec_${base64(crypto.getRandomValues(new Uint8Array(32)))}`;
|
|
1694
|
+
}
|
|
1321
1695
|
async function signWebhook(secret, id, timestamp, body) {
|
|
1322
|
-
const
|
|
1323
|
-
|
|
1324
|
-
|
|
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(" ");
|
|
1325
1705
|
}
|
|
1326
1706
|
async function verifyWebhook(input) {
|
|
1327
1707
|
const header = (name) => {
|
|
@@ -1335,12 +1715,14 @@ async function verifyWebhook(input) {
|
|
|
1335
1715
|
if (!id || !/^\d{1,12}$/.test(timestamp) || !signatures) return false;
|
|
1336
1716
|
const now = Math.floor((input.now?.() ?? Date.now()) / 1e3);
|
|
1337
1717
|
if (Math.abs(now - Number(timestamp)) > (input.toleranceSeconds ?? 300)) return false;
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
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
|
+
}
|
|
1343
1724
|
}
|
|
1725
|
+
if (keys.length === 0) return false;
|
|
1344
1726
|
const data = new TextEncoder().encode(`${id}.${timestamp}.${input.body}`);
|
|
1345
1727
|
for (const part of signatures.split(" ")) {
|
|
1346
1728
|
const [version, value] = part.split(",", 2);
|
|
@@ -1351,7 +1733,7 @@ async function verifyWebhook(input) {
|
|
|
1351
1733
|
} catch {
|
|
1352
1734
|
continue;
|
|
1353
1735
|
}
|
|
1354
|
-
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;
|
|
1355
1737
|
}
|
|
1356
1738
|
return false;
|
|
1357
1739
|
}
|
|
@@ -1416,9 +1798,10 @@ function webhookPayload(submission, definition, options = {}) {
|
|
|
1416
1798
|
for (const [name, value] of Object.entries(source ?? {})) if (own(allowed, name) !== void 0) out[name] = value;
|
|
1417
1799
|
return out;
|
|
1418
1800
|
};
|
|
1801
|
+
const happened = submission.submittedAt ? Date.parse(submission.submittedAt) : Number.NaN;
|
|
1419
1802
|
return {
|
|
1420
1803
|
type: options.type ?? "form.submitted",
|
|
1421
|
-
timestamp: new Date(options.now ?? Date.now()).toISOString(),
|
|
1804
|
+
timestamp: new Date(Number.isNaN(happened) ? options.now ?? Date.now() : happened).toISOString(),
|
|
1422
1805
|
data: {
|
|
1423
1806
|
form: { id: definition.id, title: definition.title },
|
|
1424
1807
|
submission: {
|
|
@@ -1440,15 +1823,17 @@ function webhookDestination(options) {
|
|
|
1440
1823
|
kind: "webhook",
|
|
1441
1824
|
async deliver(submission, definition, config, deliveryOptions = {}) {
|
|
1442
1825
|
try {
|
|
1443
|
-
const
|
|
1444
|
-
const
|
|
1445
|
-
|
|
1826
|
+
const context = { destinationId: deliveryOptions.destinationId };
|
|
1827
|
+
const raw = options.url ? await resolveOption("The webhook url", options.url, config, context) : configString(config, "url");
|
|
1828
|
+
const url = checkWebhookUrl(typeof raw === "string" ? raw.trim() : "", options);
|
|
1829
|
+
if (options.resolveHost && !options.allowPrivateHosts) await checkHookHost(url, options.resolveHost);
|
|
1830
|
+
const secret = await resolveOption("The webhook secret", options.secret, config, context);
|
|
1831
|
+
if (!secret || secret.length === 0) throw new DeliveryError("The webhook secret is not set");
|
|
1446
1832
|
const now = options.now?.() ?? Date.now();
|
|
1447
1833
|
const id = deliveryOptions.deliveryId ?? submission.id;
|
|
1448
1834
|
const timestamp = Math.floor(now / 1e3);
|
|
1449
|
-
const
|
|
1450
|
-
|
|
1451
|
-
);
|
|
1835
|
+
const type = options.type ?? (deliveryOptions.update ? "form.updated" : void 0);
|
|
1836
|
+
const body = JSON.stringify(webhookPayload(submission, definition, { sensitive: options.sensitive, type, now }));
|
|
1452
1837
|
const response = await timedFetch(
|
|
1453
1838
|
options,
|
|
1454
1839
|
url.toString(),
|
|
@@ -1468,6 +1853,9 @@ function webhookDestination(options) {
|
|
|
1468
1853
|
);
|
|
1469
1854
|
discard(response);
|
|
1470
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
|
+
}
|
|
1471
1859
|
if (response.status === 0 || response.status >= 300 && response.status < 400) {
|
|
1472
1860
|
throw new DeliveryError(`Webhook answered ${response.status || "a redirect"}: redirects are not followed`, {
|
|
1473
1861
|
...response.status ? { status: response.status } : {}
|
|
@@ -1481,6 +1869,390 @@ function webhookDestination(options) {
|
|
|
1481
1869
|
};
|
|
1482
1870
|
}
|
|
1483
1871
|
|
|
1484
|
-
|
|
1872
|
+
// src/destinations/slack.ts
|
|
1873
|
+
var SLACK_VALUE_LIMIT = 1500;
|
|
1874
|
+
function escapeSlack(text) {
|
|
1875
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1876
|
+
}
|
|
1877
|
+
var cut = (text) => text.length > SLACK_VALUE_LIMIT ? `${text.slice(0, SLACK_VALUE_LIMIT)}\u2026` : text;
|
|
1878
|
+
function slackMessage(submission, definition, options = {}) {
|
|
1879
|
+
const title = definition.title || definition.id;
|
|
1880
|
+
const heading = options.heading ?? `${options.update ? "Updated response" : "New response"}: ${title}`;
|
|
1881
|
+
const lines = [`*${escapeSlack(heading)}*`];
|
|
1882
|
+
for (const [label, value] of options.lines ?? []) {
|
|
1883
|
+
if (value.trim()) lines.push(`*${escapeSlack(label)}*: ${escapeSlack(cut(value))}`);
|
|
1884
|
+
}
|
|
1885
|
+
const { row, labels } = responseRow(submission, definition, { sensitive: options.sensitive ?? "mask" });
|
|
1886
|
+
const omit = new Set(options.omit ?? []);
|
|
1887
|
+
let answered = 0;
|
|
1888
|
+
for (const [key, label] of Object.entries(labels)) {
|
|
1889
|
+
const text = String(row[key] ?? "");
|
|
1890
|
+
if (!text || omit.has(key)) continue;
|
|
1891
|
+
answered++;
|
|
1892
|
+
lines.push(`*${escapeSlack(label || key)}*: ${escapeSlack(cut(text))}`);
|
|
1893
|
+
}
|
|
1894
|
+
if (!answered) lines.push("_No answers._");
|
|
1895
|
+
const when = submission.submittedAt ? ` \xB7 ${escapeSlack(submission.submittedAt)}` : "";
|
|
1896
|
+
const link = options.link;
|
|
1897
|
+
lines.push(
|
|
1898
|
+
link && /^https?:\/\//i.test(link.url) ? `<${link.url.replace(/[<>|]/g, "")}|${escapeSlack(link.text ?? "Open the response").replace(/[|]/g, "")}>${when}` : `Response ${escapeSlack(submission.id)}${when}`
|
|
1899
|
+
);
|
|
1900
|
+
return { text: lines.join("\n") };
|
|
1901
|
+
}
|
|
1902
|
+
function slackDestination(options = {}) {
|
|
1903
|
+
return {
|
|
1904
|
+
kind: "slack",
|
|
1905
|
+
async deliver(submission, definition, config, deliveryOptions = {}) {
|
|
1906
|
+
try {
|
|
1907
|
+
const context = { destinationId: deliveryOptions.destinationId };
|
|
1908
|
+
const raw = options.url ? await resolveOption("The Slack webhook url", options.url, config, context) : configString(config, "url");
|
|
1909
|
+
if (typeof raw !== "string" || !raw.trim()) throw new DeliveryError("The Slack destination has no url");
|
|
1910
|
+
const url = parseHookUrl(raw, "slack");
|
|
1911
|
+
if (options.resolveHost) await checkHookHost(url, options.resolveHost);
|
|
1912
|
+
const update = deliveryOptions.update === true;
|
|
1913
|
+
const messageOptions = options.message ? await resolveOption(
|
|
1914
|
+
"The Slack message",
|
|
1915
|
+
() => options.message({ submission, definition, config, update, destinationId: deliveryOptions.destinationId }),
|
|
1916
|
+
config
|
|
1917
|
+
) : {};
|
|
1918
|
+
const body = JSON.stringify(slackMessage(submission, definition, { update, ...messageOptions }));
|
|
1919
|
+
const response = await timedFetch(
|
|
1920
|
+
options,
|
|
1921
|
+
url.toString(),
|
|
1922
|
+
{
|
|
1923
|
+
method: "POST",
|
|
1924
|
+
redirect: "manual",
|
|
1925
|
+
headers: { "content-type": "application/json", "user-agent": "genesis-forms-slack/1" },
|
|
1926
|
+
body
|
|
1927
|
+
},
|
|
1928
|
+
deliveryOptions.signal
|
|
1929
|
+
);
|
|
1930
|
+
discard(response);
|
|
1931
|
+
if (response.status >= 200 && response.status < 300) return { ok: true };
|
|
1932
|
+
if (response.status === 0 || response.status >= 300 && response.status < 400) {
|
|
1933
|
+
throw new DeliveryError(`Slack answered ${response.status || "a redirect"}: redirects are not followed`, {
|
|
1934
|
+
...response.status ? { status: response.status } : {}
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
throw httpError("Slack", response);
|
|
1938
|
+
} catch (error) {
|
|
1939
|
+
return deliveryFailure(error);
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
// src/destinations/condition.ts
|
|
1946
|
+
var OPERATORS = /* @__PURE__ */ new Set(["eq", "neq", "lt", "lte", "gt", "gte", "in", "contains", "answered", "empty"]);
|
|
1947
|
+
var MAX_CONDITION_DEPTH = 10;
|
|
1948
|
+
var isObject2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1949
|
+
function isOperand(v) {
|
|
1950
|
+
if (!isObject2(v)) return false;
|
|
1951
|
+
const keys = Object.keys(v);
|
|
1952
|
+
if (keys.length !== 1) return false;
|
|
1953
|
+
if (keys[0] === "field" || keys[0] === "hidden") return typeof v[keys[0]] === "string";
|
|
1954
|
+
return keys[0] === "value";
|
|
1955
|
+
}
|
|
1956
|
+
function isCondition(v, depth = 0) {
|
|
1957
|
+
if (!isObject2(v) || depth > MAX_CONDITION_DEPTH) return false;
|
|
1958
|
+
if ("all" in v) return Array.isArray(v["all"]) && v["all"].every((c) => isCondition(c, depth + 1));
|
|
1959
|
+
if ("any" in v) return Array.isArray(v["any"]) && v["any"].every((c) => isCondition(c, depth + 1));
|
|
1960
|
+
if ("not" in v) return isCondition(v["not"], depth + 1);
|
|
1961
|
+
return typeof v["op"] === "string" && OPERATORS.has(v["op"]) && isOperand(v["left"]) && (v["right"] === void 0 || isOperand(v["right"]));
|
|
1962
|
+
}
|
|
1963
|
+
function conditionOf(config) {
|
|
1964
|
+
const when = config["when"];
|
|
1965
|
+
if (when === void 0 || when === null) return null;
|
|
1966
|
+
if (!isCondition(when)) throw new DeliveryError("The destination's condition can't be read");
|
|
1967
|
+
return when;
|
|
1968
|
+
}
|
|
1969
|
+
function passesCondition(submission, definition, config) {
|
|
1970
|
+
const condition = conditionOf(config);
|
|
1971
|
+
if (!condition) return true;
|
|
1972
|
+
const fields = /* @__PURE__ */ new Map();
|
|
1973
|
+
for (const block of definition.blocks) if (block.kind === "field") fields.set(block.id, block);
|
|
1974
|
+
const hidden = /* @__PURE__ */ Object.create(null);
|
|
1975
|
+
for (const source of [submission.hidden, submission.context]) {
|
|
1976
|
+
for (const name of Object.keys(source ?? {})) {
|
|
1977
|
+
const value = own(source, name);
|
|
1978
|
+
if (value !== void 0) hidden[name] = value;
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
return evaluate(condition, { values: submission.answers, hidden, fields });
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
// src/destinations/google-oauth.ts
|
|
1985
|
+
var DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file";
|
|
1986
|
+
async function refreshGoogleAccessToken(client, refreshToken, options = {}) {
|
|
1987
|
+
if (!client.clientId || !client.clientSecret) throw new DeliveryError("The Google OAuth client id or secret is not set");
|
|
1988
|
+
if (!refreshToken) throw new DeliveryError("This sheet's Google connection is gone. Connect the sheet again.");
|
|
1989
|
+
const response = await timedFetch(
|
|
1990
|
+
options,
|
|
1991
|
+
GOOGLE_TOKEN_URL,
|
|
1992
|
+
{
|
|
1993
|
+
method: "POST",
|
|
1994
|
+
// The body holds the refresh token and client secret: never follow a redirect with it.
|
|
1995
|
+
redirect: "error",
|
|
1996
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
1997
|
+
body: new URLSearchParams({
|
|
1998
|
+
grant_type: "refresh_token",
|
|
1999
|
+
refresh_token: refreshToken,
|
|
2000
|
+
client_id: client.clientId,
|
|
2001
|
+
client_secret: client.clientSecret
|
|
2002
|
+
}).toString()
|
|
2003
|
+
},
|
|
2004
|
+
options.signal
|
|
2005
|
+
);
|
|
2006
|
+
if (!response.ok) {
|
|
2007
|
+
const body2 = jsonObject(await response.json().catch(() => null));
|
|
2008
|
+
if (response.status === 400 && body2.error === "invalid_grant") {
|
|
2009
|
+
throw new DeliveryError("Google access to this sheet was removed. Connect the sheet again.", { status: 400 });
|
|
2010
|
+
}
|
|
2011
|
+
discard(response);
|
|
2012
|
+
throw httpError("Google token endpoint", response);
|
|
2013
|
+
}
|
|
2014
|
+
const body = jsonObject(await response.json().catch(() => null));
|
|
2015
|
+
if (typeof body.access_token !== "string" || !body.access_token) {
|
|
2016
|
+
throw new DeliveryError("Google token endpoint returned no access token", { retryable: true });
|
|
2017
|
+
}
|
|
2018
|
+
return {
|
|
2019
|
+
accessToken: body.access_token,
|
|
2020
|
+
expiresIn: typeof body.expires_in === "number" && body.expires_in > 0 ? body.expires_in : 3600,
|
|
2021
|
+
scope: typeof body.scope === "string" ? body.scope : DRIVE_FILE_SCOPE
|
|
2022
|
+
};
|
|
2023
|
+
}
|
|
2024
|
+
function jsonObject(value) {
|
|
2025
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
2026
|
+
}
|
|
2027
|
+
var cache2 = /* @__PURE__ */ new Map();
|
|
2028
|
+
function clearGoogleOAuthTokenCache() {
|
|
2029
|
+
cache2.clear();
|
|
2030
|
+
}
|
|
2031
|
+
async function googleOAuthAccessToken(client, refreshToken, options = {}) {
|
|
2032
|
+
if (!refreshToken) throw new DeliveryError("This sheet's Google connection is gone. Connect the sheet again.");
|
|
2033
|
+
const now = options.now?.() ?? Date.now();
|
|
2034
|
+
const key = await sha256Hex(`${client.clientId}
|
|
2035
|
+
${refreshToken}`);
|
|
2036
|
+
const hit = cache2.get(key);
|
|
2037
|
+
if (hit && hit.expiresAt - 6e4 > now) return hit.token;
|
|
2038
|
+
const fresh = await refreshGoogleAccessToken(client, refreshToken, options);
|
|
2039
|
+
cache2.set(key, { token: fresh.accessToken, expiresAt: now + fresh.expiresIn * 1e3 });
|
|
2040
|
+
return fresh.accessToken;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
// src/destinations/secret-box.ts
|
|
2044
|
+
var enc = new TextEncoder();
|
|
2045
|
+
function secretKeyBytes(key) {
|
|
2046
|
+
if (typeof key !== "string") {
|
|
2047
|
+
if (key.length !== 32) throw new DeliveryError("The encryption key must be 32 bytes");
|
|
2048
|
+
return new Uint8Array(key);
|
|
2049
|
+
}
|
|
2050
|
+
const text = key.trim().replace(/=+$/, "");
|
|
2051
|
+
if (!text) throw new DeliveryError("The encryption key is not set");
|
|
2052
|
+
let bytes;
|
|
2053
|
+
try {
|
|
2054
|
+
bytes = fromBase64(text + "=".repeat((4 - text.length % 4) % 4));
|
|
2055
|
+
} catch {
|
|
2056
|
+
throw new DeliveryError("The encryption key is not base64");
|
|
2057
|
+
}
|
|
2058
|
+
if (bytes.length !== 32) throw new DeliveryError("The encryption key must be 32 bytes");
|
|
2059
|
+
return bytes;
|
|
2060
|
+
}
|
|
2061
|
+
async function deriveSecretKey(key, info, options = {}) {
|
|
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));
|
|
2064
|
+
const base = await crypto.subtle.importKey("raw", secretKeyBytes(key), "HKDF", false, ["deriveKey"]);
|
|
2065
|
+
return crypto.subtle.deriveKey(
|
|
2066
|
+
{ name: "HKDF", hash: "SHA-256", salt, info: enc.encode(info) },
|
|
2067
|
+
base,
|
|
2068
|
+
{ name: "AES-GCM", length: 256 },
|
|
2069
|
+
false,
|
|
2070
|
+
["encrypt", "decrypt"]
|
|
2071
|
+
);
|
|
2072
|
+
}
|
|
2073
|
+
async function sealSecret(key, plaintext, aad) {
|
|
2074
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
2075
|
+
const sealed = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: enc.encode(aad) }, key, enc.encode(plaintext));
|
|
2076
|
+
return `v1.${base64url(iv)}.${base64url(new Uint8Array(sealed))}`;
|
|
2077
|
+
}
|
|
2078
|
+
async function openSecret(key, sealed, aad) {
|
|
2079
|
+
const [version, iv, body, extra] = sealed.split(".");
|
|
2080
|
+
if (version !== "v1" || !iv || !body || extra !== void 0) throw new DeliveryError("The stored secret is not in a known format");
|
|
2081
|
+
try {
|
|
2082
|
+
const plain = await crypto.subtle.decrypt(
|
|
2083
|
+
{ name: "AES-GCM", iv: padded(iv), additionalData: enc.encode(aad) },
|
|
2084
|
+
key,
|
|
2085
|
+
padded(body)
|
|
2086
|
+
);
|
|
2087
|
+
return new TextDecoder().decode(plain);
|
|
2088
|
+
} catch {
|
|
2089
|
+
throw new DeliveryError("The stored secret could not be decrypted");
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
function padded(text) {
|
|
2093
|
+
return fromBase64(text + "=".repeat((4 - text.length % 4) % 4));
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
// src/destinations/runner.ts
|
|
2097
|
+
var DEFAULT_MAX_ATTEMPTS = 8;
|
|
2098
|
+
var DEFAULT_MAX_DEFERRALS = 24;
|
|
2099
|
+
var DEFAULT_BATCH_SIZE = 25;
|
|
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 deliveryOutcome(result, attempt, options = {}) {
|
|
2106
|
+
if (result.ok) return result.notSent ? { status: "skipped", reason: result.notSent.slice(0, 500) } : { status: "sent" };
|
|
2107
|
+
const http = result.status !== void 0 ? { httpStatus: result.status } : {};
|
|
2108
|
+
if (result.disable) return { status: "failed", error: result.error, ...http, disable: true };
|
|
2109
|
+
if (!result.retryable) return { status: "failed", error: result.error, ...http };
|
|
2110
|
+
const now = options.now ?? Date.now();
|
|
2111
|
+
if (result.retryAfterSeconds !== void 0 && (options.deferrals ?? 0) < (options.maxDeferrals ?? DEFAULT_MAX_DEFERRALS)) {
|
|
2112
|
+
const seconds2 = Math.max(1, Math.ceil(result.retryAfterSeconds));
|
|
2113
|
+
return { status: "retry", error: result.error, retryAt: new Date(now + seconds2 * 1e3), retryAfterSeconds: seconds2, counted: false, ...http };
|
|
2114
|
+
}
|
|
2115
|
+
const max = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
2116
|
+
if (attempt >= max) return { status: "failed", error: `${result.error} (gave up after ${attempt} attempts)`.slice(0, 500), ...http };
|
|
2117
|
+
const seconds = Math.max(1, Math.ceil((options.backoff ?? defaultBackoff)(attempt)));
|
|
2118
|
+
return { status: "retry", error: result.error, retryAt: new Date(now + seconds * 1e3), retryAfterSeconds: seconds, counted: true, ...http };
|
|
2119
|
+
}
|
|
2120
|
+
function createDeliveryRunner(options) {
|
|
2121
|
+
const log = (message) => {
|
|
2122
|
+
try {
|
|
2123
|
+
options.log?.(message);
|
|
2124
|
+
} catch {
|
|
2125
|
+
}
|
|
2126
|
+
};
|
|
2127
|
+
const now = () => options.now?.() ?? Date.now();
|
|
2128
|
+
async function send(group, signal) {
|
|
2129
|
+
const first = group[0];
|
|
2130
|
+
const destination = options.destinations[first.kind];
|
|
2131
|
+
if (!destination) {
|
|
2132
|
+
return group.map(() => ({ ok: false, retryable: false, error: `No destination for kind ${first.kind}` }));
|
|
2133
|
+
}
|
|
2134
|
+
const results = group.map((d) => {
|
|
2135
|
+
try {
|
|
2136
|
+
return passesCondition(d.submission, d.definition, d.config) ? null : "filtered";
|
|
2137
|
+
} catch (error) {
|
|
2138
|
+
return deliveryFailure(error);
|
|
2139
|
+
}
|
|
2140
|
+
});
|
|
2141
|
+
const due = group.filter((_, i) => results[i] === null);
|
|
2142
|
+
let state = first.state;
|
|
2143
|
+
const keep = (result) => {
|
|
2144
|
+
if (result.ok && result.state) state = result.state;
|
|
2145
|
+
};
|
|
2146
|
+
const guard = async (call) => {
|
|
2147
|
+
try {
|
|
2148
|
+
return await call();
|
|
2149
|
+
} catch (error) {
|
|
2150
|
+
return deliveryFailure(error);
|
|
2151
|
+
}
|
|
2152
|
+
};
|
|
2153
|
+
const base = { state, signal, destinationId: first.destinationId };
|
|
2154
|
+
let sent;
|
|
2155
|
+
if (due.length > 1 && destination.deliverBatch) {
|
|
2156
|
+
const items = due.map((d) => ({ submission: d.submission, definition: d.definition, deliveryId: d.id, update: d.update }));
|
|
2157
|
+
const result = await guard(
|
|
2158
|
+
() => destination.deliverBatch(items, first.config, { ...base, redelivery: due.some((d) => d.redelivery) })
|
|
2159
|
+
);
|
|
2160
|
+
keep(result);
|
|
2161
|
+
sent = due.map(() => result);
|
|
2162
|
+
} else {
|
|
2163
|
+
sent = [];
|
|
2164
|
+
for (const d of due) {
|
|
2165
|
+
const result = await guard(
|
|
2166
|
+
() => destination.deliver(d.submission, d.definition, d.config, {
|
|
2167
|
+
...base,
|
|
2168
|
+
state,
|
|
2169
|
+
deliveryId: d.id,
|
|
2170
|
+
redelivery: d.redelivery,
|
|
2171
|
+
update: d.update
|
|
2172
|
+
})
|
|
2173
|
+
);
|
|
2174
|
+
keep(result);
|
|
2175
|
+
sent.push(result);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
if (state !== first.state && state && options.saveState) {
|
|
2179
|
+
try {
|
|
2180
|
+
await options.saveState(first.destinationId, state);
|
|
2181
|
+
} catch {
|
|
2182
|
+
log(`Saving destination ${first.destinationId}'s state failed`);
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
let next = 0;
|
|
2186
|
+
return results.map((r) => r ?? sent[next++]);
|
|
2187
|
+
}
|
|
2188
|
+
return {
|
|
2189
|
+
async run(runOptions = {}) {
|
|
2190
|
+
const report = {
|
|
2191
|
+
claimed: 0,
|
|
2192
|
+
sent: 0,
|
|
2193
|
+
filtered: 0,
|
|
2194
|
+
skipped: 0,
|
|
2195
|
+
retrying: 0,
|
|
2196
|
+
deferred: 0,
|
|
2197
|
+
failed: 0,
|
|
2198
|
+
stale: 0,
|
|
2199
|
+
unrecorded: 0
|
|
2200
|
+
};
|
|
2201
|
+
const limit = runOptions.limit ?? options.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
2202
|
+
const claimed = await options.claim({ limit, filter: runOptions.filter });
|
|
2203
|
+
report.claimed = claimed.length;
|
|
2204
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2205
|
+
for (const delivery of claimed) {
|
|
2206
|
+
const list = groups.get(delivery.destinationId) ?? [];
|
|
2207
|
+
list.push(delivery);
|
|
2208
|
+
groups.set(delivery.destinationId, list);
|
|
2209
|
+
}
|
|
2210
|
+
await Promise.all(
|
|
2211
|
+
[...groups.values()].map(async (group) => {
|
|
2212
|
+
const results = await send(group, runOptions.signal);
|
|
2213
|
+
const at = now();
|
|
2214
|
+
await Promise.all(
|
|
2215
|
+
group.map(async (delivery, i) => {
|
|
2216
|
+
const result = results[i];
|
|
2217
|
+
const outcome = result === "filtered" ? { status: "filtered" } : deliveryOutcome(result, delivery.attempt, {
|
|
2218
|
+
now: at,
|
|
2219
|
+
maxAttempts: options.maxAttempts,
|
|
2220
|
+
backoff: options.backoff,
|
|
2221
|
+
deferrals: delivery.deferrals,
|
|
2222
|
+
maxDeferrals: options.maxDeferrals
|
|
2223
|
+
});
|
|
2224
|
+
if (outcome.status === "sent" && result !== "filtered" && result.ok && result.skipped?.includes(delivery.submission.id)) {
|
|
2225
|
+
outcome.alreadyThere = true;
|
|
2226
|
+
}
|
|
2227
|
+
if (outcome.status === "failed" || outcome.status === "retry") {
|
|
2228
|
+
log(`Delivery ${delivery.id} (${delivery.kind}) ${outcome.status === "failed" ? "failed" : "will be retried"}: ${outcome.error}`);
|
|
2229
|
+
} else if (outcome.status === "skipped") {
|
|
2230
|
+
log(`Delivery ${delivery.id} (${delivery.kind}) skipped: ${outcome.reason}`);
|
|
2231
|
+
}
|
|
2232
|
+
let recorded;
|
|
2233
|
+
try {
|
|
2234
|
+
recorded = await options.record(delivery, outcome);
|
|
2235
|
+
} catch {
|
|
2236
|
+
report.unrecorded++;
|
|
2237
|
+
log(`Recording delivery ${delivery.id} failed`);
|
|
2238
|
+
return;
|
|
2239
|
+
}
|
|
2240
|
+
if (recorded === false) report.stale++;
|
|
2241
|
+
else if (outcome.status === "sent") report.sent++;
|
|
2242
|
+
else if (outcome.status === "filtered") report.filtered++;
|
|
2243
|
+
else if (outcome.status === "skipped") report.skipped++;
|
|
2244
|
+
else if (outcome.status === "failed") report.failed++;
|
|
2245
|
+
else if (outcome.counted) report.retrying++;
|
|
2246
|
+
else report.deferred++;
|
|
2247
|
+
})
|
|
2248
|
+
);
|
|
2249
|
+
})
|
|
2250
|
+
);
|
|
2251
|
+
return report;
|
|
2252
|
+
}
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
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, 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, 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 };
|
|
1485
2257
|
//# sourceMappingURL=destinations.js.map
|
|
1486
2258
|
//# sourceMappingURL=destinations.js.map
|