@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.cjs
CHANGED
|
@@ -6,9 +6,12 @@ var DeliveryError = class extends Error {
|
|
|
6
6
|
retryable;
|
|
7
7
|
retryAfterSeconds;
|
|
8
8
|
status;
|
|
9
|
+
/** The receiver asked to be switched off (`DeliveryFailure.disable`); never retryable. */
|
|
10
|
+
disable;
|
|
9
11
|
constructor(message, options = {}) {
|
|
10
12
|
super(message);
|
|
11
|
-
this.
|
|
13
|
+
this.disable = options.disable ?? false;
|
|
14
|
+
this.retryable = !this.disable && (options.retryable ?? false);
|
|
12
15
|
this.retryAfterSeconds = options.retryAfterSeconds;
|
|
13
16
|
this.status = options.status;
|
|
14
17
|
}
|
|
@@ -49,7 +52,8 @@ function deliveryFailure(error) {
|
|
|
49
52
|
retryable: error.retryable,
|
|
50
53
|
error: error.message.slice(0, 500),
|
|
51
54
|
...error.retryAfterSeconds !== void 0 ? { retryAfterSeconds: error.retryAfterSeconds } : {},
|
|
52
|
-
...error.status !== void 0 ? { status: error.status } : {}
|
|
55
|
+
...error.status !== void 0 ? { status: error.status } : {},
|
|
56
|
+
...error.disable ? { disable: true } : {}
|
|
53
57
|
};
|
|
54
58
|
}
|
|
55
59
|
const name = error instanceof Error ? error.name : "";
|
|
@@ -74,10 +78,10 @@ async function timedFetch(deps, url, init, signal) {
|
|
|
74
78
|
throw error;
|
|
75
79
|
}
|
|
76
80
|
}
|
|
77
|
-
async function resolveOption(what, option, config) {
|
|
81
|
+
async function resolveOption(what, option, config, context = {}) {
|
|
78
82
|
if (typeof option !== "function") return option;
|
|
79
83
|
try {
|
|
80
|
-
return await option(config);
|
|
84
|
+
return await option(config, context);
|
|
81
85
|
} catch (error) {
|
|
82
86
|
if (error instanceof DeliveryError) throw error;
|
|
83
87
|
throw new DeliveryError(`${what} could not be resolved`);
|
|
@@ -114,6 +118,12 @@ async function sha256Hex(text) {
|
|
|
114
118
|
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
115
119
|
}
|
|
116
120
|
|
|
121
|
+
// src/props-check.ts
|
|
122
|
+
new Set(Object.getOwnPropertyNames(Object.prototype));
|
|
123
|
+
var isPlainRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
124
|
+
var isWholeNumber = (v) => typeof v === "number" && Number.isInteger(v);
|
|
125
|
+
var isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
126
|
+
|
|
117
127
|
// src/calculated.ts
|
|
118
128
|
var CALCULATED_TYPE = "calculated";
|
|
119
129
|
var MAX_CALCULATED_DECIMALS = 10;
|
|
@@ -142,6 +152,17 @@ var calculatedFieldType = {
|
|
|
142
152
|
value: { json: true },
|
|
143
153
|
operators: numberOps,
|
|
144
154
|
a11y: { semantics: "control" },
|
|
155
|
+
// A number, unless the format says text; without a format it can hold either, so it isn't known.
|
|
156
|
+
literal: (props) => props.format === "text" ? "string" : props.format === "number" || props.format === "percent" ? "number" : void 0,
|
|
157
|
+
// The formula itself is checked once every key is known (`parseForm`).
|
|
158
|
+
checkProps: (props, check) => {
|
|
159
|
+
if (props.format !== void 0 && props.format !== "number" && props.format !== "percent" && props.format !== "text") {
|
|
160
|
+
check.issue("format", "Must be number, percent or text.");
|
|
161
|
+
}
|
|
162
|
+
if (props.decimals !== void 0 && !(isWholeNumber(props.decimals) && props.decimals >= 0 && props.decimals <= MAX_CALCULATED_DECIMALS)) {
|
|
163
|
+
check.issue("decimals", `Must be a whole number from 0 to ${MAX_CALCULATED_DECIMALS}.`);
|
|
164
|
+
}
|
|
165
|
+
},
|
|
145
166
|
// The engine works the value out; any number, text or true / false it made is valid.
|
|
146
167
|
validate: (value) => typeof value === "number" || typeof value === "string" || typeof value === "boolean" ? null : "This value is worked out from other answers.",
|
|
147
168
|
summarize: (value, field) => formatCalculated(value, field)
|
|
@@ -165,6 +186,7 @@ function listLimits(field) {
|
|
|
165
186
|
return { min: optionalIntProp(field, "min") ?? 0, max: intProp(field, "max", 10), itemMaxLength: intProp(field, "itemMaxLength", 500) };
|
|
166
187
|
}
|
|
167
188
|
var listItems = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string" && v.trim() !== "") : [];
|
|
189
|
+
var MAX_TABLE_COLUMNS = 6;
|
|
168
190
|
var MAX_TABLE_CELL_LENGTH = 2e3;
|
|
169
191
|
function tableColumns(field) {
|
|
170
192
|
const raw = field.props?.columns;
|
|
@@ -198,12 +220,42 @@ var MAX_HIGHLIGHT_NOTE_LENGTH = 1e3;
|
|
|
198
220
|
var MAX_HIGHLIGHT_QUOTE_LENGTH = 2e3;
|
|
199
221
|
var HIGHLIGHT_NOTE_MESSAGE = "Add a note to each highlight.";
|
|
200
222
|
var sourceText = (field) => typeof field.props?.text === "string" && field.props.text ? field.props.text : void 0;
|
|
223
|
+
var listProps = (props, check) => {
|
|
224
|
+
check.optionalInt(props.min, "min", 0);
|
|
225
|
+
check.optionalInt(props.max, "max", 1);
|
|
226
|
+
check.optionalInt(props.itemMaxLength, "itemMaxLength", 1);
|
|
227
|
+
check.optionalText(props.placeholder, "placeholder");
|
|
228
|
+
check.ordered(props.min, props.max ?? 10, "min", "most items");
|
|
229
|
+
};
|
|
230
|
+
var tableProps = (props, check) => {
|
|
231
|
+
const columns = props.columns;
|
|
232
|
+
if (!Array.isArray(columns) || columns.length < 1 || columns.length > MAX_TABLE_COLUMNS) {
|
|
233
|
+
check.issue("columns", `Must be a list of 1 to ${MAX_TABLE_COLUMNS} columns.`);
|
|
234
|
+
} else {
|
|
235
|
+
const seen = /* @__PURE__ */ new Set();
|
|
236
|
+
columns.forEach((c, j) => {
|
|
237
|
+
const at = `columns[${j}]`;
|
|
238
|
+
if (!isPlainRecord(c)) return check.issue(at, "Each column needs a key and a label.");
|
|
239
|
+
if (check.name(c.key, `${at}.key`, "A column key")) {
|
|
240
|
+
if (seen.has(c.key)) check.issue(`${at}.key`, `Duplicate column key "${c.key}".`);
|
|
241
|
+
seen.add(c.key);
|
|
242
|
+
}
|
|
243
|
+
check.text(c.label, `${at}.label`);
|
|
244
|
+
check.optionalBoolean(c.long, `${at}.long`);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
check.optionalInt(props.minRows, "minRows", 0);
|
|
248
|
+
check.optionalInt(props.maxRows, "maxRows", 1);
|
|
249
|
+
check.ordered(props.minRows, props.maxRows ?? 3, "minRows", "most rows");
|
|
250
|
+
};
|
|
201
251
|
var structuredFieldTypes = [
|
|
202
252
|
{
|
|
203
253
|
type: "list",
|
|
204
254
|
label: "List",
|
|
205
255
|
category: "text",
|
|
206
256
|
value: { json: true },
|
|
257
|
+
literal: "string",
|
|
258
|
+
checkProps: listProps,
|
|
207
259
|
// logic.ts `contains` on an array is exact item equality, same as multi-choice, so it is offered.
|
|
208
260
|
operators: ["contains", "answered", "empty"],
|
|
209
261
|
a11y: { semantics: "group" },
|
|
@@ -229,6 +281,7 @@ var structuredFieldTypes = [
|
|
|
229
281
|
label: "Table",
|
|
230
282
|
category: "advanced",
|
|
231
283
|
value: { json: true },
|
|
284
|
+
checkProps: tableProps,
|
|
232
285
|
operators: ["answered", "empty"],
|
|
233
286
|
a11y: { semantics: "group" },
|
|
234
287
|
isEmpty: (value) => emptyList(value, (r) => isObject(r) && Object.values(r).every(blankText)),
|
|
@@ -272,6 +325,10 @@ var structuredFieldTypes = [
|
|
|
272
325
|
label: "Notes at moments",
|
|
273
326
|
category: "media",
|
|
274
327
|
value: { json: true },
|
|
328
|
+
checkProps: (props, check) => {
|
|
329
|
+
check.optionalInt(props.maxNotes, "maxNotes", 1);
|
|
330
|
+
check.optionalInt(props.textMaxLength, "textMaxLength", 1);
|
|
331
|
+
},
|
|
275
332
|
operators: ["answered", "empty"],
|
|
276
333
|
a11y: { semantics: "group" },
|
|
277
334
|
isEmpty: (value) => emptyList(value, (n) => isObject(n) && blankText(n.text)),
|
|
@@ -295,6 +352,10 @@ var structuredFieldTypes = [
|
|
|
295
352
|
label: "Highlights",
|
|
296
353
|
category: "media",
|
|
297
354
|
value: { json: true },
|
|
355
|
+
checkProps: (props, check) => {
|
|
356
|
+
check.optionalText(props.text, "text");
|
|
357
|
+
check.optionalInt(props.maxHighlights, "maxHighlights", 1);
|
|
358
|
+
},
|
|
298
359
|
operators: ["answered", "empty"],
|
|
299
360
|
a11y: { semantics: "group" },
|
|
300
361
|
isEmpty: (value) => emptyList(value, () => false),
|
|
@@ -344,6 +405,7 @@ var DEFAULT_PHOTO_SIZE_MB = 10;
|
|
|
344
405
|
var MAX_PHOTO_SIZE_MB = 25;
|
|
345
406
|
var MAX_PHOTO_ID_LENGTH = 200;
|
|
346
407
|
var MAX_PHOTO_NAME_LENGTH = 255;
|
|
408
|
+
var isPhotoFormat = (v) => typeof v === "string" && Object.prototype.hasOwnProperty.call(PHOTO_FORMATS, v);
|
|
347
409
|
var wholeIn = (v, min, max) => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max;
|
|
348
410
|
function photoLimits(field) {
|
|
349
411
|
const maxFiles = wholeIn(field.props?.maxFiles, 1, MAX_PHOTO_FILES) ? field.props.maxFiles : DEFAULT_PHOTO_FILES;
|
|
@@ -385,6 +447,22 @@ var photoFieldType = {
|
|
|
385
447
|
value: { json: true },
|
|
386
448
|
operators: ["answered", "empty"],
|
|
387
449
|
a11y: { semantics: "group" },
|
|
450
|
+
checkProps: (props, check) => {
|
|
451
|
+
if (props.maxFiles !== void 0 && !(isWholeNumber(props.maxFiles) && props.maxFiles >= 1 && props.maxFiles <= MAX_PHOTO_FILES)) {
|
|
452
|
+
check.issue("maxFiles", `Must be a whole number from 1 to ${MAX_PHOTO_FILES}.`);
|
|
453
|
+
}
|
|
454
|
+
if (props.maxSizeMb !== void 0 && !(isWholeNumber(props.maxSizeMb) && props.maxSizeMb >= 1 && props.maxSizeMb <= MAX_PHOTO_SIZE_MB)) {
|
|
455
|
+
check.issue("maxSizeMb", `Must be a whole number from 1 to ${MAX_PHOTO_SIZE_MB}.`);
|
|
456
|
+
}
|
|
457
|
+
if (props.formats !== void 0) {
|
|
458
|
+
const formats = props.formats;
|
|
459
|
+
if (!Array.isArray(formats) || formats.length === 0 || !formats.every(isPhotoFormat)) {
|
|
460
|
+
check.issue("formats", "Must be a list of one or more of jpeg, png, heic and webp.");
|
|
461
|
+
} else if (new Set(formats).size !== formats.length) {
|
|
462
|
+
check.issue("formats", "Name each format once.");
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
},
|
|
388
466
|
isEmpty: (value) => value === void 0 || value === null || Array.isArray(value) && value.length === 0,
|
|
389
467
|
validate: (value, field) => {
|
|
390
468
|
if (!Array.isArray(value) || !value.every(isPhotoRef)) return "Add a photo.";
|
|
@@ -402,22 +480,37 @@ var photoFieldType = {
|
|
|
402
480
|
};
|
|
403
481
|
|
|
404
482
|
// src/fields.ts
|
|
405
|
-
var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
|
|
483
|
+
var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types@2");
|
|
484
|
+
var BUILT_IN = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/built-in-field-types@2");
|
|
406
485
|
var globalScope = globalThis;
|
|
407
486
|
var registry = globalScope[REGISTRY] ??= /* @__PURE__ */ new Map();
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
);
|
|
487
|
+
var builtIn = globalScope[BUILT_IN] ??= /* @__PURE__ */ new Set();
|
|
488
|
+
var freezeRecord = (v) => v && typeof v === "object" && !Array.isArray(v) ? Object.freeze({ ...v }) : v;
|
|
489
|
+
function frozen(manifest) {
|
|
490
|
+
const copy = { ...manifest };
|
|
491
|
+
for (const key of ["choice", "a11y", "value"]) if (key in copy) copy[key] = freezeRecord(copy[key]);
|
|
492
|
+
if (Array.isArray(copy.operators)) {
|
|
493
|
+
const operators = Object.freeze([...copy.operators]);
|
|
494
|
+
Object.defineProperty(copy, "operators", { get: () => [...operators], enumerable: true });
|
|
415
495
|
}
|
|
416
|
-
|
|
496
|
+
return Object.freeze(copy);
|
|
497
|
+
}
|
|
498
|
+
function registerBuiltIn(manifest) {
|
|
499
|
+
builtIn.add(manifest.type);
|
|
500
|
+
if (!registry.has(manifest.type)) registry.set(manifest.type, frozen(manifest));
|
|
417
501
|
}
|
|
418
502
|
function getFieldType(type) {
|
|
419
503
|
return registry.get(type);
|
|
420
504
|
}
|
|
505
|
+
var CONTENT_REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/content-types@1");
|
|
506
|
+
var contentScope = globalThis;
|
|
507
|
+
var contentRegistry = contentScope[CONTENT_REGISTRY] ??= /* @__PURE__ */ new Map();
|
|
508
|
+
for (const manifest of [
|
|
509
|
+
{ type: "heading", label: "Heading" },
|
|
510
|
+
{ type: "text", label: "Text" }
|
|
511
|
+
]) {
|
|
512
|
+
if (!contentRegistry.has(manifest.type)) contentRegistry.set(manifest.type, frozen(manifest));
|
|
513
|
+
}
|
|
421
514
|
function isEmptyValue(value, field) {
|
|
422
515
|
const manifest = field ? registry.get(field.type) : void 0;
|
|
423
516
|
if (manifest?.isEmpty) return manifest.isEmpty(value);
|
|
@@ -550,6 +643,18 @@ function isPhoneNumber(value) {
|
|
|
550
643
|
}
|
|
551
644
|
return digits >= 7 && digits <= 15;
|
|
552
645
|
}
|
|
646
|
+
var oneOptionValidate = (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.";
|
|
647
|
+
var oneOptionSummary = (value, field) => optionLabel(field, String(value));
|
|
648
|
+
var optionsValidate = (value, field) => {
|
|
649
|
+
const allowed = new Set(choiceOptions(field).map((o) => o.value));
|
|
650
|
+
const others = Array.isArray(value) ? value.filter((v) => typeof v === "string" && !allowed.has(v)) : [];
|
|
651
|
+
return Array.isArray(value) && new Set(value).size === value.length && value.every((v) => typeof v === "string") && (others.length === 0 || others.length === 1 && typedOther(field, others[0])) ? null : "Choose from the options.";
|
|
652
|
+
};
|
|
653
|
+
var optionsSummary = (value, field) => Array.isArray(value) ? value.map((v) => optionLabel(field, String(v))).join(", ") : String(value);
|
|
654
|
+
var scaleProps = (props, check) => {
|
|
655
|
+
check.optionalText(props.lowLabel, "lowLabel");
|
|
656
|
+
check.optionalText(props.highLabel, "highLabel");
|
|
657
|
+
};
|
|
553
658
|
var builtins = [
|
|
554
659
|
{
|
|
555
660
|
type: "short-text",
|
|
@@ -558,6 +663,7 @@ var builtins = [
|
|
|
558
663
|
value: { json: true },
|
|
559
664
|
operators: textOps,
|
|
560
665
|
a11y: { semantics: "control" },
|
|
666
|
+
literal: "string",
|
|
561
667
|
validate: textValidate(500)
|
|
562
668
|
},
|
|
563
669
|
{
|
|
@@ -567,6 +673,7 @@ var builtins = [
|
|
|
567
673
|
value: { json: true },
|
|
568
674
|
operators: textOps,
|
|
569
675
|
a11y: { semantics: "control" },
|
|
676
|
+
literal: "string",
|
|
570
677
|
validate: textValidate(5e3)
|
|
571
678
|
},
|
|
572
679
|
{
|
|
@@ -576,6 +683,7 @@ var builtins = [
|
|
|
576
683
|
value: { json: true },
|
|
577
684
|
operators: textOps,
|
|
578
685
|
a11y: { semantics: "control", autocomplete: "email", inputMode: "email" },
|
|
686
|
+
literal: "string",
|
|
579
687
|
validate: (value) => typeof value === "string" && isEmailAddress(value.trim()) ? null : "Enter an email address like name@example.com.",
|
|
580
688
|
normalize: (value) => typeof value === "string" ? value.trim() : value
|
|
581
689
|
},
|
|
@@ -586,6 +694,7 @@ var builtins = [
|
|
|
586
694
|
value: { json: true },
|
|
587
695
|
operators: ["eq", "neq", "answered", "empty"],
|
|
588
696
|
a11y: { semantics: "control", autocomplete: "tel", inputMode: "tel" },
|
|
697
|
+
literal: "string",
|
|
589
698
|
validate: (value) => typeof value === "string" && isPhoneNumber(value.trim()) ? null : "Enter a phone number with 7 to 15 digits.",
|
|
590
699
|
normalize: (value) => typeof value === "string" ? value.trim() : value,
|
|
591
700
|
summarize: (value) => String(value)
|
|
@@ -597,6 +706,13 @@ var builtins = [
|
|
|
597
706
|
value: { json: true },
|
|
598
707
|
operators: numberOps2,
|
|
599
708
|
a11y: { semantics: "group" },
|
|
709
|
+
literal: "number",
|
|
710
|
+
checkProps: (props, check) => {
|
|
711
|
+
scaleProps(props, check);
|
|
712
|
+
if (props.display !== void 0 && props.display !== "numbers" && props.display !== "stars") {
|
|
713
|
+
check.issue("display", "Must be numbers or stars.");
|
|
714
|
+
}
|
|
715
|
+
},
|
|
600
716
|
validate: (value, field) => {
|
|
601
717
|
const { min, max } = intRange(field, 1, 5);
|
|
602
718
|
return Number.isInteger(value) && value >= min && value <= max ? null : `Choose a rating from ${min} to ${max}.`;
|
|
@@ -614,6 +730,8 @@ var builtins = [
|
|
|
614
730
|
value: { json: true },
|
|
615
731
|
operators: numberOps2,
|
|
616
732
|
a11y: { semantics: "group" },
|
|
733
|
+
literal: "number",
|
|
734
|
+
checkProps: scaleProps,
|
|
617
735
|
validate: (value) => Number.isInteger(value) && value >= 0 && value <= 10 ? null : "Choose a score from 0 to 10.",
|
|
618
736
|
summarize: (value, field) => {
|
|
619
737
|
const end = scaleEndLabel(field, value, 0, 10);
|
|
@@ -627,8 +745,10 @@ var builtins = [
|
|
|
627
745
|
value: { json: true },
|
|
628
746
|
operators: ["eq", "neq", "in", "answered", "empty"],
|
|
629
747
|
a11y: { semantics: "group" },
|
|
630
|
-
|
|
631
|
-
|
|
748
|
+
literal: "string",
|
|
749
|
+
choice: { multiple: false },
|
|
750
|
+
validate: oneOptionValidate,
|
|
751
|
+
summarize: oneOptionSummary
|
|
632
752
|
},
|
|
633
753
|
{
|
|
634
754
|
type: "multi-choice",
|
|
@@ -637,12 +757,10 @@ var builtins = [
|
|
|
637
757
|
value: { json: true },
|
|
638
758
|
operators: ["contains", "answered", "empty"],
|
|
639
759
|
a11y: { semantics: "group" },
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
},
|
|
645
|
-
summarize: (value, field) => Array.isArray(value) ? value.map((v) => optionLabel(field, String(v))).join(", ") : String(value)
|
|
760
|
+
literal: "string",
|
|
761
|
+
choice: { multiple: true },
|
|
762
|
+
validate: optionsValidate,
|
|
763
|
+
summarize: optionsSummary
|
|
646
764
|
},
|
|
647
765
|
{
|
|
648
766
|
type: "yes-no",
|
|
@@ -651,6 +769,7 @@ var builtins = [
|
|
|
651
769
|
value: { json: true },
|
|
652
770
|
operators: ["eq", "neq", "answered", "empty"],
|
|
653
771
|
a11y: { semantics: "group" },
|
|
772
|
+
literal: "boolean",
|
|
654
773
|
validate: (value) => typeof value === "boolean" ? null : "Choose yes or no.",
|
|
655
774
|
summarize: (value) => value ? "Yes" : "No"
|
|
656
775
|
},
|
|
@@ -661,8 +780,10 @@ var builtins = [
|
|
|
661
780
|
value: { json: true },
|
|
662
781
|
operators: ["eq", "neq", "in", "answered", "empty"],
|
|
663
782
|
a11y: { semantics: "control" },
|
|
664
|
-
|
|
665
|
-
|
|
783
|
+
literal: "string",
|
|
784
|
+
choice: { multiple: false },
|
|
785
|
+
validate: oneOptionValidate,
|
|
786
|
+
summarize: oneOptionSummary
|
|
666
787
|
},
|
|
667
788
|
{
|
|
668
789
|
type: "number",
|
|
@@ -671,6 +792,16 @@ var builtins = [
|
|
|
671
792
|
value: { json: true },
|
|
672
793
|
operators: numberOps2,
|
|
673
794
|
a11y: { semantics: "control", inputMode: "decimal" },
|
|
795
|
+
literal: "number",
|
|
796
|
+
checkProps: (props, check) => {
|
|
797
|
+
for (const k of ["min", "max", "step"]) {
|
|
798
|
+
if (props[k] !== void 0 && !isFiniteNumber(props[k])) check.issue(k, "Must be a number.");
|
|
799
|
+
}
|
|
800
|
+
if (isFiniteNumber(props.step) && props.step <= 0) check.issue("step", "Must be above 0.");
|
|
801
|
+
if (isFiniteNumber(props.min) && isFiniteNumber(props.max) && props.min > props.max) {
|
|
802
|
+
check.issue("min", "Can't be more than the largest number.");
|
|
803
|
+
}
|
|
804
|
+
},
|
|
674
805
|
validate: (value, field) => {
|
|
675
806
|
if (typeof value !== "number" || !Number.isFinite(value)) return "Enter a number.";
|
|
676
807
|
const { min, max, step } = numberLimits(field);
|
|
@@ -697,6 +828,17 @@ var builtins = [
|
|
|
697
828
|
value: { json: true },
|
|
698
829
|
operators: ["eq", "neq", "answered", "empty"],
|
|
699
830
|
a11y: { semantics: "control" },
|
|
831
|
+
literal: "string",
|
|
832
|
+
checkProps: (props, check) => {
|
|
833
|
+
for (const k of ["min", "max"]) {
|
|
834
|
+
if (props[k] !== void 0 && !(typeof props[k] === "string" && isIsoDate(props[k]))) {
|
|
835
|
+
check.issue(k, "Must be a date like 2026-10-22.");
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
if (typeof props.min === "string" && typeof props.max === "string" && isIsoDate(props.min) && isIsoDate(props.max) && props.min > props.max) {
|
|
839
|
+
check.issue("min", "Can't be after the latest date.");
|
|
840
|
+
}
|
|
841
|
+
},
|
|
700
842
|
validate: (value, field) => {
|
|
701
843
|
if (typeof value !== "string" || !isIsoDate(value.trim())) return "Enter a date like 2026-10-22.";
|
|
702
844
|
const date = value.trim();
|
|
@@ -719,6 +861,8 @@ var builtins = [
|
|
|
719
861
|
value: { json: true },
|
|
720
862
|
operators: ["answered", "empty"],
|
|
721
863
|
a11y: { semantics: "group" },
|
|
864
|
+
literal: "boolean",
|
|
865
|
+
checkProps: (props, check) => check.optionalText(props.text, "text"),
|
|
722
866
|
// Unchecked is unanswered, so a required consent must be checked.
|
|
723
867
|
isEmpty: (value) => value === void 0 || value === null || value === false,
|
|
724
868
|
validate: (value) => value === true ? null : "Check the box to agree.",
|
|
@@ -768,12 +912,59 @@ function dateLimits(field) {
|
|
|
768
912
|
if (typeof max === "string" && isIsoDate(max)) out.max = max;
|
|
769
913
|
return out;
|
|
770
914
|
}
|
|
771
|
-
for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType, photoFieldType])
|
|
915
|
+
for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType, photoFieldType]) registerBuiltIn(manifest);
|
|
772
916
|
|
|
773
917
|
// src/logic.ts
|
|
774
918
|
function own(record, key) {
|
|
775
919
|
return Object.prototype.hasOwnProperty.call(record, key) ? record[key] : void 0;
|
|
776
920
|
}
|
|
921
|
+
function resolve(operand, scope) {
|
|
922
|
+
if ("field" in operand) return own(scope.values, operand.field);
|
|
923
|
+
if ("hidden" in operand) return own(scope.hidden, operand.hidden);
|
|
924
|
+
return operand.value;
|
|
925
|
+
}
|
|
926
|
+
function deepEqual(a, b) {
|
|
927
|
+
if (a === b) return true;
|
|
928
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
929
|
+
return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
|
|
930
|
+
}
|
|
931
|
+
return false;
|
|
932
|
+
}
|
|
933
|
+
function evaluate(condition, scope) {
|
|
934
|
+
if ("all" in condition) return condition.all.every((c) => evaluate(c, scope));
|
|
935
|
+
if ("any" in condition) return condition.any.some((c) => evaluate(c, scope));
|
|
936
|
+
if ("not" in condition) return !evaluate(condition.not, scope);
|
|
937
|
+
const left = resolve(condition.left, scope);
|
|
938
|
+
const right = condition.right ? resolve(condition.right, scope) : void 0;
|
|
939
|
+
const field = "field" in condition.left ? scope.fields.get(condition.left.field) : void 0;
|
|
940
|
+
const unanswered = isEmptyValue(left, field);
|
|
941
|
+
switch (condition.op) {
|
|
942
|
+
case "answered":
|
|
943
|
+
return !unanswered;
|
|
944
|
+
case "empty":
|
|
945
|
+
return unanswered;
|
|
946
|
+
case "eq":
|
|
947
|
+
return !unanswered && deepEqual(left, right);
|
|
948
|
+
case "neq":
|
|
949
|
+
return unanswered || !deepEqual(left, right);
|
|
950
|
+
case "lt":
|
|
951
|
+
case "lte":
|
|
952
|
+
case "gt":
|
|
953
|
+
case "gte": {
|
|
954
|
+
if (typeof left !== "number" || typeof right !== "number") return false;
|
|
955
|
+
if (condition.op === "lt") return left < right;
|
|
956
|
+
if (condition.op === "lte") return left <= right;
|
|
957
|
+
if (condition.op === "gt") return left > right;
|
|
958
|
+
return left >= right;
|
|
959
|
+
}
|
|
960
|
+
case "in":
|
|
961
|
+
return !unanswered && Array.isArray(right) && right.some((r) => deepEqual(r, left));
|
|
962
|
+
case "contains":
|
|
963
|
+
if (unanswered) return false;
|
|
964
|
+
if (Array.isArray(left)) return left.some((l) => deepEqual(l, right));
|
|
965
|
+
return typeof left === "string" && typeof right === "string" && left.toLowerCase().includes(right.toLowerCase());
|
|
966
|
+
}
|
|
967
|
+
}
|
|
777
968
|
function withheld(def, field) {
|
|
778
969
|
return def.settings?.anonymous === true && field?.identifying === true;
|
|
779
970
|
}
|
|
@@ -1141,9 +1332,20 @@ function sheetsDestination(options) {
|
|
|
1141
1332
|
async function run(items, config, deliveryOptions) {
|
|
1142
1333
|
if (!items.length) return { ok: true };
|
|
1143
1334
|
const { spreadsheetId, sheetName } = configOf(config);
|
|
1144
|
-
const account = await resolveOption("The sheet destination's account", options.account, config);
|
|
1145
1335
|
const signal = deliveryOptions.signal;
|
|
1146
|
-
const
|
|
1336
|
+
const context = { destinationId: deliveryOptions.destinationId };
|
|
1337
|
+
let account = null;
|
|
1338
|
+
let token;
|
|
1339
|
+
if (options.accessToken) {
|
|
1340
|
+
token = await resolveOption("The sheet destination's access token", options.accessToken, config, context);
|
|
1341
|
+
if (typeof token !== "string" || !token) throw new DeliveryError("The sheet destination has no access token");
|
|
1342
|
+
} else if (options.account) {
|
|
1343
|
+
account = await resolveOption("The sheet destination's account", options.account, config, context);
|
|
1344
|
+
token = await googleAccessToken(account, { ...options, signal });
|
|
1345
|
+
} else {
|
|
1346
|
+
throw new DeliveryError("The sheet destination has neither an account nor an access token");
|
|
1347
|
+
}
|
|
1348
|
+
const sharedWith = account ? `is the sheet shared with ${account.clientEmail}?` : "is the sheet still connected to its Google account?";
|
|
1147
1349
|
const base = `${SHEETS_API}/${encodeURIComponent(spreadsheetId)}`;
|
|
1148
1350
|
const escape = options.escapeFormulas === true;
|
|
1149
1351
|
const call = async (what, url, init = {}) => {
|
|
@@ -1155,12 +1357,12 @@ function sheetsDestination(options) {
|
|
|
1155
1357
|
);
|
|
1156
1358
|
if (!response.ok) {
|
|
1157
1359
|
discard(response);
|
|
1158
|
-
if (what === "append" && (response.status === 400 || response.status === 404)) {
|
|
1360
|
+
if ((what === "append" || what === "update rows") && (response.status === 400 || response.status === 404)) {
|
|
1159
1361
|
throw new StaleSheetError(`Google Sheets append answered ${response.status}`, { status: response.status });
|
|
1160
1362
|
}
|
|
1161
1363
|
if (response.status === 403 || response.status === 404) {
|
|
1162
1364
|
throw new DeliveryError(
|
|
1163
|
-
`Google Sheets ${what} answered ${response.status}:
|
|
1365
|
+
`Google Sheets ${what} answered ${response.status}: ${sharedWith}`,
|
|
1164
1366
|
{ status: response.status }
|
|
1165
1367
|
);
|
|
1166
1368
|
}
|
|
@@ -1256,14 +1458,45 @@ function sheetsDestination(options) {
|
|
|
1256
1458
|
}
|
|
1257
1459
|
let skipped = [];
|
|
1258
1460
|
async function append(state2) {
|
|
1259
|
-
|
|
1260
|
-
|
|
1461
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1462
|
+
items.forEach((item, i) => {
|
|
1463
|
+
const id = item.submission.id;
|
|
1464
|
+
const update = (item.update ?? deliveryOptions.update) === true || byId.get(id)?.update === true;
|
|
1465
|
+
byId.delete(id);
|
|
1466
|
+
byId.set(id, { id, row: rows[i].row, update });
|
|
1467
|
+
});
|
|
1468
|
+
let pending = [...byId.values()];
|
|
1469
|
+
const updating = pending.some((p) => p.update);
|
|
1470
|
+
const overwrite = [];
|
|
1471
|
+
skipped = [];
|
|
1472
|
+
if (deliveryOptions.redelivery || updating) {
|
|
1261
1473
|
const column = columnLetter(state2.header.indexOf("id"));
|
|
1262
|
-
const from = state2.last_row > 0 ? Math.max(2, state2.last_row - DEDUPE_WINDOW) : 2;
|
|
1474
|
+
const from = !updating && state2.last_row > 0 ? Math.max(2, state2.last_row - DEDUPE_WINDOW) : 2;
|
|
1263
1475
|
const ids = await call("read ids", range(`${column}${from}:${column}`));
|
|
1264
|
-
const
|
|
1265
|
-
|
|
1266
|
-
|
|
1476
|
+
const rowOf = /* @__PURE__ */ new Map();
|
|
1477
|
+
(ids.values ?? []).forEach((r, i) => {
|
|
1478
|
+
const id = String(r?.[0] ?? "");
|
|
1479
|
+
if (id && !rowOf.has(id)) rowOf.set(id, from + i);
|
|
1480
|
+
});
|
|
1481
|
+
pending = pending.filter((p) => {
|
|
1482
|
+
const at = rowOf.get(p.id);
|
|
1483
|
+
if (at === void 0) return true;
|
|
1484
|
+
if (p.update) overwrite.push({ at, row: p.row });
|
|
1485
|
+
else skipped.push(p.id);
|
|
1486
|
+
return false;
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
if (overwrite.length) {
|
|
1490
|
+
await call("update rows", `${base}/values:batchUpdate`, {
|
|
1491
|
+
method: "POST",
|
|
1492
|
+
body: JSON.stringify({
|
|
1493
|
+
valueInputOption: "RAW",
|
|
1494
|
+
data: overwrite.map((o) => ({
|
|
1495
|
+
range: `${quoteSheet(sheetName)}!A${o.at}`,
|
|
1496
|
+
values: [rowFor(state2.header, o.row, escape)]
|
|
1497
|
+
}))
|
|
1498
|
+
})
|
|
1499
|
+
});
|
|
1267
1500
|
}
|
|
1268
1501
|
if (!pending.length) return state2;
|
|
1269
1502
|
const result = await call(
|
|
@@ -1298,13 +1531,149 @@ function sheetsDestination(options) {
|
|
|
1298
1531
|
};
|
|
1299
1532
|
return {
|
|
1300
1533
|
kind: "sheet",
|
|
1301
|
-
deliver: (submission, definition, config, deliveryOptions = {}) => guarded(
|
|
1534
|
+
deliver: (submission, definition, config, deliveryOptions = {}) => guarded(
|
|
1535
|
+
[{ submission, definition, deliveryId: deliveryOptions.deliveryId, update: deliveryOptions.update }],
|
|
1536
|
+
config,
|
|
1537
|
+
deliveryOptions
|
|
1538
|
+
),
|
|
1302
1539
|
deliverBatch: (items, config, deliveryOptions = {}) => guarded(items, config, deliveryOptions)
|
|
1303
1540
|
};
|
|
1304
1541
|
}
|
|
1305
1542
|
|
|
1543
|
+
// src/destinations/hook-url.ts
|
|
1544
|
+
var MAX_HOOK_URL_LENGTH = 2048;
|
|
1545
|
+
var SLACK_HOSTS = /* @__PURE__ */ new Set(["hooks.slack.com"]);
|
|
1546
|
+
var HookUrlError = class extends DeliveryError {
|
|
1547
|
+
name = "HookUrlError";
|
|
1548
|
+
};
|
|
1549
|
+
var NOT_PUBLIC = "The address must be on the public internet.";
|
|
1550
|
+
var INTERNAL_SUFFIXES = [".localhost", ".local", ".internal", ".intranet", ".lan", ".home.arpa"];
|
|
1551
|
+
function ipv4Bytes(ip) {
|
|
1552
|
+
const parts = ip.split(".");
|
|
1553
|
+
if (parts.length !== 4) return null;
|
|
1554
|
+
const bytes = parts.map((p) => /^\d{1,3}$/.test(p) ? Number(p) : NaN);
|
|
1555
|
+
return bytes.every((b) => Number.isInteger(b) && b >= 0 && b <= 255) ? bytes : null;
|
|
1556
|
+
}
|
|
1557
|
+
function publicIpv4(b) {
|
|
1558
|
+
const [a, c] = [b[0], b[1]];
|
|
1559
|
+
if (a === 0 || a === 10 || a === 127) return false;
|
|
1560
|
+
if (a === 100 && c >= 64 && c <= 127) return false;
|
|
1561
|
+
if (a === 169 && c === 254) return false;
|
|
1562
|
+
if (a === 172 && c >= 16 && c <= 31) return false;
|
|
1563
|
+
if (a === 192 && c === 168) return false;
|
|
1564
|
+
if (a === 192 && c === 0 && (b[2] === 0 || b[2] === 2)) return false;
|
|
1565
|
+
if (a === 198 && (c === 18 || c === 19)) return false;
|
|
1566
|
+
if (a === 198 && c === 51 && b[2] === 100) return false;
|
|
1567
|
+
if (a === 203 && c === 0 && b[2] === 113) return false;
|
|
1568
|
+
if (a >= 224) return false;
|
|
1569
|
+
return true;
|
|
1570
|
+
}
|
|
1571
|
+
function ipv6Bytes(ip) {
|
|
1572
|
+
let text = ip.replace(/^\[|\]$/g, "").toLowerCase();
|
|
1573
|
+
const zone = text.indexOf("%");
|
|
1574
|
+
if (zone >= 0) text = text.slice(0, zone);
|
|
1575
|
+
let tail = [];
|
|
1576
|
+
const lastColon = text.lastIndexOf(":");
|
|
1577
|
+
if (text.slice(lastColon + 1).includes(".")) {
|
|
1578
|
+
const v4 = ipv4Bytes(text.slice(lastColon + 1));
|
|
1579
|
+
if (!v4) return null;
|
|
1580
|
+
tail = v4;
|
|
1581
|
+
text = text.slice(0, lastColon + 1) + "0:0";
|
|
1582
|
+
}
|
|
1583
|
+
const halves = text.split("::");
|
|
1584
|
+
if (halves.length > 2) return null;
|
|
1585
|
+
const groups = (s) => s === "" ? [] : s.split(":");
|
|
1586
|
+
const head = groups(halves[0]);
|
|
1587
|
+
const rest = halves.length === 2 ? groups(halves[1]) : [];
|
|
1588
|
+
const missing = 8 - head.length - rest.length;
|
|
1589
|
+
if (halves.length === 1 ? missing !== 0 : missing < 1) return null;
|
|
1590
|
+
const all = [...head, ...Array(halves.length === 2 ? missing : 0).fill("0"), ...rest];
|
|
1591
|
+
const bytes = [];
|
|
1592
|
+
for (const g of all) {
|
|
1593
|
+
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
|
|
1594
|
+
const n = parseInt(g, 16);
|
|
1595
|
+
bytes.push(n >> 8, n & 255);
|
|
1596
|
+
}
|
|
1597
|
+
if (tail.length) bytes.splice(12, 4, ...tail);
|
|
1598
|
+
return bytes;
|
|
1599
|
+
}
|
|
1600
|
+
function publicIpv6(b) {
|
|
1601
|
+
const zeros = (from, to) => b.slice(from, to).every((x) => x === 0);
|
|
1602
|
+
if (zeros(0, 16)) return false;
|
|
1603
|
+
if (zeros(0, 15) && b[15] === 1) return false;
|
|
1604
|
+
if (zeros(0, 10) && b[10] === 255 && b[11] === 255) return publicIpv4(b.slice(12));
|
|
1605
|
+
if (zeros(0, 12)) return publicIpv4(b.slice(12));
|
|
1606
|
+
if (b[0] === 0 && b[1] === 100 && b[2] === 255 && b[3] === 155 && zeros(4, 12)) return publicIpv4(b.slice(12));
|
|
1607
|
+
if ((b[0] & 254) === 252) return false;
|
|
1608
|
+
if (b[0] === 254 && (b[1] & 192) === 128) return false;
|
|
1609
|
+
if (b[0] === 254 && (b[1] & 192) === 192) return false;
|
|
1610
|
+
if (b[0] === 255) return false;
|
|
1611
|
+
if (b[0] === 32 && b[1] === 1 && b[2] === 13 && b[3] === 184) return false;
|
|
1612
|
+
if (b[0] === 32 && b[1] === 1 && b[2] === 0 && b[3] === 0) return false;
|
|
1613
|
+
if (b[0] === 32 && b[1] === 2) return publicIpv4(b.slice(2, 6));
|
|
1614
|
+
if (b[0] === 1 && zeros(1, 8)) return false;
|
|
1615
|
+
return true;
|
|
1616
|
+
}
|
|
1617
|
+
function isPublicAddress(ip) {
|
|
1618
|
+
const v4 = ipv4Bytes(ip);
|
|
1619
|
+
if (v4) return publicIpv4(v4);
|
|
1620
|
+
const v6 = ipv6Bytes(ip);
|
|
1621
|
+
return v6 ? publicIpv6(v6) : false;
|
|
1622
|
+
}
|
|
1623
|
+
function isIpLiteral(host) {
|
|
1624
|
+
return ipv4Bytes(host) !== null || host.startsWith("[");
|
|
1625
|
+
}
|
|
1626
|
+
function parseHookUrl(raw, kind = "webhook") {
|
|
1627
|
+
const text = raw.trim();
|
|
1628
|
+
if (!text) throw new HookUrlError("Paste the address to send responses to.");
|
|
1629
|
+
if (text.length > MAX_HOOK_URL_LENGTH) throw new HookUrlError("That address is too long.");
|
|
1630
|
+
let url;
|
|
1631
|
+
try {
|
|
1632
|
+
url = new URL(text);
|
|
1633
|
+
} catch {
|
|
1634
|
+
throw new HookUrlError("That doesn't look like a web address.");
|
|
1635
|
+
}
|
|
1636
|
+
if (url.protocol !== "https:") throw new HookUrlError("The address must start with https://.");
|
|
1637
|
+
if (url.username || url.password) throw new HookUrlError("The address can't include a user name or password.");
|
|
1638
|
+
if (url.port && url.port !== "443") throw new HookUrlError("The address must use the standard https port.");
|
|
1639
|
+
url.hash = "";
|
|
1640
|
+
const host = url.hostname.replace(/\.$/, "");
|
|
1641
|
+
if (kind === "slack") {
|
|
1642
|
+
if (!SLACK_HOSTS.has(host) || !url.pathname.startsWith("/services/")) {
|
|
1643
|
+
throw new HookUrlError("Use a Slack incoming webhook address, which starts with https://hooks.slack.com/services/.");
|
|
1644
|
+
}
|
|
1645
|
+
return url;
|
|
1646
|
+
}
|
|
1647
|
+
if (isIpLiteral(host)) {
|
|
1648
|
+
if (!isPublicAddress(host)) throw new HookUrlError(NOT_PUBLIC);
|
|
1649
|
+
return url;
|
|
1650
|
+
}
|
|
1651
|
+
if (!host.includes(".") || host === "localhost" || INTERNAL_SUFFIXES.some((suffix) => host.endsWith(suffix))) {
|
|
1652
|
+
throw new HookUrlError(NOT_PUBLIC);
|
|
1653
|
+
}
|
|
1654
|
+
return url;
|
|
1655
|
+
}
|
|
1656
|
+
async function checkHookHost(url, resolve2) {
|
|
1657
|
+
const host = url.hostname.replace(/\.$/, "");
|
|
1658
|
+
if (isIpLiteral(host)) {
|
|
1659
|
+
if (!isPublicAddress(host)) throw new HookUrlError(NOT_PUBLIC);
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
let addresses;
|
|
1663
|
+
try {
|
|
1664
|
+
addresses = await resolve2(host);
|
|
1665
|
+
} catch {
|
|
1666
|
+
throw new HookUrlError(`${host} couldn't be found`, { retryable: true });
|
|
1667
|
+
}
|
|
1668
|
+
if (!addresses.length) throw new HookUrlError(`${host} couldn't be found`, { retryable: true });
|
|
1669
|
+
if (!addresses.every(isPublicAddress)) throw new HookUrlError(NOT_PUBLIC);
|
|
1670
|
+
}
|
|
1671
|
+
function hookHost(url) {
|
|
1672
|
+
return url.hostname.replace(/\.$/, "").toLowerCase();
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1306
1675
|
// src/destinations/webhook.ts
|
|
1307
|
-
var
|
|
1676
|
+
var MIN_WEBHOOK_SECRET_BYTES = 24;
|
|
1308
1677
|
var STRICT_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
1309
1678
|
function secretKey(secret) {
|
|
1310
1679
|
const encoded = secret.startsWith("whsec_") ? secret.slice(6) : secret;
|
|
@@ -1314,16 +1683,27 @@ function secretKey(secret) {
|
|
|
1314
1683
|
);
|
|
1315
1684
|
}
|
|
1316
1685
|
const bytes = fromBase64(encoded);
|
|
1317
|
-
if (bytes.length <
|
|
1686
|
+
if (bytes.length < MIN_WEBHOOK_SECRET_BYTES) {
|
|
1687
|
+
throw new DeliveryError(`The webhook secret is shorter than ${MIN_WEBHOOK_SECRET_BYTES} bytes`);
|
|
1688
|
+
}
|
|
1318
1689
|
return bytes;
|
|
1319
1690
|
}
|
|
1320
1691
|
async function hmacKey(secret, usage) {
|
|
1321
1692
|
return crypto.subtle.importKey("raw", secretKey(secret), { name: "HMAC", hash: "SHA-256" }, false, [usage]);
|
|
1322
1693
|
}
|
|
1694
|
+
function newWebhookSecret() {
|
|
1695
|
+
return `whsec_${base64(crypto.getRandomValues(new Uint8Array(32)))}`;
|
|
1696
|
+
}
|
|
1323
1697
|
async function signWebhook(secret, id, timestamp, body) {
|
|
1324
|
-
const
|
|
1325
|
-
|
|
1326
|
-
|
|
1698
|
+
const secrets = typeof secret === "string" ? [secret] : secret;
|
|
1699
|
+
if (secrets.length === 0) throw new DeliveryError("The webhook secret is not set");
|
|
1700
|
+
const data = new TextEncoder().encode(`${id}.${timestamp}.${body}`);
|
|
1701
|
+
const signatures = [];
|
|
1702
|
+
for (const one of secrets) {
|
|
1703
|
+
const key = await hmacKey(one, "sign");
|
|
1704
|
+
signatures.push(`v1,${base64(new Uint8Array(await crypto.subtle.sign("HMAC", key, data)))}`);
|
|
1705
|
+
}
|
|
1706
|
+
return signatures.join(" ");
|
|
1327
1707
|
}
|
|
1328
1708
|
async function verifyWebhook(input) {
|
|
1329
1709
|
const header = (name) => {
|
|
@@ -1337,12 +1717,14 @@ async function verifyWebhook(input) {
|
|
|
1337
1717
|
if (!id || !/^\d{1,12}$/.test(timestamp) || !signatures) return false;
|
|
1338
1718
|
const now = Math.floor((input.now?.() ?? Date.now()) / 1e3);
|
|
1339
1719
|
if (Math.abs(now - Number(timestamp)) > (input.toleranceSeconds ?? 300)) return false;
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1720
|
+
const keys = [];
|
|
1721
|
+
for (const secret of typeof input.secret === "string" ? [input.secret] : input.secret) {
|
|
1722
|
+
try {
|
|
1723
|
+
keys.push(await hmacKey(secret, "verify"));
|
|
1724
|
+
} catch {
|
|
1725
|
+
}
|
|
1345
1726
|
}
|
|
1727
|
+
if (keys.length === 0) return false;
|
|
1346
1728
|
const data = new TextEncoder().encode(`${id}.${timestamp}.${input.body}`);
|
|
1347
1729
|
for (const part of signatures.split(" ")) {
|
|
1348
1730
|
const [version, value] = part.split(",", 2);
|
|
@@ -1353,7 +1735,7 @@ async function verifyWebhook(input) {
|
|
|
1353
1735
|
} catch {
|
|
1354
1736
|
continue;
|
|
1355
1737
|
}
|
|
1356
|
-
if (await crypto.subtle.verify("HMAC", key, mac, data)) return true;
|
|
1738
|
+
for (const key of keys) if (await crypto.subtle.verify("HMAC", key, mac, data)) return true;
|
|
1357
1739
|
}
|
|
1358
1740
|
return false;
|
|
1359
1741
|
}
|
|
@@ -1418,9 +1800,10 @@ function webhookPayload(submission, definition, options = {}) {
|
|
|
1418
1800
|
for (const [name, value] of Object.entries(source ?? {})) if (own(allowed, name) !== void 0) out[name] = value;
|
|
1419
1801
|
return out;
|
|
1420
1802
|
};
|
|
1803
|
+
const happened = submission.submittedAt ? Date.parse(submission.submittedAt) : Number.NaN;
|
|
1421
1804
|
return {
|
|
1422
1805
|
type: options.type ?? "form.submitted",
|
|
1423
|
-
timestamp: new Date(options.now ?? Date.now()).toISOString(),
|
|
1806
|
+
timestamp: new Date(Number.isNaN(happened) ? options.now ?? Date.now() : happened).toISOString(),
|
|
1424
1807
|
data: {
|
|
1425
1808
|
form: { id: definition.id, title: definition.title },
|
|
1426
1809
|
submission: {
|
|
@@ -1442,15 +1825,17 @@ function webhookDestination(options) {
|
|
|
1442
1825
|
kind: "webhook",
|
|
1443
1826
|
async deliver(submission, definition, config, deliveryOptions = {}) {
|
|
1444
1827
|
try {
|
|
1445
|
-
const
|
|
1446
|
-
const
|
|
1447
|
-
|
|
1828
|
+
const context = { destinationId: deliveryOptions.destinationId };
|
|
1829
|
+
const raw = options.url ? await resolveOption("The webhook url", options.url, config, context) : configString(config, "url");
|
|
1830
|
+
const url = checkWebhookUrl(typeof raw === "string" ? raw.trim() : "", options);
|
|
1831
|
+
if (options.resolveHost && !options.allowPrivateHosts) await checkHookHost(url, options.resolveHost);
|
|
1832
|
+
const secret = await resolveOption("The webhook secret", options.secret, config, context);
|
|
1833
|
+
if (!secret || secret.length === 0) throw new DeliveryError("The webhook secret is not set");
|
|
1448
1834
|
const now = options.now?.() ?? Date.now();
|
|
1449
1835
|
const id = deliveryOptions.deliveryId ?? submission.id;
|
|
1450
1836
|
const timestamp = Math.floor(now / 1e3);
|
|
1451
|
-
const
|
|
1452
|
-
|
|
1453
|
-
);
|
|
1837
|
+
const type = options.type ?? (deliveryOptions.update ? "form.updated" : void 0);
|
|
1838
|
+
const body = JSON.stringify(webhookPayload(submission, definition, { sensitive: options.sensitive, type, now }));
|
|
1454
1839
|
const response = await timedFetch(
|
|
1455
1840
|
options,
|
|
1456
1841
|
url.toString(),
|
|
@@ -1470,6 +1855,9 @@ function webhookDestination(options) {
|
|
|
1470
1855
|
);
|
|
1471
1856
|
discard(response);
|
|
1472
1857
|
if (response.status >= 200 && response.status < 300) return { ok: true };
|
|
1858
|
+
if (response.status === 410) {
|
|
1859
|
+
throw new DeliveryError("Webhook answered 410: the endpoint is gone; disable the destination", { status: 410, disable: true });
|
|
1860
|
+
}
|
|
1473
1861
|
if (response.status === 0 || response.status >= 300 && response.status < 400) {
|
|
1474
1862
|
throw new DeliveryError(`Webhook answered ${response.status || "a redirect"}: redirects are not followed`, {
|
|
1475
1863
|
...response.status ? { status: response.status } : {}
|
|
@@ -1483,43 +1871,458 @@ function webhookDestination(options) {
|
|
|
1483
1871
|
};
|
|
1484
1872
|
}
|
|
1485
1873
|
|
|
1874
|
+
// src/destinations/slack.ts
|
|
1875
|
+
var SLACK_VALUE_LIMIT = 1500;
|
|
1876
|
+
function escapeSlack(text) {
|
|
1877
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1878
|
+
}
|
|
1879
|
+
var cut = (text) => text.length > SLACK_VALUE_LIMIT ? `${text.slice(0, SLACK_VALUE_LIMIT)}\u2026` : text;
|
|
1880
|
+
function slackMessage(submission, definition, options = {}) {
|
|
1881
|
+
const title = definition.title || definition.id;
|
|
1882
|
+
const heading = options.heading ?? `${options.update ? "Updated response" : "New response"}: ${title}`;
|
|
1883
|
+
const lines = [`*${escapeSlack(heading)}*`];
|
|
1884
|
+
for (const [label, value] of options.lines ?? []) {
|
|
1885
|
+
if (value.trim()) lines.push(`*${escapeSlack(label)}*: ${escapeSlack(cut(value))}`);
|
|
1886
|
+
}
|
|
1887
|
+
const { row, labels } = responseRow(submission, definition, { sensitive: options.sensitive ?? "mask" });
|
|
1888
|
+
const omit = new Set(options.omit ?? []);
|
|
1889
|
+
let answered = 0;
|
|
1890
|
+
for (const [key, label] of Object.entries(labels)) {
|
|
1891
|
+
const text = String(row[key] ?? "");
|
|
1892
|
+
if (!text || omit.has(key)) continue;
|
|
1893
|
+
answered++;
|
|
1894
|
+
lines.push(`*${escapeSlack(label || key)}*: ${escapeSlack(cut(text))}`);
|
|
1895
|
+
}
|
|
1896
|
+
if (!answered) lines.push("_No answers._");
|
|
1897
|
+
const when = submission.submittedAt ? ` \xB7 ${escapeSlack(submission.submittedAt)}` : "";
|
|
1898
|
+
const link = options.link;
|
|
1899
|
+
lines.push(
|
|
1900
|
+
link && /^https?:\/\//i.test(link.url) ? `<${link.url.replace(/[<>|]/g, "")}|${escapeSlack(link.text ?? "Open the response").replace(/[|]/g, "")}>${when}` : `Response ${escapeSlack(submission.id)}${when}`
|
|
1901
|
+
);
|
|
1902
|
+
return { text: lines.join("\n") };
|
|
1903
|
+
}
|
|
1904
|
+
function slackDestination(options = {}) {
|
|
1905
|
+
return {
|
|
1906
|
+
kind: "slack",
|
|
1907
|
+
async deliver(submission, definition, config, deliveryOptions = {}) {
|
|
1908
|
+
try {
|
|
1909
|
+
const context = { destinationId: deliveryOptions.destinationId };
|
|
1910
|
+
const raw = options.url ? await resolveOption("The Slack webhook url", options.url, config, context) : configString(config, "url");
|
|
1911
|
+
if (typeof raw !== "string" || !raw.trim()) throw new DeliveryError("The Slack destination has no url");
|
|
1912
|
+
const url = parseHookUrl(raw, "slack");
|
|
1913
|
+
if (options.resolveHost) await checkHookHost(url, options.resolveHost);
|
|
1914
|
+
const update = deliveryOptions.update === true;
|
|
1915
|
+
const messageOptions = options.message ? await resolveOption(
|
|
1916
|
+
"The Slack message",
|
|
1917
|
+
() => options.message({ submission, definition, config, update, destinationId: deliveryOptions.destinationId }),
|
|
1918
|
+
config
|
|
1919
|
+
) : {};
|
|
1920
|
+
const body = JSON.stringify(slackMessage(submission, definition, { update, ...messageOptions }));
|
|
1921
|
+
const response = await timedFetch(
|
|
1922
|
+
options,
|
|
1923
|
+
url.toString(),
|
|
1924
|
+
{
|
|
1925
|
+
method: "POST",
|
|
1926
|
+
redirect: "manual",
|
|
1927
|
+
headers: { "content-type": "application/json", "user-agent": "genesis-forms-slack/1" },
|
|
1928
|
+
body
|
|
1929
|
+
},
|
|
1930
|
+
deliveryOptions.signal
|
|
1931
|
+
);
|
|
1932
|
+
discard(response);
|
|
1933
|
+
if (response.status >= 200 && response.status < 300) return { ok: true };
|
|
1934
|
+
if (response.status === 0 || response.status >= 300 && response.status < 400) {
|
|
1935
|
+
throw new DeliveryError(`Slack answered ${response.status || "a redirect"}: redirects are not followed`, {
|
|
1936
|
+
...response.status ? { status: response.status } : {}
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
throw httpError("Slack", response);
|
|
1940
|
+
} catch (error) {
|
|
1941
|
+
return deliveryFailure(error);
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
};
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
// src/destinations/condition.ts
|
|
1948
|
+
var OPERATORS = /* @__PURE__ */ new Set(["eq", "neq", "lt", "lte", "gt", "gte", "in", "contains", "answered", "empty"]);
|
|
1949
|
+
var MAX_CONDITION_DEPTH = 10;
|
|
1950
|
+
var isObject2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1951
|
+
function isOperand(v) {
|
|
1952
|
+
if (!isObject2(v)) return false;
|
|
1953
|
+
const keys = Object.keys(v);
|
|
1954
|
+
if (keys.length !== 1) return false;
|
|
1955
|
+
if (keys[0] === "field" || keys[0] === "hidden") return typeof v[keys[0]] === "string";
|
|
1956
|
+
return keys[0] === "value";
|
|
1957
|
+
}
|
|
1958
|
+
function isCondition(v, depth = 0) {
|
|
1959
|
+
if (!isObject2(v) || depth > MAX_CONDITION_DEPTH) return false;
|
|
1960
|
+
if ("all" in v) return Array.isArray(v["all"]) && v["all"].every((c) => isCondition(c, depth + 1));
|
|
1961
|
+
if ("any" in v) return Array.isArray(v["any"]) && v["any"].every((c) => isCondition(c, depth + 1));
|
|
1962
|
+
if ("not" in v) return isCondition(v["not"], depth + 1);
|
|
1963
|
+
return typeof v["op"] === "string" && OPERATORS.has(v["op"]) && isOperand(v["left"]) && (v["right"] === void 0 || isOperand(v["right"]));
|
|
1964
|
+
}
|
|
1965
|
+
function conditionOf(config) {
|
|
1966
|
+
const when = config["when"];
|
|
1967
|
+
if (when === void 0 || when === null) return null;
|
|
1968
|
+
if (!isCondition(when)) throw new DeliveryError("The destination's condition can't be read");
|
|
1969
|
+
return when;
|
|
1970
|
+
}
|
|
1971
|
+
function passesCondition(submission, definition, config) {
|
|
1972
|
+
const condition = conditionOf(config);
|
|
1973
|
+
if (!condition) return true;
|
|
1974
|
+
const fields = /* @__PURE__ */ new Map();
|
|
1975
|
+
for (const block of definition.blocks) if (block.kind === "field") fields.set(block.id, block);
|
|
1976
|
+
const hidden = /* @__PURE__ */ Object.create(null);
|
|
1977
|
+
for (const source of [submission.hidden, submission.context]) {
|
|
1978
|
+
for (const name of Object.keys(source ?? {})) {
|
|
1979
|
+
const value = own(source, name);
|
|
1980
|
+
if (value !== void 0) hidden[name] = value;
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
return evaluate(condition, { values: submission.answers, hidden, fields });
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
// src/destinations/google-oauth.ts
|
|
1987
|
+
var DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file";
|
|
1988
|
+
async function refreshGoogleAccessToken(client, refreshToken, options = {}) {
|
|
1989
|
+
if (!client.clientId || !client.clientSecret) throw new DeliveryError("The Google OAuth client id or secret is not set");
|
|
1990
|
+
if (!refreshToken) throw new DeliveryError("This sheet's Google connection is gone. Connect the sheet again.");
|
|
1991
|
+
const response = await timedFetch(
|
|
1992
|
+
options,
|
|
1993
|
+
GOOGLE_TOKEN_URL,
|
|
1994
|
+
{
|
|
1995
|
+
method: "POST",
|
|
1996
|
+
// The body holds the refresh token and client secret: never follow a redirect with it.
|
|
1997
|
+
redirect: "error",
|
|
1998
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
1999
|
+
body: new URLSearchParams({
|
|
2000
|
+
grant_type: "refresh_token",
|
|
2001
|
+
refresh_token: refreshToken,
|
|
2002
|
+
client_id: client.clientId,
|
|
2003
|
+
client_secret: client.clientSecret
|
|
2004
|
+
}).toString()
|
|
2005
|
+
},
|
|
2006
|
+
options.signal
|
|
2007
|
+
);
|
|
2008
|
+
if (!response.ok) {
|
|
2009
|
+
const body2 = jsonObject(await response.json().catch(() => null));
|
|
2010
|
+
if (response.status === 400 && body2.error === "invalid_grant") {
|
|
2011
|
+
throw new DeliveryError("Google access to this sheet was removed. Connect the sheet again.", { status: 400 });
|
|
2012
|
+
}
|
|
2013
|
+
discard(response);
|
|
2014
|
+
throw httpError("Google token endpoint", response);
|
|
2015
|
+
}
|
|
2016
|
+
const body = jsonObject(await response.json().catch(() => null));
|
|
2017
|
+
if (typeof body.access_token !== "string" || !body.access_token) {
|
|
2018
|
+
throw new DeliveryError("Google token endpoint returned no access token", { retryable: true });
|
|
2019
|
+
}
|
|
2020
|
+
return {
|
|
2021
|
+
accessToken: body.access_token,
|
|
2022
|
+
expiresIn: typeof body.expires_in === "number" && body.expires_in > 0 ? body.expires_in : 3600,
|
|
2023
|
+
scope: typeof body.scope === "string" ? body.scope : DRIVE_FILE_SCOPE
|
|
2024
|
+
};
|
|
2025
|
+
}
|
|
2026
|
+
function jsonObject(value) {
|
|
2027
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
2028
|
+
}
|
|
2029
|
+
var cache2 = /* @__PURE__ */ new Map();
|
|
2030
|
+
function clearGoogleOAuthTokenCache() {
|
|
2031
|
+
cache2.clear();
|
|
2032
|
+
}
|
|
2033
|
+
async function googleOAuthAccessToken(client, refreshToken, options = {}) {
|
|
2034
|
+
if (!refreshToken) throw new DeliveryError("This sheet's Google connection is gone. Connect the sheet again.");
|
|
2035
|
+
const now = options.now?.() ?? Date.now();
|
|
2036
|
+
const key = await sha256Hex(`${client.clientId}
|
|
2037
|
+
${refreshToken}`);
|
|
2038
|
+
const hit = cache2.get(key);
|
|
2039
|
+
if (hit && hit.expiresAt - 6e4 > now) return hit.token;
|
|
2040
|
+
const fresh = await refreshGoogleAccessToken(client, refreshToken, options);
|
|
2041
|
+
cache2.set(key, { token: fresh.accessToken, expiresAt: now + fresh.expiresIn * 1e3 });
|
|
2042
|
+
return fresh.accessToken;
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// src/destinations/secret-box.ts
|
|
2046
|
+
var enc = new TextEncoder();
|
|
2047
|
+
function secretKeyBytes(key) {
|
|
2048
|
+
if (typeof key !== "string") {
|
|
2049
|
+
if (key.length !== 32) throw new DeliveryError("The encryption key must be 32 bytes");
|
|
2050
|
+
return new Uint8Array(key);
|
|
2051
|
+
}
|
|
2052
|
+
const text = key.trim().replace(/=+$/, "");
|
|
2053
|
+
if (!text) throw new DeliveryError("The encryption key is not set");
|
|
2054
|
+
let bytes;
|
|
2055
|
+
try {
|
|
2056
|
+
bytes = fromBase64(text + "=".repeat((4 - text.length % 4) % 4));
|
|
2057
|
+
} catch {
|
|
2058
|
+
throw new DeliveryError("The encryption key is not base64");
|
|
2059
|
+
}
|
|
2060
|
+
if (bytes.length !== 32) throw new DeliveryError("The encryption key must be 32 bytes");
|
|
2061
|
+
return bytes;
|
|
2062
|
+
}
|
|
2063
|
+
async function deriveSecretKey(key, info, options = {}) {
|
|
2064
|
+
if (!info) throw new DeliveryError("A sealing key needs its purpose (info)");
|
|
2065
|
+
const salt = typeof options.salt === "string" ? enc.encode(options.salt) : new Uint8Array(options.salt ?? new Uint8Array(0));
|
|
2066
|
+
const base = await crypto.subtle.importKey("raw", secretKeyBytes(key), "HKDF", false, ["deriveKey"]);
|
|
2067
|
+
return crypto.subtle.deriveKey(
|
|
2068
|
+
{ name: "HKDF", hash: "SHA-256", salt, info: enc.encode(info) },
|
|
2069
|
+
base,
|
|
2070
|
+
{ name: "AES-GCM", length: 256 },
|
|
2071
|
+
false,
|
|
2072
|
+
["encrypt", "decrypt"]
|
|
2073
|
+
);
|
|
2074
|
+
}
|
|
2075
|
+
async function sealSecret(key, plaintext, aad) {
|
|
2076
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
2077
|
+
const sealed = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: enc.encode(aad) }, key, enc.encode(plaintext));
|
|
2078
|
+
return `v1.${base64url(iv)}.${base64url(new Uint8Array(sealed))}`;
|
|
2079
|
+
}
|
|
2080
|
+
async function openSecret(key, sealed, aad) {
|
|
2081
|
+
const [version, iv, body, extra] = sealed.split(".");
|
|
2082
|
+
if (version !== "v1" || !iv || !body || extra !== void 0) throw new DeliveryError("The stored secret is not in a known format");
|
|
2083
|
+
try {
|
|
2084
|
+
const plain = await crypto.subtle.decrypt(
|
|
2085
|
+
{ name: "AES-GCM", iv: padded(iv), additionalData: enc.encode(aad) },
|
|
2086
|
+
key,
|
|
2087
|
+
padded(body)
|
|
2088
|
+
);
|
|
2089
|
+
return new TextDecoder().decode(plain);
|
|
2090
|
+
} catch {
|
|
2091
|
+
throw new DeliveryError("The stored secret could not be decrypted");
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
function padded(text) {
|
|
2095
|
+
return fromBase64(text + "=".repeat((4 - text.length % 4) % 4));
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
// src/destinations/runner.ts
|
|
2099
|
+
var DEFAULT_MAX_ATTEMPTS = 8;
|
|
2100
|
+
var DEFAULT_MAX_DEFERRALS = 24;
|
|
2101
|
+
var DEFAULT_BATCH_SIZE = 25;
|
|
2102
|
+
function defaultBackoff(attempt, random) {
|
|
2103
|
+
const nominal = Math.min(24 * 3600, 300 * 4 ** Math.max(0, attempt - 1));
|
|
2104
|
+
const r = typeof random === "function" ? random() : Math.random();
|
|
2105
|
+
return Math.round(nominal * (0.8 + 0.4 * Math.min(1, Math.max(0, r))));
|
|
2106
|
+
}
|
|
2107
|
+
function deliveryOutcome(result, attempt, options = {}) {
|
|
2108
|
+
if (result.ok) return result.notSent ? { status: "skipped", reason: result.notSent.slice(0, 500) } : { status: "sent" };
|
|
2109
|
+
const http = result.status !== void 0 ? { httpStatus: result.status } : {};
|
|
2110
|
+
if (result.disable) return { status: "failed", error: result.error, ...http, disable: true };
|
|
2111
|
+
if (!result.retryable) return { status: "failed", error: result.error, ...http };
|
|
2112
|
+
const now = options.now ?? Date.now();
|
|
2113
|
+
if (result.retryAfterSeconds !== void 0 && (options.deferrals ?? 0) < (options.maxDeferrals ?? DEFAULT_MAX_DEFERRALS)) {
|
|
2114
|
+
const seconds2 = Math.max(1, Math.ceil(result.retryAfterSeconds));
|
|
2115
|
+
return { status: "retry", error: result.error, retryAt: new Date(now + seconds2 * 1e3), retryAfterSeconds: seconds2, counted: false, ...http };
|
|
2116
|
+
}
|
|
2117
|
+
const max = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
2118
|
+
if (attempt >= max) return { status: "failed", error: `${result.error} (gave up after ${attempt} attempts)`.slice(0, 500), ...http };
|
|
2119
|
+
const seconds = Math.max(1, Math.ceil((options.backoff ?? defaultBackoff)(attempt)));
|
|
2120
|
+
return { status: "retry", error: result.error, retryAt: new Date(now + seconds * 1e3), retryAfterSeconds: seconds, counted: true, ...http };
|
|
2121
|
+
}
|
|
2122
|
+
function createDeliveryRunner(options) {
|
|
2123
|
+
const log = (message) => {
|
|
2124
|
+
try {
|
|
2125
|
+
options.log?.(message);
|
|
2126
|
+
} catch {
|
|
2127
|
+
}
|
|
2128
|
+
};
|
|
2129
|
+
const now = () => options.now?.() ?? Date.now();
|
|
2130
|
+
async function send(group, signal) {
|
|
2131
|
+
const first = group[0];
|
|
2132
|
+
const destination = options.destinations[first.kind];
|
|
2133
|
+
if (!destination) {
|
|
2134
|
+
return group.map(() => ({ ok: false, retryable: false, error: `No destination for kind ${first.kind}` }));
|
|
2135
|
+
}
|
|
2136
|
+
const results = group.map((d) => {
|
|
2137
|
+
try {
|
|
2138
|
+
return passesCondition(d.submission, d.definition, d.config) ? null : "filtered";
|
|
2139
|
+
} catch (error) {
|
|
2140
|
+
return deliveryFailure(error);
|
|
2141
|
+
}
|
|
2142
|
+
});
|
|
2143
|
+
const due = group.filter((_, i) => results[i] === null);
|
|
2144
|
+
let state = first.state;
|
|
2145
|
+
const keep = (result) => {
|
|
2146
|
+
if (result.ok && result.state) state = result.state;
|
|
2147
|
+
};
|
|
2148
|
+
const guard = async (call) => {
|
|
2149
|
+
try {
|
|
2150
|
+
return await call();
|
|
2151
|
+
} catch (error) {
|
|
2152
|
+
return deliveryFailure(error);
|
|
2153
|
+
}
|
|
2154
|
+
};
|
|
2155
|
+
const base = { state, signal, destinationId: first.destinationId };
|
|
2156
|
+
let sent;
|
|
2157
|
+
if (due.length > 1 && destination.deliverBatch) {
|
|
2158
|
+
const items = due.map((d) => ({ submission: d.submission, definition: d.definition, deliveryId: d.id, update: d.update }));
|
|
2159
|
+
const result = await guard(
|
|
2160
|
+
() => destination.deliverBatch(items, first.config, { ...base, redelivery: due.some((d) => d.redelivery) })
|
|
2161
|
+
);
|
|
2162
|
+
keep(result);
|
|
2163
|
+
sent = due.map(() => result);
|
|
2164
|
+
} else {
|
|
2165
|
+
sent = [];
|
|
2166
|
+
for (const d of due) {
|
|
2167
|
+
const result = await guard(
|
|
2168
|
+
() => destination.deliver(d.submission, d.definition, d.config, {
|
|
2169
|
+
...base,
|
|
2170
|
+
state,
|
|
2171
|
+
deliveryId: d.id,
|
|
2172
|
+
redelivery: d.redelivery,
|
|
2173
|
+
update: d.update
|
|
2174
|
+
})
|
|
2175
|
+
);
|
|
2176
|
+
keep(result);
|
|
2177
|
+
sent.push(result);
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
if (state !== first.state && state && options.saveState) {
|
|
2181
|
+
try {
|
|
2182
|
+
await options.saveState(first.destinationId, state);
|
|
2183
|
+
} catch {
|
|
2184
|
+
log(`Saving destination ${first.destinationId}'s state failed`);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
let next = 0;
|
|
2188
|
+
return results.map((r) => r ?? sent[next++]);
|
|
2189
|
+
}
|
|
2190
|
+
return {
|
|
2191
|
+
async run(runOptions = {}) {
|
|
2192
|
+
const report = {
|
|
2193
|
+
claimed: 0,
|
|
2194
|
+
sent: 0,
|
|
2195
|
+
filtered: 0,
|
|
2196
|
+
skipped: 0,
|
|
2197
|
+
retrying: 0,
|
|
2198
|
+
deferred: 0,
|
|
2199
|
+
failed: 0,
|
|
2200
|
+
stale: 0,
|
|
2201
|
+
unrecorded: 0
|
|
2202
|
+
};
|
|
2203
|
+
const limit = runOptions.limit ?? options.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
2204
|
+
const claimed = await options.claim({ limit, filter: runOptions.filter });
|
|
2205
|
+
report.claimed = claimed.length;
|
|
2206
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2207
|
+
for (const delivery of claimed) {
|
|
2208
|
+
const list = groups.get(delivery.destinationId) ?? [];
|
|
2209
|
+
list.push(delivery);
|
|
2210
|
+
groups.set(delivery.destinationId, list);
|
|
2211
|
+
}
|
|
2212
|
+
await Promise.all(
|
|
2213
|
+
[...groups.values()].map(async (group) => {
|
|
2214
|
+
const results = await send(group, runOptions.signal);
|
|
2215
|
+
const at = now();
|
|
2216
|
+
await Promise.all(
|
|
2217
|
+
group.map(async (delivery, i) => {
|
|
2218
|
+
const result = results[i];
|
|
2219
|
+
const outcome = result === "filtered" ? { status: "filtered" } : deliveryOutcome(result, delivery.attempt, {
|
|
2220
|
+
now: at,
|
|
2221
|
+
maxAttempts: options.maxAttempts,
|
|
2222
|
+
backoff: options.backoff,
|
|
2223
|
+
deferrals: delivery.deferrals,
|
|
2224
|
+
maxDeferrals: options.maxDeferrals
|
|
2225
|
+
});
|
|
2226
|
+
if (outcome.status === "sent" && result !== "filtered" && result.ok && result.skipped?.includes(delivery.submission.id)) {
|
|
2227
|
+
outcome.alreadyThere = true;
|
|
2228
|
+
}
|
|
2229
|
+
if (outcome.status === "failed" || outcome.status === "retry") {
|
|
2230
|
+
log(`Delivery ${delivery.id} (${delivery.kind}) ${outcome.status === "failed" ? "failed" : "will be retried"}: ${outcome.error}`);
|
|
2231
|
+
} else if (outcome.status === "skipped") {
|
|
2232
|
+
log(`Delivery ${delivery.id} (${delivery.kind}) skipped: ${outcome.reason}`);
|
|
2233
|
+
}
|
|
2234
|
+
let recorded;
|
|
2235
|
+
try {
|
|
2236
|
+
recorded = await options.record(delivery, outcome);
|
|
2237
|
+
} catch {
|
|
2238
|
+
report.unrecorded++;
|
|
2239
|
+
log(`Recording delivery ${delivery.id} failed`);
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
if (recorded === false) report.stale++;
|
|
2243
|
+
else if (outcome.status === "sent") report.sent++;
|
|
2244
|
+
else if (outcome.status === "filtered") report.filtered++;
|
|
2245
|
+
else if (outcome.status === "skipped") report.skipped++;
|
|
2246
|
+
else if (outcome.status === "failed") report.failed++;
|
|
2247
|
+
else if (outcome.counted) report.retrying++;
|
|
2248
|
+
else report.deferred++;
|
|
2249
|
+
})
|
|
2250
|
+
);
|
|
2251
|
+
})
|
|
2252
|
+
);
|
|
2253
|
+
return report;
|
|
2254
|
+
}
|
|
2255
|
+
};
|
|
2256
|
+
}
|
|
2257
|
+
|
|
1486
2258
|
exports.DEDUPE_WINDOW = DEDUPE_WINDOW;
|
|
2259
|
+
exports.DEFAULT_BATCH_SIZE = DEFAULT_BATCH_SIZE;
|
|
2260
|
+
exports.DEFAULT_MAX_ATTEMPTS = DEFAULT_MAX_ATTEMPTS;
|
|
2261
|
+
exports.DEFAULT_MAX_DEFERRALS = DEFAULT_MAX_DEFERRALS;
|
|
1487
2262
|
exports.DEFAULT_SHEET_NAME = DEFAULT_SHEET_NAME;
|
|
2263
|
+
exports.DRIVE_FILE_SCOPE = DRIVE_FILE_SCOPE;
|
|
1488
2264
|
exports.DeliveryError = DeliveryError;
|
|
1489
2265
|
exports.GOOGLE_TOKEN_URL = GOOGLE_TOKEN_URL;
|
|
1490
2266
|
exports.HEADER_SCAN_ROWS = HEADER_SCAN_ROWS;
|
|
2267
|
+
exports.HookUrlError = HookUrlError;
|
|
2268
|
+
exports.MAX_CONDITION_DEPTH = MAX_CONDITION_DEPTH;
|
|
2269
|
+
exports.MAX_HOOK_URL_LENGTH = MAX_HOOK_URL_LENGTH;
|
|
1491
2270
|
exports.MAX_RECIPIENTS = MAX_RECIPIENTS;
|
|
2271
|
+
exports.MIN_WEBHOOK_SECRET_BYTES = MIN_WEBHOOK_SECRET_BYTES;
|
|
1492
2272
|
exports.REQUEST_TIMEOUT_MS = REQUEST_TIMEOUT_MS;
|
|
1493
2273
|
exports.RESEND_API = RESEND_API;
|
|
1494
2274
|
exports.SENSITIVE_MASK = SENSITIVE_MASK;
|
|
1495
2275
|
exports.SHEETS_API = SHEETS_API;
|
|
1496
2276
|
exports.SHEETS_SCOPE = SHEETS_SCOPE;
|
|
2277
|
+
exports.SLACK_HOSTS = SLACK_HOSTS;
|
|
2278
|
+
exports.SLACK_VALUE_LIMIT = SLACK_VALUE_LIMIT;
|
|
1497
2279
|
exports.answerCell = answerCell;
|
|
2280
|
+
exports.checkHookHost = checkHookHost;
|
|
1498
2281
|
exports.checkWebhookUrl = checkWebhookUrl;
|
|
2282
|
+
exports.clearGoogleOAuthTokenCache = clearGoogleOAuthTokenCache;
|
|
1499
2283
|
exports.clearGoogleTokenCache = clearGoogleTokenCache;
|
|
1500
2284
|
exports.columnLetter = columnLetter;
|
|
2285
|
+
exports.conditionOf = conditionOf;
|
|
2286
|
+
exports.createDeliveryRunner = createDeliveryRunner;
|
|
2287
|
+
exports.defaultBackoff = defaultBackoff;
|
|
1501
2288
|
exports.deliveryFailure = deliveryFailure;
|
|
2289
|
+
exports.deliveryOutcome = deliveryOutcome;
|
|
2290
|
+
exports.deriveSecretKey = deriveSecretKey;
|
|
1502
2291
|
exports.emailBody = emailBody;
|
|
1503
2292
|
exports.emailDestination = emailDestination;
|
|
1504
2293
|
exports.escapeFormula = escapeFormula;
|
|
1505
2294
|
exports.escapeHtml = escapeHtml;
|
|
2295
|
+
exports.escapeSlack = escapeSlack;
|
|
1506
2296
|
exports.googleAccessToken = googleAccessToken;
|
|
2297
|
+
exports.googleOAuthAccessToken = googleOAuthAccessToken;
|
|
1507
2298
|
exports.headerSafe = headerSafe;
|
|
2299
|
+
exports.hookHost = hookHost;
|
|
1508
2300
|
exports.httpError = httpError;
|
|
1509
2301
|
exports.idempotencyKey = idempotencyKey;
|
|
2302
|
+
exports.isCondition = isCondition;
|
|
1510
2303
|
exports.isPrivateHost = isPrivateHost;
|
|
2304
|
+
exports.isPublicAddress = isPublicAddress;
|
|
1511
2305
|
exports.isRetryableStatus = isRetryableStatus;
|
|
1512
2306
|
exports.isSafeAddress = isSafeAddress;
|
|
1513
2307
|
exports.isSafeFrom = isSafeFrom;
|
|
1514
2308
|
exports.mergeHeader = mergeHeader;
|
|
2309
|
+
exports.newWebhookSecret = newWebhookSecret;
|
|
2310
|
+
exports.openSecret = openSecret;
|
|
2311
|
+
exports.parseHookUrl = parseHookUrl;
|
|
2312
|
+
exports.passesCondition = passesCondition;
|
|
2313
|
+
exports.refreshGoogleAccessToken = refreshGoogleAccessToken;
|
|
1515
2314
|
exports.renderEmail = renderEmail;
|
|
1516
2315
|
exports.resendProvider = resendProvider;
|
|
1517
2316
|
exports.responseRow = responseRow;
|
|
1518
2317
|
exports.retryAfterSeconds = retryAfterSeconds;
|
|
1519
2318
|
exports.rowFor = rowFor;
|
|
2319
|
+
exports.sealSecret = sealSecret;
|
|
2320
|
+
exports.secretKeyBytes = secretKeyBytes;
|
|
1520
2321
|
exports.sheetsDestination = sheetsDestination;
|
|
1521
2322
|
exports.signJwt = signJwt;
|
|
1522
2323
|
exports.signWebhook = signWebhook;
|
|
2324
|
+
exports.slackDestination = slackDestination;
|
|
2325
|
+
exports.slackMessage = slackMessage;
|
|
1523
2326
|
exports.verifyWebhook = verifyWebhook;
|
|
1524
2327
|
exports.visibleInputs = visibleInputs;
|
|
1525
2328
|
exports.webhookDestination = webhookDestination;
|