@marktiderman/genesis-forms 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -13
- package/conformance/{after-class-back-after-a-jump.scenario.json → event-feedback-back-after-a-jump.scenario.json} +21 -21
- package/conformance/{after-class-contact-yes-completed.scenario.json → event-feedback-contact-yes-completed.scenario.json} +21 -21
- package/conformance/{after-class-contact-yes-requires-name-and-email.scenario.json → event-feedback-contact-yes-requires-name-and-email.scenario.json} +19 -19
- package/conformance/{after-class-happy-path.scenario.json → event-feedback-happy-path.scenario.json} +24 -24
- package/conformance/{after-class-invalid-email-rejected.scenario.json → event-feedback-invalid-email-rejected.scenario.json} +19 -19
- package/conformance/{after-class-low-rating-skips-went-well.scenario.json → event-feedback-low-rating-skips-went-well.scenario.json} +21 -21
- package/conformance/{after-class-server-drops-unknown-and-hidden-answers.scenario.json → event-feedback-server-drops-unknown-and-hidden-answers.scenario.json} +22 -22
- package/conformance/{after-class-server-ignores-a-client-sent-coach.scenario.json → event-feedback-server-ignores-a-client-sent-owner.scenario.json} +24 -24
- package/conformance/{monthly-pulse-default-ending.scenario.json → monthly-check-in-default-ending.scenario.json} +6 -6
- package/conformance/{monthly-pulse-promoter-ending-via-jump.scenario.json → monthly-check-in-promoter-ending-via-jump.scenario.json} +6 -6
- package/dist/destinations.cjs +1235 -0
- package/dist/destinations.cjs.map +1 -0
- package/dist/destinations.d.cts +465 -0
- package/dist/destinations.d.ts +465 -0
- package/dist/destinations.js +1193 -0
- package/dist/destinations.js.map +1 -0
- package/dist/{engine-Coc-gQIB.d.ts → engine-CPLtKVew.d.cts} +2 -1
- package/dist/{engine-Bj8-mMVb.d.cts → engine-DOrPgFDw.d.ts} +2 -1
- package/dist/index.cjs +322 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +102 -5
- package/dist/index.d.ts +102 -5
- package/dist/index.js +311 -25
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +249 -22
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +3 -2
- package/dist/server.d.ts +3 -2
- package/dist/server.js +249 -22
- package/dist/server.js.map +1 -1
- package/dist/testing.cjs +253 -22
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +4 -3
- package/dist/testing.d.ts +4 -3
- package/dist/testing.js +253 -22
- package/dist/testing.js.map +1 -1
- package/dist/{validate-BbackAjB.d.cts → types-BBKBQ8sp.d.cts} +2 -29
- package/dist/{validate-BbackAjB.d.ts → types-BBKBQ8sp.d.ts} +2 -29
- package/dist/validate-BXXqi0fq.d.cts +30 -0
- package/dist/validate-BjNwF1UE.d.ts +30 -0
- package/package.json +14 -2
|
@@ -0,0 +1,1193 @@
|
|
|
1
|
+
// src/destinations/types.ts
|
|
2
|
+
var DeliveryError = class extends Error {
|
|
3
|
+
name = "DeliveryError";
|
|
4
|
+
retryable;
|
|
5
|
+
retryAfterSeconds;
|
|
6
|
+
status;
|
|
7
|
+
constructor(message, options = {}) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.retryable = options.retryable ?? false;
|
|
10
|
+
this.retryAfterSeconds = options.retryAfterSeconds;
|
|
11
|
+
this.status = options.status;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
var REQUEST_TIMEOUT_MS = 1e4;
|
|
15
|
+
var IMF_FIXDATE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/;
|
|
16
|
+
function retryAfterSeconds(header, fallback = 60, now = Date.now()) {
|
|
17
|
+
const clamp = (s) => Math.min(86400, Math.max(1, s));
|
|
18
|
+
if (header) {
|
|
19
|
+
const trimmed = header.trim();
|
|
20
|
+
if (/^\d+$/.test(trimmed)) return clamp(Number(trimmed));
|
|
21
|
+
if (IMF_FIXDATE.test(trimmed)) {
|
|
22
|
+
const at = Date.parse(trimmed);
|
|
23
|
+
if (Number.isFinite(at)) return clamp(Math.ceil((at - now) / 1e3));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return fallback;
|
|
27
|
+
}
|
|
28
|
+
function isRetryableStatus(status) {
|
|
29
|
+
return status === 408 || status === 425 || status === 429 || status >= 500;
|
|
30
|
+
}
|
|
31
|
+
function httpError(what, response, detail) {
|
|
32
|
+
const status = response.status;
|
|
33
|
+
const message = `${what} answered ${status}${detail ? ` (${detail})` : ""}`;
|
|
34
|
+
if (isRetryableStatus(status)) {
|
|
35
|
+
return new DeliveryError(message, {
|
|
36
|
+
retryable: true,
|
|
37
|
+
retryAfterSeconds: retryAfterSeconds(response.headers.get("retry-after")),
|
|
38
|
+
status
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return new DeliveryError(message, { retryable: false, status });
|
|
42
|
+
}
|
|
43
|
+
function deliveryFailure(error) {
|
|
44
|
+
if (error instanceof DeliveryError) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
retryable: error.retryable,
|
|
48
|
+
error: error.message.slice(0, 500),
|
|
49
|
+
...error.retryAfterSeconds !== void 0 ? { retryAfterSeconds: error.retryAfterSeconds } : {},
|
|
50
|
+
...error.status !== void 0 ? { status: error.status } : {}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const name = error instanceof Error ? error.name : "";
|
|
54
|
+
if (name === "TimeoutError") return { ok: false, retryable: true, error: "Timed out" };
|
|
55
|
+
if (name === "AbortError") return { ok: false, retryable: true, error: "Aborted" };
|
|
56
|
+
if (name === "TypeError") return { ok: false, retryable: false, error: "Unexpected error" };
|
|
57
|
+
return { ok: false, retryable: true, error: "Unexpected error" };
|
|
58
|
+
}
|
|
59
|
+
async function timedFetch(deps, url, init, signal) {
|
|
60
|
+
const timeout = AbortSignal.timeout(deps.timeoutMs ?? REQUEST_TIMEOUT_MS);
|
|
61
|
+
const any = AbortSignal.any;
|
|
62
|
+
const combined = signal ? any ? any([timeout, signal]) : signal.aborted ? signal : timeout : timeout;
|
|
63
|
+
try {
|
|
64
|
+
new Request(url, { method: init.method ?? "GET", headers: init.headers ?? {} });
|
|
65
|
+
} catch {
|
|
66
|
+
throw new DeliveryError("Invalid request (a header value or the URL is malformed)", { retryable: false });
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
return await (deps.fetch ?? fetch)(url, { ...init, signal: combined });
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error instanceof Error && error.name === "TypeError") throw new DeliveryError("Network error", { retryable: true });
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function resolveOption(what, option, config) {
|
|
76
|
+
if (typeof option !== "function") return option;
|
|
77
|
+
try {
|
|
78
|
+
return await option(config);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error instanceof DeliveryError) throw error;
|
|
81
|
+
throw new DeliveryError(`${what} could not be resolved`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function discard(response) {
|
|
85
|
+
try {
|
|
86
|
+
void response.body?.cancel().catch(() => {
|
|
87
|
+
});
|
|
88
|
+
} catch {
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function configString(config, key, alias) {
|
|
92
|
+
const raw = config[key] ?? (alias ? config[alias] : void 0);
|
|
93
|
+
return typeof raw === "string" ? raw.trim() : "";
|
|
94
|
+
}
|
|
95
|
+
function base64url(bytes) {
|
|
96
|
+
return base64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
97
|
+
}
|
|
98
|
+
function base64(bytes) {
|
|
99
|
+
const raw = typeof bytes === "string" ? new TextEncoder().encode(bytes) : bytes;
|
|
100
|
+
let s = "";
|
|
101
|
+
for (let i = 0; i < raw.length; i++) s += String.fromCharCode(raw[i]);
|
|
102
|
+
return btoa(s);
|
|
103
|
+
}
|
|
104
|
+
function fromBase64(text) {
|
|
105
|
+
const bin = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
|
|
106
|
+
const out = new Uint8Array(bin.length);
|
|
107
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
async function sha256Hex(text) {
|
|
111
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
112
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/structured.ts
|
|
116
|
+
var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
117
|
+
function intProp(field, name, fallback, floor = 1) {
|
|
118
|
+
const v = field.props?.[name];
|
|
119
|
+
return typeof v === "number" && Number.isInteger(v) && v >= floor ? v : fallback;
|
|
120
|
+
}
|
|
121
|
+
function optionalIntProp(field, name, floor = 0) {
|
|
122
|
+
const v = field.props?.[name];
|
|
123
|
+
return typeof v === "number" && Number.isInteger(v) && v >= floor ? v : void 0;
|
|
124
|
+
}
|
|
125
|
+
var blank = (v) => typeof v !== "string" || v.trim() === "";
|
|
126
|
+
var blankText = (v) => typeof v === "string" && v.trim() === "";
|
|
127
|
+
var emptyList = (value, isBlank) => value === void 0 || value === null || Array.isArray(value) && value.every(isBlank);
|
|
128
|
+
var plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
129
|
+
function listLimits(field) {
|
|
130
|
+
return { min: optionalIntProp(field, "min") ?? 0, max: intProp(field, "max", 10), itemMaxLength: intProp(field, "itemMaxLength", 500) };
|
|
131
|
+
}
|
|
132
|
+
var listItems = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string" && v.trim() !== "") : [];
|
|
133
|
+
var MAX_TABLE_CELL_LENGTH = 2e3;
|
|
134
|
+
function tableColumns(field) {
|
|
135
|
+
const raw = field.props?.columns;
|
|
136
|
+
if (!Array.isArray(raw)) return [];
|
|
137
|
+
const seen = /* @__PURE__ */ new Set();
|
|
138
|
+
const out = [];
|
|
139
|
+
for (const c of raw) {
|
|
140
|
+
if (!isObject(c) || typeof c.key !== "string" || !c.key || seen.has(c.key)) continue;
|
|
141
|
+
seen.add(c.key);
|
|
142
|
+
const column = { key: c.key, label: typeof c.label === "string" ? c.label : c.key };
|
|
143
|
+
if (c.long === true) column.long = true;
|
|
144
|
+
out.push(column);
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
function tableLimits(field) {
|
|
149
|
+
return { minRows: optionalIntProp(field, "minRows") ?? 0, maxRows: intProp(field, "maxRows", 3) };
|
|
150
|
+
}
|
|
151
|
+
var filledRows = (value) => Array.isArray(value) ? value.filter((r) => isObject(r) && Object.values(r).some((c) => !blank(c))) : [];
|
|
152
|
+
var MAX_TIMESTAMP_SECONDS = 86400;
|
|
153
|
+
function formatTimestamp(seconds) {
|
|
154
|
+
const total = Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds) : 0;
|
|
155
|
+
const h = Math.floor(total / 3600);
|
|
156
|
+
const m = Math.floor(total % 3600 / 60);
|
|
157
|
+
const s = String(total % 60).padStart(2, "0");
|
|
158
|
+
return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
|
|
159
|
+
}
|
|
160
|
+
var isTimestamp = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= MAX_TIMESTAMP_SECONDS;
|
|
161
|
+
var notesWithText = (value) => Array.isArray(value) ? value.filter((n) => isObject(n) && !blank(n.text)) : [];
|
|
162
|
+
var MAX_HIGHLIGHT_NOTE_LENGTH = 1e3;
|
|
163
|
+
var MAX_HIGHLIGHT_QUOTE_LENGTH = 2e3;
|
|
164
|
+
var HIGHLIGHT_NOTE_MESSAGE = "Add a note to each highlight.";
|
|
165
|
+
var sourceText = (field) => typeof field.props?.text === "string" && field.props.text ? field.props.text : void 0;
|
|
166
|
+
var structuredFieldTypes = [
|
|
167
|
+
{
|
|
168
|
+
type: "list",
|
|
169
|
+
label: "List",
|
|
170
|
+
category: "text",
|
|
171
|
+
value: { json: true },
|
|
172
|
+
// logic.ts `contains` on an array is exact item equality, same as multi-choice, so it is offered.
|
|
173
|
+
operators: ["contains", "answered", "empty"],
|
|
174
|
+
a11y: { semantics: "group" },
|
|
175
|
+
isEmpty: (value) => emptyList(value, blankText),
|
|
176
|
+
validate: (value, field) => {
|
|
177
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) return "Enter a list of answers.";
|
|
178
|
+
const { min, max, itemMaxLength } = listLimits(field);
|
|
179
|
+
if (value.some((v) => v.trim().length > itemMaxLength)) return `Keep each item under ${itemMaxLength} characters.`;
|
|
180
|
+
const count = listItems(value).length;
|
|
181
|
+
if (count < min) return `Add at least ${plural(min, "item")}.`;
|
|
182
|
+
if (count > max) return `Add at most ${plural(max, "item")}.`;
|
|
183
|
+
return null;
|
|
184
|
+
},
|
|
185
|
+
normalize: (value) => Array.isArray(value) ? listItems(value).map((v) => v.trim()) : value,
|
|
186
|
+
summarize: (value) => listItems(value).map((v) => v.trim()).join("; "),
|
|
187
|
+
samples: (field) => {
|
|
188
|
+
const { min, max } = listLimits(field);
|
|
189
|
+
return [Array.from({ length: Math.min(Math.max(1, min), max) }, (_, i) => `Item ${i + 1}`)];
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
type: "table",
|
|
194
|
+
label: "Table",
|
|
195
|
+
category: "advanced",
|
|
196
|
+
value: { json: true },
|
|
197
|
+
operators: ["answered", "empty"],
|
|
198
|
+
a11y: { semantics: "group" },
|
|
199
|
+
isEmpty: (value) => emptyList(value, (r) => isObject(r) && Object.values(r).every(blankText)),
|
|
200
|
+
validate: (value, field) => {
|
|
201
|
+
const columns = tableColumns(field);
|
|
202
|
+
if (!columns.length) return "This question has no columns.";
|
|
203
|
+
if (!Array.isArray(value) || !value.every(isObject)) return "Fill in the rows.";
|
|
204
|
+
const keys = new Set(columns.map((c) => c.key));
|
|
205
|
+
for (const row of value) {
|
|
206
|
+
for (const [key, cell] of Object.entries(row)) {
|
|
207
|
+
if (!keys.has(key)) return "An answer names a column this question doesn't have.";
|
|
208
|
+
if (typeof cell !== "string") return "Fill in the rows with text.";
|
|
209
|
+
if (cell.trim().length > MAX_TABLE_CELL_LENGTH) return `Keep each answer under ${MAX_TABLE_CELL_LENGTH} characters.`;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const { minRows, maxRows } = tableLimits(field);
|
|
213
|
+
const count = filledRows(value).length;
|
|
214
|
+
if (count < minRows) return `Fill in at least ${plural(minRows, "row")}.`;
|
|
215
|
+
if (count > maxRows) return `Fill in at most ${plural(maxRows, "row")}.`;
|
|
216
|
+
return null;
|
|
217
|
+
},
|
|
218
|
+
normalize: (value) => Array.isArray(value) ? filledRows(value).map((row) => {
|
|
219
|
+
const out = {};
|
|
220
|
+
for (const [key, cell] of Object.entries(row)) if (typeof cell === "string") out[key] = cell.trim();
|
|
221
|
+
return out;
|
|
222
|
+
}) : value,
|
|
223
|
+
summarize: (value, field) => {
|
|
224
|
+
const columns = tableColumns(field);
|
|
225
|
+
return filledRows(value).map((row) => columns.filter((c) => !blank(row[c.key])).map((c) => `${c.label}: ${row[c.key].trim()}`).join(" \xB7 ")).join(" / ");
|
|
226
|
+
},
|
|
227
|
+
samples: (field) => {
|
|
228
|
+
const columns = tableColumns(field);
|
|
229
|
+
if (!columns.length) return [];
|
|
230
|
+
const { minRows, maxRows } = tableLimits(field);
|
|
231
|
+
const row = Object.fromEntries(columns.map((c) => [c.key, "sample"]));
|
|
232
|
+
return [Array.from({ length: Math.min(Math.max(1, minRows), maxRows) }, () => ({ ...row }))];
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
type: "media-notes",
|
|
237
|
+
label: "Notes at moments",
|
|
238
|
+
category: "media",
|
|
239
|
+
value: { json: true },
|
|
240
|
+
operators: ["answered", "empty"],
|
|
241
|
+
a11y: { semantics: "group" },
|
|
242
|
+
isEmpty: (value) => emptyList(value, (n) => isObject(n) && blankText(n.text)),
|
|
243
|
+
validate: (value, field) => {
|
|
244
|
+
if (!Array.isArray(value)) return "Add notes.";
|
|
245
|
+
const textMaxLength = intProp(field, "textMaxLength", 1e3);
|
|
246
|
+
for (const note of value) {
|
|
247
|
+
if (!isObject(note) || typeof note.text !== "string") return "Add notes.";
|
|
248
|
+
if (!isTimestamp(note.at)) return "Each note needs a time.";
|
|
249
|
+
if (note.text.trim().length > textMaxLength) return `Keep each note under ${textMaxLength} characters.`;
|
|
250
|
+
}
|
|
251
|
+
const maxNotes = intProp(field, "maxNotes", 20);
|
|
252
|
+
return notesWithText(value).length > maxNotes ? `Add at most ${plural(maxNotes, "note")}.` : null;
|
|
253
|
+
},
|
|
254
|
+
normalize: (value) => Array.isArray(value) ? notesWithText(value).map((n) => ({ at: n.at, text: n.text.trim() })).sort((a, b) => a.at - b.at) : value,
|
|
255
|
+
summarize: (value) => notesWithText(value).map((n) => ({ at: typeof n.at === "number" ? n.at : 0, text: n.text.trim() })).sort((a, b) => a.at - b.at).map((n) => `${formatTimestamp(n.at)} ${n.text}`).join("; "),
|
|
256
|
+
samples: () => [[{ at: 0, text: "sample" }]]
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
type: "text-highlights",
|
|
260
|
+
label: "Highlights",
|
|
261
|
+
category: "media",
|
|
262
|
+
value: { json: true },
|
|
263
|
+
operators: ["answered", "empty"],
|
|
264
|
+
a11y: { semantics: "group" },
|
|
265
|
+
isEmpty: (value) => emptyList(value, () => false),
|
|
266
|
+
validate: (value, field) => {
|
|
267
|
+
if (!Array.isArray(value)) return "Highlight a passage.";
|
|
268
|
+
const maxHighlights = intProp(field, "maxHighlights", 20);
|
|
269
|
+
if (value.length > maxHighlights) return `Add at most ${plural(maxHighlights, "highlight")}.`;
|
|
270
|
+
const text = sourceText(field);
|
|
271
|
+
for (const h of value) {
|
|
272
|
+
if (!isObject(h) || !Number.isInteger(h.start) || !Number.isInteger(h.end) || typeof h.quote !== "string" || typeof h.note !== "string") {
|
|
273
|
+
return "Highlight a passage of the text.";
|
|
274
|
+
}
|
|
275
|
+
const start = h.start;
|
|
276
|
+
const end = h.end;
|
|
277
|
+
if (start < 0 || end <= start || h.quote.length !== end - start) return "Highlight a passage of the text.";
|
|
278
|
+
if (end - start > MAX_HIGHLIGHT_QUOTE_LENGTH) return `Highlight under ${MAX_HIGHLIGHT_QUOTE_LENGTH} characters at a time.`;
|
|
279
|
+
if (text !== void 0 && (end > text.length || text.slice(start, end) !== h.quote)) return "A highlight doesn't match the text.";
|
|
280
|
+
if (h.note.trim() === "") return HIGHLIGHT_NOTE_MESSAGE;
|
|
281
|
+
if (h.note.trim().length > MAX_HIGHLIGHT_NOTE_LENGTH) return `Keep each note under ${MAX_HIGHLIGHT_NOTE_LENGTH} characters.`;
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
},
|
|
285
|
+
normalize: (value) => Array.isArray(value) ? value.filter(isObject).map((h) => ({ start: h.start, end: h.end, quote: h.quote, note: String(h.note).trim() })).sort((a, b) => a.start - b.start || a.end - b.end) : value,
|
|
286
|
+
summarize: (value) => Array.isArray(value) ? value.filter(isObject).map((h) => `"${String(h.quote)}" \u2014 ${String(h.note).trim()}`).join("; ") : "",
|
|
287
|
+
samples: (field) => {
|
|
288
|
+
const text = sourceText(field);
|
|
289
|
+
if (text === void 0) return [[{ start: 0, end: 6, quote: "sample", note: "sample" }]];
|
|
290
|
+
const end = Math.min(text.length, 20);
|
|
291
|
+
return [[{ start: 0, end, quote: text.slice(0, end), note: "sample" }]];
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
// src/fields.ts
|
|
297
|
+
var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
|
|
298
|
+
var globalScope = globalThis;
|
|
299
|
+
var registry = globalScope[REGISTRY] ??= /* @__PURE__ */ new Map();
|
|
300
|
+
function registerFieldType(manifest) {
|
|
301
|
+
if (!manifest.type) throw new Error("registerFieldType: a field type needs a `type`.");
|
|
302
|
+
const value = manifest.value;
|
|
303
|
+
if (!value.json && (typeof value.serialize !== "function" || typeof value.deserialize !== "function")) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`registerFieldType: "${manifest.type}" stores a non-JSON value, so it must provide both serialize and deserialize.`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
registry.set(manifest.type, manifest);
|
|
309
|
+
}
|
|
310
|
+
function getFieldType(type) {
|
|
311
|
+
return registry.get(type);
|
|
312
|
+
}
|
|
313
|
+
function isEmptyValue(value, field) {
|
|
314
|
+
const manifest = field ? registry.get(field.type) : void 0;
|
|
315
|
+
if (manifest?.isEmpty) return manifest.isEmpty(value);
|
|
316
|
+
if (value === void 0 || value === null) return true;
|
|
317
|
+
if (typeof value === "string") return value.trim() === "";
|
|
318
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
function isSensitive(block) {
|
|
322
|
+
return block.kind === "field" && (block.sensitive === true || block.props?.sensitive === true);
|
|
323
|
+
}
|
|
324
|
+
function summarizeValue(value, field) {
|
|
325
|
+
if (isEmptyValue(value, field)) return "";
|
|
326
|
+
const manifest = registry.get(field.type);
|
|
327
|
+
if (manifest?.summarize) return manifest.summarize(value, field);
|
|
328
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
329
|
+
return String(value);
|
|
330
|
+
}
|
|
331
|
+
function choiceOptions(field) {
|
|
332
|
+
const raw = field.props?.options;
|
|
333
|
+
if (!Array.isArray(raw)) return [];
|
|
334
|
+
return raw.flatMap(
|
|
335
|
+
(o) => o && typeof o === "object" && !Array.isArray(o) && typeof o.value === "string" ? [{ value: o.value, label: typeof o.label === "string" ? o.label : void 0 }] : []
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
function optionLabel(field, value) {
|
|
339
|
+
const option = choiceOptions(field).find((o) => o.value === value);
|
|
340
|
+
return option?.label ?? value;
|
|
341
|
+
}
|
|
342
|
+
function intRange(field, min, maxDefault) {
|
|
343
|
+
const max = typeof field.props?.max === "number" ? field.props.max : maxDefault;
|
|
344
|
+
return { min, max };
|
|
345
|
+
}
|
|
346
|
+
var numberOps = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
|
|
347
|
+
var textOps = ["eq", "neq", "contains", "answered", "empty"];
|
|
348
|
+
function maxLength(field, fallback) {
|
|
349
|
+
return typeof field.props?.maxLength === "number" ? field.props.maxLength : fallback;
|
|
350
|
+
}
|
|
351
|
+
function textValidate(fallback) {
|
|
352
|
+
return (value, field) => {
|
|
353
|
+
if (typeof value !== "string") return "Enter text.";
|
|
354
|
+
const limit = maxLength(field, fallback);
|
|
355
|
+
return value.length > limit ? `Keep it under ${limit} characters.` : null;
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
var MAX_EMAIL_LENGTH = 254;
|
|
359
|
+
function isEmailAddress(value) {
|
|
360
|
+
if (value.length > MAX_EMAIL_LENGTH || /\s/.test(value)) return false;
|
|
361
|
+
const at = value.indexOf("@");
|
|
362
|
+
if (at < 1 || value.indexOf("@", at + 1) !== -1) return false;
|
|
363
|
+
const domain = value.slice(at + 1);
|
|
364
|
+
return domain.slice(1, -1).includes(".");
|
|
365
|
+
}
|
|
366
|
+
var MAX_PHONE_LENGTH = 32;
|
|
367
|
+
function isPhoneNumber(value) {
|
|
368
|
+
if (value.length > MAX_PHONE_LENGTH) return false;
|
|
369
|
+
let digits = 0;
|
|
370
|
+
for (let i = 0; i < value.length; i++) {
|
|
371
|
+
const c = value[i];
|
|
372
|
+
if (c >= "0" && c <= "9") digits++;
|
|
373
|
+
else if (c === "+") {
|
|
374
|
+
if (i !== 0) return false;
|
|
375
|
+
} else if (c !== " " && c !== "-" && c !== "(" && c !== ")" && c !== ".") return false;
|
|
376
|
+
}
|
|
377
|
+
return digits >= 7 && digits <= 15;
|
|
378
|
+
}
|
|
379
|
+
var builtins = [
|
|
380
|
+
{
|
|
381
|
+
type: "short-text",
|
|
382
|
+
label: "Short text",
|
|
383
|
+
category: "text",
|
|
384
|
+
value: { json: true },
|
|
385
|
+
operators: textOps,
|
|
386
|
+
a11y: { semantics: "control" },
|
|
387
|
+
validate: textValidate(500)
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
type: "long-text",
|
|
391
|
+
label: "Long text",
|
|
392
|
+
category: "text",
|
|
393
|
+
value: { json: true },
|
|
394
|
+
operators: textOps,
|
|
395
|
+
a11y: { semantics: "control" },
|
|
396
|
+
validate: textValidate(5e3)
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
type: "email",
|
|
400
|
+
label: "Email",
|
|
401
|
+
category: "text",
|
|
402
|
+
value: { json: true },
|
|
403
|
+
operators: textOps,
|
|
404
|
+
a11y: { semantics: "control", autocomplete: "email", inputMode: "email" },
|
|
405
|
+
validate: (value) => typeof value === "string" && isEmailAddress(value.trim()) ? null : "Enter an email address like name@example.com.",
|
|
406
|
+
normalize: (value) => typeof value === "string" ? value.trim() : value
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
type: "phone",
|
|
410
|
+
label: "Phone",
|
|
411
|
+
category: "text",
|
|
412
|
+
value: { json: true },
|
|
413
|
+
operators: ["eq", "neq", "answered", "empty"],
|
|
414
|
+
a11y: { semantics: "control", autocomplete: "tel", inputMode: "tel" },
|
|
415
|
+
validate: (value) => typeof value === "string" && isPhoneNumber(value.trim()) ? null : "Enter a phone number with 7 to 15 digits.",
|
|
416
|
+
normalize: (value) => typeof value === "string" ? value.trim() : value,
|
|
417
|
+
summarize: (value) => String(value)
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
type: "rating",
|
|
421
|
+
label: "Rating",
|
|
422
|
+
category: "scale",
|
|
423
|
+
value: { json: true },
|
|
424
|
+
operators: numberOps,
|
|
425
|
+
a11y: { semantics: "group" },
|
|
426
|
+
validate: (value, field) => {
|
|
427
|
+
const { min, max } = intRange(field, 1, 5);
|
|
428
|
+
return Number.isInteger(value) && value >= min && value <= max ? null : `Choose a rating from ${min} to ${max}.`;
|
|
429
|
+
},
|
|
430
|
+
summarize: (value, field) => `${value}/${intRange(field, 1, 5).max}`
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
type: "nps",
|
|
434
|
+
label: "Net Promoter Score",
|
|
435
|
+
category: "scale",
|
|
436
|
+
value: { json: true },
|
|
437
|
+
operators: numberOps,
|
|
438
|
+
a11y: { semantics: "group" },
|
|
439
|
+
validate: (value) => Number.isInteger(value) && value >= 0 && value <= 10 ? null : "Choose a score from 0 to 10.",
|
|
440
|
+
summarize: (value) => `${value}/10`
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
type: "single-choice",
|
|
444
|
+
label: "Single choice",
|
|
445
|
+
category: "choice",
|
|
446
|
+
value: { json: true },
|
|
447
|
+
operators: ["eq", "neq", "in", "answered", "empty"],
|
|
448
|
+
a11y: { semantics: "group" },
|
|
449
|
+
validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
|
|
450
|
+
summarize: (value, field) => optionLabel(field, String(value))
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
type: "multi-choice",
|
|
454
|
+
label: "Multiple choice",
|
|
455
|
+
category: "choice",
|
|
456
|
+
value: { json: true },
|
|
457
|
+
operators: ["contains", "answered", "empty"],
|
|
458
|
+
a11y: { semantics: "group" },
|
|
459
|
+
validate: (value, field) => {
|
|
460
|
+
const allowed = new Set(choiceOptions(field).map((o) => o.value));
|
|
461
|
+
return Array.isArray(value) && value.length <= allowed.size && new Set(value).size === value.length && value.every((v) => typeof v === "string" && allowed.has(v)) ? null : "Choose from the options.";
|
|
462
|
+
},
|
|
463
|
+
summarize: (value, field) => Array.isArray(value) ? value.map((v) => optionLabel(field, String(v))).join(", ") : String(value)
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
type: "yes-no",
|
|
467
|
+
label: "Yes / no",
|
|
468
|
+
category: "choice",
|
|
469
|
+
value: { json: true },
|
|
470
|
+
operators: ["eq", "neq", "answered", "empty"],
|
|
471
|
+
a11y: { semantics: "group" },
|
|
472
|
+
validate: (value) => typeof value === "boolean" ? null : "Choose yes or no.",
|
|
473
|
+
summarize: (value) => value ? "Yes" : "No"
|
|
474
|
+
}
|
|
475
|
+
];
|
|
476
|
+
for (const manifest of [...builtins, ...structuredFieldTypes]) registerFieldType(manifest);
|
|
477
|
+
|
|
478
|
+
// src/logic.ts
|
|
479
|
+
function own(record, key) {
|
|
480
|
+
return Object.prototype.hasOwnProperty.call(record, key) ? record[key] : void 0;
|
|
481
|
+
}
|
|
482
|
+
function withheld(def, field) {
|
|
483
|
+
return def.settings?.anonymous === true && field?.identifying === true;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// src/recall.ts
|
|
487
|
+
var TOKEN = /\{\{\s*(field|hidden):([A-Za-z0-9_-]+)\s*(?:\|([^}]*))?\}\}/g;
|
|
488
|
+
function recall(text, scope) {
|
|
489
|
+
return text.replace(TOKEN, (_match, kind, name, fallback) => {
|
|
490
|
+
if (kind === "field") {
|
|
491
|
+
const field = scope.fieldsByKey.get(name);
|
|
492
|
+
const out2 = field ? summarizeValue(own(scope.values, field.id), field) : "";
|
|
493
|
+
return out2 || (fallback ?? "").trim();
|
|
494
|
+
}
|
|
495
|
+
const value = own(scope.hidden, name);
|
|
496
|
+
const out = value === void 0 || value === null ? "" : String(value);
|
|
497
|
+
return out || (fallback ?? "").trim();
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// src/destinations/row.ts
|
|
502
|
+
var SENSITIVE_MASK = "[sensitive]";
|
|
503
|
+
var RESERVED = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
504
|
+
function answerCell(field, value) {
|
|
505
|
+
if (value === void 0 || isEmptyValue(value, field)) return "";
|
|
506
|
+
if (typeof value === "number") return value;
|
|
507
|
+
const manifest = getFieldType(field.type);
|
|
508
|
+
if (manifest?.summarize) return summarizeValue(value, field);
|
|
509
|
+
if (typeof value === "string") return value;
|
|
510
|
+
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
511
|
+
if (Array.isArray(value) && value.every((v) => typeof v !== "object" || v === null)) {
|
|
512
|
+
return value.map((v) => String(v)).join(", ");
|
|
513
|
+
}
|
|
514
|
+
return JSON.stringify(value);
|
|
515
|
+
}
|
|
516
|
+
function responseRow(submission, definition, options = {}) {
|
|
517
|
+
const row = {};
|
|
518
|
+
const columns = [];
|
|
519
|
+
const set = (key, value) => {
|
|
520
|
+
if (RESERVED.has(key)) return;
|
|
521
|
+
if (!Object.prototype.hasOwnProperty.call(row, key)) columns.push(key);
|
|
522
|
+
row[key] = value;
|
|
523
|
+
};
|
|
524
|
+
set("id", submission.id);
|
|
525
|
+
if (submission.submittedAt !== void 0) set("submitted_at", submission.submittedAt);
|
|
526
|
+
for (const [key, value] of Object.entries(submission.meta ?? {})) {
|
|
527
|
+
if (key !== "id" && key !== "submitted_at") set(key, value);
|
|
528
|
+
}
|
|
529
|
+
const fields = definition.blocks.filter((b) => b.kind === "field");
|
|
530
|
+
const fieldsByKey = new Map(fields.map((f) => [f.key, f]));
|
|
531
|
+
const sensitive = options.sensitive ?? "include";
|
|
532
|
+
const values = {};
|
|
533
|
+
for (const field of fields) {
|
|
534
|
+
const value = own(submission.answers, field.id);
|
|
535
|
+
if (value === void 0 || withheld(definition, field)) continue;
|
|
536
|
+
if (isSensitive(field) && sensitive !== "include") continue;
|
|
537
|
+
values[field.id] = value;
|
|
538
|
+
}
|
|
539
|
+
const scope = { fieldsByKey, values, hidden: visibleInputs(submission, definition, sensitive) };
|
|
540
|
+
const labels = {};
|
|
541
|
+
for (const field of fields) {
|
|
542
|
+
if (withheld(definition, field)) continue;
|
|
543
|
+
if (isSensitive(field) && sensitive === "omit") continue;
|
|
544
|
+
if (RESERVED.has(field.key)) continue;
|
|
545
|
+
labels[field.key] = recall(field.label, scope).trim() || field.key;
|
|
546
|
+
const cell = answerCell(field, own(submission.answers, field.id));
|
|
547
|
+
set(field.key, isSensitive(field) && sensitive === "mask" && cell !== "" ? SENSITIVE_MASK : cell);
|
|
548
|
+
}
|
|
549
|
+
return { row, columns, labels };
|
|
550
|
+
}
|
|
551
|
+
function visibleInputs(submission, definition, sensitive = "include") {
|
|
552
|
+
const declared = new Map((definition.hidden ?? []).map((h) => [h.name, h]));
|
|
553
|
+
const out = {};
|
|
554
|
+
for (const [name, value] of Object.entries({ ...submission.hidden, ...submission.context })) {
|
|
555
|
+
const input = declared.get(name);
|
|
556
|
+
if (RESERVED.has(name)) continue;
|
|
557
|
+
if (input?.identifying && definition.settings?.anonymous === true) continue;
|
|
558
|
+
if (input?.sensitive && sensitive !== "include") continue;
|
|
559
|
+
out[name] = value;
|
|
560
|
+
}
|
|
561
|
+
return out;
|
|
562
|
+
}
|
|
563
|
+
function escapeFormula(cell) {
|
|
564
|
+
if (typeof cell !== "string" || cell === "") return cell;
|
|
565
|
+
const first = cell[0];
|
|
566
|
+
if (first === "=" || first === "@" || first === " " || first === "\r") return `'${cell}`;
|
|
567
|
+
if ((first === "+" || first === "-") && !/^[+-][\d\s().,+-]*$/.test(cell)) return `'${cell}`;
|
|
568
|
+
return cell;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// src/destinations/email.ts
|
|
572
|
+
var MAX_RECIPIENTS = 50;
|
|
573
|
+
var MAX_SUBJECT_LENGTH = 200;
|
|
574
|
+
function escapeHtml(text) {
|
|
575
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
576
|
+
}
|
|
577
|
+
function headerSafe(text, max = MAX_SUBJECT_LENGTH) {
|
|
578
|
+
const flat = text.replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, " ").replace(/\s+/g, " ").trim();
|
|
579
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
|
|
580
|
+
}
|
|
581
|
+
function isSafeAddress(value) {
|
|
582
|
+
return typeof value === "string" && isEmailAddress(value) && !/[\u0000-\u001f\u007f<>,;:"()[\]\\]/.test(value);
|
|
583
|
+
}
|
|
584
|
+
function isSafeFrom(value) {
|
|
585
|
+
const named = /^([^"<>\u0000-\u001f\u007f]{1,100}) <([^<>]+)>$/.exec(value);
|
|
586
|
+
return named ? isSafeAddress(named[2]) : isSafeAddress(value);
|
|
587
|
+
}
|
|
588
|
+
function emailBody(lines, footer) {
|
|
589
|
+
const text = lines.map(([k, v]) => `${k}: ${v}`).join("\n") + (footer ? `
|
|
590
|
+
|
|
591
|
+
${footer}` : "");
|
|
592
|
+
const font = "font-family:sans-serif;font-size:14px;margin:0 0 12px";
|
|
593
|
+
const html = lines.map(
|
|
594
|
+
([k, v]) => `<p style="${font}"><span style="color:#666">${escapeHtml(k)}</span><br><span style="white-space:pre-wrap">${escapeHtml(String(v))}</span></p>`
|
|
595
|
+
).join("") + (footer ? `<p style="font-family:sans-serif;font-size:12px;color:#666">${escapeHtml(footer)}</p>` : "");
|
|
596
|
+
return { text, html };
|
|
597
|
+
}
|
|
598
|
+
function renderEmail(input) {
|
|
599
|
+
const { submission, definition, config, row } = input;
|
|
600
|
+
const lines = [];
|
|
601
|
+
const fieldKeys = new Set(Object.keys(row.labels));
|
|
602
|
+
for (const key of row.columns) {
|
|
603
|
+
if (key === "id") continue;
|
|
604
|
+
const value = row.row[key];
|
|
605
|
+
if (value === void 0 || value === "") continue;
|
|
606
|
+
const label = fieldKeys.has(key) ? row.labels[key] : key === "submitted_at" ? "Submitted" : key;
|
|
607
|
+
lines.push([label, value]);
|
|
608
|
+
}
|
|
609
|
+
const endings = definition.endings ?? [];
|
|
610
|
+
if (submission.ending && endings.length > 1) {
|
|
611
|
+
const ending = endings.find((e) => e.id === submission.ending);
|
|
612
|
+
if (ending) lines.push(["Ending", ending.title]);
|
|
613
|
+
}
|
|
614
|
+
if (!lines.length) lines.push(["Answers", "(none)"]);
|
|
615
|
+
const subject = configString(config, "subject") || `${definition.title || definition.id}: new response`;
|
|
616
|
+
return { subject, ...emailBody(lines, `Response ${submission.id}`) };
|
|
617
|
+
}
|
|
618
|
+
function recipients(config) {
|
|
619
|
+
const raw = config["to"];
|
|
620
|
+
const list = (Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(",") : []).map(
|
|
621
|
+
(t) => typeof t === "string" ? t.trim() : t
|
|
622
|
+
);
|
|
623
|
+
const to = [...new Set(list.filter(isSafeAddress))];
|
|
624
|
+
if (to.length !== list.filter((t) => t !== "").length) {
|
|
625
|
+
throw new DeliveryError("The email destination has an invalid recipient");
|
|
626
|
+
}
|
|
627
|
+
if (!to.length) throw new DeliveryError("The email destination has no recipients");
|
|
628
|
+
if (to.length > MAX_RECIPIENTS) throw new DeliveryError(`The email destination has more than ${MAX_RECIPIENTS} recipients`);
|
|
629
|
+
return to;
|
|
630
|
+
}
|
|
631
|
+
function replyToAddress(option, input) {
|
|
632
|
+
if (!option) return void 0;
|
|
633
|
+
let value;
|
|
634
|
+
if (typeof option === "function") value = option(input);
|
|
635
|
+
else {
|
|
636
|
+
const field = input.definition.blocks.find((b) => b.kind === "field" && b.key === option);
|
|
637
|
+
value = field ? own(input.submission.answers, field.id) : void 0;
|
|
638
|
+
if (field?.kind === "field" && field.identifying && input.definition.settings?.anonymous) value = void 0;
|
|
639
|
+
}
|
|
640
|
+
const trimmed = typeof value === "string" ? value.trim() : value;
|
|
641
|
+
return isSafeAddress(trimmed) ? trimmed : void 0;
|
|
642
|
+
}
|
|
643
|
+
async function idempotencyKey(deliveryId, to) {
|
|
644
|
+
const hex = await sha256Hex([...to].sort().join(","));
|
|
645
|
+
return `${deliveryId.slice(0, 200)}:${hex.slice(0, 16)}`;
|
|
646
|
+
}
|
|
647
|
+
function emailDestination(options) {
|
|
648
|
+
return {
|
|
649
|
+
kind: "email",
|
|
650
|
+
async deliver(submission, definition, config, deliveryOptions = {}) {
|
|
651
|
+
try {
|
|
652
|
+
if (!isSafeFrom(options.from)) throw new DeliveryError("The email destination's from address is invalid");
|
|
653
|
+
const to = recipients(config);
|
|
654
|
+
const row = responseRow(submission, definition, { sensitive: options.sensitive ?? "mask" });
|
|
655
|
+
const input = { submission, definition, config, row };
|
|
656
|
+
const content = (options.render ?? renderEmail)(input);
|
|
657
|
+
const replyTo = replyToAddress(options.replyTo, input);
|
|
658
|
+
await options.provider.send(
|
|
659
|
+
{
|
|
660
|
+
from: options.from,
|
|
661
|
+
to,
|
|
662
|
+
...replyTo ? { replyTo } : {},
|
|
663
|
+
subject: headerSafe(content.subject) || "New response",
|
|
664
|
+
text: content.text,
|
|
665
|
+
html: content.html,
|
|
666
|
+
idempotencyKey: await idempotencyKey(deliveryOptions.deliveryId ?? submission.id, to)
|
|
667
|
+
},
|
|
668
|
+
{ signal: deliveryOptions.signal }
|
|
669
|
+
);
|
|
670
|
+
return { ok: true };
|
|
671
|
+
} catch (error) {
|
|
672
|
+
return deliveryFailure(error);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
var RESEND_API = "https://api.resend.com/emails";
|
|
678
|
+
function resendProvider(options) {
|
|
679
|
+
return {
|
|
680
|
+
name: "resend",
|
|
681
|
+
async send(message, { signal }) {
|
|
682
|
+
if (!options.apiKey) throw new DeliveryError("The Resend API key is not set");
|
|
683
|
+
const response = await timedFetch(
|
|
684
|
+
options,
|
|
685
|
+
options.endpoint ?? RESEND_API,
|
|
686
|
+
{
|
|
687
|
+
method: "POST",
|
|
688
|
+
headers: {
|
|
689
|
+
authorization: `Bearer ${options.apiKey}`,
|
|
690
|
+
"content-type": "application/json",
|
|
691
|
+
"idempotency-key": message.idempotencyKey
|
|
692
|
+
},
|
|
693
|
+
body: JSON.stringify({
|
|
694
|
+
from: message.from,
|
|
695
|
+
to: message.to,
|
|
696
|
+
subject: message.subject,
|
|
697
|
+
text: message.text,
|
|
698
|
+
html: message.html,
|
|
699
|
+
...message.replyTo ? { reply_to: message.replyTo } : {}
|
|
700
|
+
})
|
|
701
|
+
},
|
|
702
|
+
signal
|
|
703
|
+
);
|
|
704
|
+
if (response.ok) {
|
|
705
|
+
discard(response);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
let name;
|
|
709
|
+
try {
|
|
710
|
+
const body = await response.json();
|
|
711
|
+
if (typeof body.name === "string" && /^[a-z_]{1,64}$/.test(body.name)) name = body.name;
|
|
712
|
+
} catch {
|
|
713
|
+
}
|
|
714
|
+
if (response.status === 409 && name === "concurrent_idempotent_requests") {
|
|
715
|
+
throw new DeliveryError("Resend answered 409 (concurrent_idempotent_requests)", {
|
|
716
|
+
retryable: true,
|
|
717
|
+
retryAfterSeconds: 5,
|
|
718
|
+
status: 409
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
throw httpError("Resend", response, name);
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// src/destinations/google-auth.ts
|
|
727
|
+
var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
728
|
+
var SHEETS_SCOPE = "https://www.googleapis.com/auth/spreadsheets";
|
|
729
|
+
function pemToPkcs8(pem) {
|
|
730
|
+
const text = pem.replace(/\\n/g, "\n");
|
|
731
|
+
if (/-----BEGIN RSA PRIVATE KEY-----/.test(text)) {
|
|
732
|
+
throw new DeliveryError("The Google private key is PKCS#1; convert it to PKCS#8 (BEGIN PRIVATE KEY)");
|
|
733
|
+
}
|
|
734
|
+
const body = text.replace(/-----(BEGIN|END) PRIVATE KEY-----/g, "").replace(/\s+/g, "");
|
|
735
|
+
return fromBase64(body);
|
|
736
|
+
}
|
|
737
|
+
async function signJwt(claims, privateKeyPem) {
|
|
738
|
+
let key;
|
|
739
|
+
try {
|
|
740
|
+
key = await crypto.subtle.importKey(
|
|
741
|
+
"pkcs8",
|
|
742
|
+
pemToPkcs8(privateKeyPem),
|
|
743
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
744
|
+
false,
|
|
745
|
+
["sign"]
|
|
746
|
+
);
|
|
747
|
+
} catch (error) {
|
|
748
|
+
if (error instanceof DeliveryError) throw error;
|
|
749
|
+
throw new DeliveryError("The Google private key is not a valid PKCS#8 RSA key");
|
|
750
|
+
}
|
|
751
|
+
const unsigned = `${base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }))}.${base64url(JSON.stringify(claims))}`;
|
|
752
|
+
const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(unsigned));
|
|
753
|
+
return `${unsigned}.${base64url(new Uint8Array(signature))}`;
|
|
754
|
+
}
|
|
755
|
+
var cache = /* @__PURE__ */ new Map();
|
|
756
|
+
function clearGoogleTokenCache() {
|
|
757
|
+
cache.clear();
|
|
758
|
+
}
|
|
759
|
+
async function googleAccessToken(account, options = {}) {
|
|
760
|
+
const { clientEmail, privateKey } = account;
|
|
761
|
+
if (!clientEmail || !privateKey) throw new DeliveryError("The Google service account email or private key is not set");
|
|
762
|
+
const scope = options.scope ?? SHEETS_SCOPE;
|
|
763
|
+
const now = options.now?.() ?? Date.now();
|
|
764
|
+
const cacheKey = `${clientEmail} ${scope} ${await sha256Hex(privateKey.replace(/\\n/g, "\n").trim())}`;
|
|
765
|
+
const hit = cache.get(cacheKey);
|
|
766
|
+
if (hit && hit.expiresAt - 6e4 > now) return hit.token;
|
|
767
|
+
const iat = Math.floor(now / 1e3);
|
|
768
|
+
const assertion = await signJwt({ iss: clientEmail, scope, aud: GOOGLE_TOKEN_URL, iat, exp: iat + 3600 }, privateKey);
|
|
769
|
+
const response = await timedFetch(
|
|
770
|
+
options,
|
|
771
|
+
GOOGLE_TOKEN_URL,
|
|
772
|
+
{
|
|
773
|
+
method: "POST",
|
|
774
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
775
|
+
body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion }).toString()
|
|
776
|
+
},
|
|
777
|
+
options.signal
|
|
778
|
+
);
|
|
779
|
+
if (!response.ok) {
|
|
780
|
+
discard(response);
|
|
781
|
+
throw httpError("Google token endpoint", response);
|
|
782
|
+
}
|
|
783
|
+
const body = await response.json();
|
|
784
|
+
if (typeof body.access_token !== "string") throw new DeliveryError("Google token endpoint returned no access token", { retryable: true });
|
|
785
|
+
const ttl = typeof body.expires_in === "number" ? body.expires_in * 1e3 : 36e5;
|
|
786
|
+
cache.set(cacheKey, { token: body.access_token, expiresAt: now + ttl });
|
|
787
|
+
return body.access_token;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// src/destinations/sheets.ts
|
|
791
|
+
var SHEETS_API = "https://sheets.googleapis.com/v4/spreadsheets";
|
|
792
|
+
var DEFAULT_SHEET_NAME = "Responses";
|
|
793
|
+
var DEDUPE_WINDOW = 500;
|
|
794
|
+
var HEADER_SCAN_ROWS = 100;
|
|
795
|
+
var HEADER_WRITE_ATTEMPTS = 3;
|
|
796
|
+
function mergeHeader(existing, keys, width = 0) {
|
|
797
|
+
const header = existing.map((h) => h == null ? "" : String(h));
|
|
798
|
+
while (header.length < width) header.push("");
|
|
799
|
+
while (header.length > width && header[header.length - 1] === "") header.pop();
|
|
800
|
+
for (const key of keys) if (key && !header.includes(key)) header.push(key);
|
|
801
|
+
return header;
|
|
802
|
+
}
|
|
803
|
+
function rowFor(header, row, escape = false) {
|
|
804
|
+
return header.map((h) => {
|
|
805
|
+
const cell = Object.prototype.hasOwnProperty.call(row, h) ? row[h] ?? "" : "";
|
|
806
|
+
return escape ? escapeFormula(cell) : cell;
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
function columnLetter(index) {
|
|
810
|
+
let n = index + 1;
|
|
811
|
+
let s = "";
|
|
812
|
+
while (n > 0) {
|
|
813
|
+
const r = (n - 1) % 26;
|
|
814
|
+
s = String.fromCharCode(65 + r) + s;
|
|
815
|
+
n = Math.floor((n - 1) / 26);
|
|
816
|
+
}
|
|
817
|
+
return s;
|
|
818
|
+
}
|
|
819
|
+
var quoteSheet = (name) => `'${name.replace(/'/g, "''")}'`;
|
|
820
|
+
function readState(raw, spreadsheetId, sheetName) {
|
|
821
|
+
if (!raw) return null;
|
|
822
|
+
const header = raw["header"];
|
|
823
|
+
if (raw["spreadsheet_id"] !== spreadsheetId || raw["sheet_name"] !== sheetName || typeof raw["sheet_id"] !== "number" || !Array.isArray(header) || !header.every((h) => typeof h === "string") || !header.includes("id")) {
|
|
824
|
+
return null;
|
|
825
|
+
}
|
|
826
|
+
return {
|
|
827
|
+
spreadsheet_id: spreadsheetId,
|
|
828
|
+
sheet_name: sheetName,
|
|
829
|
+
sheet_id: raw["sheet_id"],
|
|
830
|
+
header,
|
|
831
|
+
columns: typeof raw["columns"] === "number" ? raw["columns"] : header.length,
|
|
832
|
+
last_row: typeof raw["last_row"] === "number" ? raw["last_row"] : 0
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
var StaleSheetError = class extends DeliveryError {
|
|
836
|
+
};
|
|
837
|
+
function configOf(config) {
|
|
838
|
+
const spreadsheetId = configString(config, "spreadsheetId", "spreadsheet_id");
|
|
839
|
+
if (!spreadsheetId) throw new DeliveryError("The sheet destination has no spreadsheetId");
|
|
840
|
+
if (!/^[A-Za-z0-9_-]{10,200}$/.test(spreadsheetId)) throw new DeliveryError("The sheet destination's spreadsheetId is invalid");
|
|
841
|
+
const sheetName = configString(config, "sheetName", "sheet_name") || DEFAULT_SHEET_NAME;
|
|
842
|
+
if (sheetName.length > 100) throw new DeliveryError("The sheet destination's sheetName is too long");
|
|
843
|
+
return { spreadsheetId, sheetName };
|
|
844
|
+
}
|
|
845
|
+
function sheetsDestination(options) {
|
|
846
|
+
async function run(items, config, deliveryOptions) {
|
|
847
|
+
if (!items.length) return { ok: true };
|
|
848
|
+
const { spreadsheetId, sheetName } = configOf(config);
|
|
849
|
+
const account = await resolveOption("The sheet destination's account", options.account, config);
|
|
850
|
+
const signal = deliveryOptions.signal;
|
|
851
|
+
const token = await googleAccessToken(account, { ...options, signal });
|
|
852
|
+
const base = `${SHEETS_API}/${encodeURIComponent(spreadsheetId)}`;
|
|
853
|
+
const escape = options.escapeFormulas === true;
|
|
854
|
+
const call = async (what, url, init = {}) => {
|
|
855
|
+
const response = await timedFetch(
|
|
856
|
+
options,
|
|
857
|
+
url,
|
|
858
|
+
{ ...init, headers: { authorization: `Bearer ${token}`, "content-type": "application/json" } },
|
|
859
|
+
signal
|
|
860
|
+
);
|
|
861
|
+
if (!response.ok) {
|
|
862
|
+
discard(response);
|
|
863
|
+
if (what === "append" && (response.status === 400 || response.status === 404)) {
|
|
864
|
+
throw new StaleSheetError(`Google Sheets append answered ${response.status}`, { status: response.status });
|
|
865
|
+
}
|
|
866
|
+
if (response.status === 403 || response.status === 404) {
|
|
867
|
+
throw new DeliveryError(
|
|
868
|
+
`Google Sheets ${what} answered ${response.status}: is the sheet shared with ${account.clientEmail}?`,
|
|
869
|
+
{ status: response.status }
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
throw httpError(`Google Sheets ${what}`, response);
|
|
873
|
+
}
|
|
874
|
+
return await response.json();
|
|
875
|
+
};
|
|
876
|
+
const batchUpdate = (what, requests) => call(what, `${base}:batchUpdate`, {
|
|
877
|
+
method: "POST",
|
|
878
|
+
body: JSON.stringify({ requests })
|
|
879
|
+
});
|
|
880
|
+
const range = (a1) => `${base}/values/${encodeURIComponent(`${quoteSheet(sheetName)}!${a1}`)}`;
|
|
881
|
+
const sensitive = options.sensitive ?? "include";
|
|
882
|
+
const columnKey = (key) => escape ? String(escapeFormula(key)) : key;
|
|
883
|
+
const rows = items.map((item) => {
|
|
884
|
+
const { row, columns } = responseRow(item.submission, item.definition, { sensitive });
|
|
885
|
+
const escaped = {};
|
|
886
|
+
for (const key of columns) escaped[columnKey(key)] = row[key];
|
|
887
|
+
return { row: escaped, columns: columns.map(columnKey) };
|
|
888
|
+
});
|
|
889
|
+
const keys = [...new Set(rows.flatMap((r) => r.columns))];
|
|
890
|
+
async function setUp(previous) {
|
|
891
|
+
const readTabs = async () => {
|
|
892
|
+
const meta = await call(
|
|
893
|
+
"read",
|
|
894
|
+
`${base}?fields=${encodeURIComponent("sheets.properties(sheetId,title,gridProperties.columnCount)")}`
|
|
895
|
+
);
|
|
896
|
+
return meta.sheets?.map((s) => s.properties).find((p) => p?.title === sheetName);
|
|
897
|
+
};
|
|
898
|
+
let sheet = await readTabs();
|
|
899
|
+
if (!sheet) {
|
|
900
|
+
try {
|
|
901
|
+
const added = await batchUpdate("add tab", [{ addSheet: { properties: { title: sheetName } } }]);
|
|
902
|
+
sheet = added.replies?.[0]?.addSheet?.properties ?? { sheetId: 0, title: sheetName };
|
|
903
|
+
} catch (error) {
|
|
904
|
+
if (!(error instanceof DeliveryError) || error.status !== 400) throw error;
|
|
905
|
+
sheet = await readTabs();
|
|
906
|
+
if (!sheet) throw error;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
const readHeader = async () => {
|
|
910
|
+
const read = await call(
|
|
911
|
+
"read header",
|
|
912
|
+
`${range(`A1:ZZZ${HEADER_SCAN_ROWS}`)}?majorDimension=COLUMNS`
|
|
913
|
+
);
|
|
914
|
+
const used2 = read.values ?? [];
|
|
915
|
+
return { used: used2.length, existing: used2.map((column) => column[0] ?? "") };
|
|
916
|
+
};
|
|
917
|
+
let columns = sheet.gridProperties?.columnCount ?? 26;
|
|
918
|
+
let { used, existing } = await readHeader();
|
|
919
|
+
let header = [];
|
|
920
|
+
for (let attempt = 0; ; attempt++) {
|
|
921
|
+
header = mergeHeader(existing, keys, used);
|
|
922
|
+
if (!header.includes("id")) {
|
|
923
|
+
if (used) header.push("id");
|
|
924
|
+
else header.unshift("id");
|
|
925
|
+
}
|
|
926
|
+
const changed = header.length !== existing.length || header.some((h, i) => h !== String(existing[i] ?? ""));
|
|
927
|
+
if (!changed) break;
|
|
928
|
+
if (attempt >= HEADER_WRITE_ATTEMPTS) {
|
|
929
|
+
throw new DeliveryError("Google Sheets header kept changing while it was written", { retryable: true });
|
|
930
|
+
}
|
|
931
|
+
if (header.length > columns) {
|
|
932
|
+
await batchUpdate("add columns", [
|
|
933
|
+
{ appendDimension: { sheetId: sheet.sheetId, dimension: "COLUMNS", length: header.length - columns } }
|
|
934
|
+
]);
|
|
935
|
+
columns = header.length;
|
|
936
|
+
}
|
|
937
|
+
await call("write header", `${range("A1")}?valueInputOption=RAW`, {
|
|
938
|
+
method: "PUT",
|
|
939
|
+
body: JSON.stringify({ values: [header] })
|
|
940
|
+
});
|
|
941
|
+
if (existing.length === 0) {
|
|
942
|
+
await batchUpdate("freeze header", [
|
|
943
|
+
{
|
|
944
|
+
updateSheetProperties: {
|
|
945
|
+
properties: { sheetId: sheet.sheetId, gridProperties: { frozenRowCount: 1 } },
|
|
946
|
+
fields: "gridProperties.frozenRowCount"
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
]);
|
|
950
|
+
}
|
|
951
|
+
({ used, existing } = await readHeader());
|
|
952
|
+
}
|
|
953
|
+
return {
|
|
954
|
+
spreadsheet_id: spreadsheetId,
|
|
955
|
+
sheet_name: sheetName,
|
|
956
|
+
sheet_id: sheet.sheetId,
|
|
957
|
+
header,
|
|
958
|
+
columns,
|
|
959
|
+
last_row: previous?.last_row ?? 0
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
let skipped = [];
|
|
963
|
+
async function append(state2) {
|
|
964
|
+
let pending = items.map((item, i) => ({ id: item.submission.id, row: rows[i].row }));
|
|
965
|
+
if (deliveryOptions.redelivery) {
|
|
966
|
+
const column = columnLetter(state2.header.indexOf("id"));
|
|
967
|
+
const from = state2.last_row > 0 ? Math.max(2, state2.last_row - DEDUPE_WINDOW) : 2;
|
|
968
|
+
const ids = await call("read ids", range(`${column}${from}:${column}`));
|
|
969
|
+
const present = new Set((ids.values ?? []).map((r) => String(r?.[0] ?? "")));
|
|
970
|
+
skipped = pending.filter((p) => present.has(p.id)).map((p) => p.id);
|
|
971
|
+
pending = pending.filter((p) => !present.has(p.id));
|
|
972
|
+
}
|
|
973
|
+
if (!pending.length) return state2;
|
|
974
|
+
const result = await call(
|
|
975
|
+
"append",
|
|
976
|
+
`${range("A1")}:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS`,
|
|
977
|
+
{ method: "POST", body: JSON.stringify({ values: pending.map((p) => rowFor(state2.header, p.row, escape)) }) }
|
|
978
|
+
);
|
|
979
|
+
const last = /![A-Z]+\d+:[A-Z]+(\d+)$/.exec(result.updates?.updatedRange ?? "")?.[1];
|
|
980
|
+
return { ...state2, last_row: last ? Number(last) : state2.last_row + pending.length };
|
|
981
|
+
}
|
|
982
|
+
const cached = readState(deliveryOptions.state, spreadsheetId, sheetName);
|
|
983
|
+
const fresh = !cached || keys.some((k) => !cached.header.includes(k));
|
|
984
|
+
let state = fresh ? await setUp(cached) : cached;
|
|
985
|
+
try {
|
|
986
|
+
state = await append(state);
|
|
987
|
+
} catch (error) {
|
|
988
|
+
if (!(error instanceof StaleSheetError) || fresh) throw error;
|
|
989
|
+
state = await append(await setUp(cached));
|
|
990
|
+
}
|
|
991
|
+
return {
|
|
992
|
+
ok: true,
|
|
993
|
+
state,
|
|
994
|
+
...skipped.length ? { skipped } : {}
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
const guarded = async (...args) => {
|
|
998
|
+
try {
|
|
999
|
+
return await run(...args);
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
return deliveryFailure(error);
|
|
1002
|
+
}
|
|
1003
|
+
};
|
|
1004
|
+
return {
|
|
1005
|
+
kind: "sheet",
|
|
1006
|
+
deliver: (submission, definition, config, deliveryOptions = {}) => guarded([{ submission, definition, deliveryId: deliveryOptions.deliveryId }], config, deliveryOptions),
|
|
1007
|
+
deliverBatch: (items, config, deliveryOptions = {}) => guarded(items, config, deliveryOptions)
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// src/destinations/webhook.ts
|
|
1012
|
+
var MIN_SECRET_BYTES = 16;
|
|
1013
|
+
var STRICT_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
1014
|
+
function secretKey(secret) {
|
|
1015
|
+
const encoded = secret.startsWith("whsec_") ? secret.slice(6) : secret;
|
|
1016
|
+
if (!encoded || !STRICT_BASE64.test(encoded)) {
|
|
1017
|
+
throw new DeliveryError(
|
|
1018
|
+
"The webhook secret must be base64, optionally prefixed with whsec_ (Standard Webhooks libraries base64-decode it)"
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
const bytes = fromBase64(encoded);
|
|
1022
|
+
if (bytes.length < MIN_SECRET_BYTES) throw new DeliveryError(`The webhook secret is shorter than ${MIN_SECRET_BYTES} bytes`);
|
|
1023
|
+
return bytes;
|
|
1024
|
+
}
|
|
1025
|
+
async function hmacKey(secret, usage) {
|
|
1026
|
+
return crypto.subtle.importKey("raw", secretKey(secret), { name: "HMAC", hash: "SHA-256" }, false, [usage]);
|
|
1027
|
+
}
|
|
1028
|
+
async function signWebhook(secret, id, timestamp, body) {
|
|
1029
|
+
const key = await hmacKey(secret, "sign");
|
|
1030
|
+
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${id}.${timestamp}.${body}`));
|
|
1031
|
+
return `v1,${base64(new Uint8Array(mac))}`;
|
|
1032
|
+
}
|
|
1033
|
+
async function verifyWebhook(input) {
|
|
1034
|
+
const header = (name) => {
|
|
1035
|
+
if (typeof input.headers.get === "function") return input.headers.get(name) ?? "";
|
|
1036
|
+
const raw = input.headers[name];
|
|
1037
|
+
return Array.isArray(raw) ? raw[0] ?? "" : raw ?? "";
|
|
1038
|
+
};
|
|
1039
|
+
const id = header("webhook-id");
|
|
1040
|
+
const timestamp = header("webhook-timestamp");
|
|
1041
|
+
const signatures = header("webhook-signature");
|
|
1042
|
+
if (!id || !/^\d{1,12}$/.test(timestamp) || !signatures) return false;
|
|
1043
|
+
const now = Math.floor((input.now?.() ?? Date.now()) / 1e3);
|
|
1044
|
+
if (Math.abs(now - Number(timestamp)) > (input.toleranceSeconds ?? 300)) return false;
|
|
1045
|
+
let key;
|
|
1046
|
+
try {
|
|
1047
|
+
key = await hmacKey(input.secret, "verify");
|
|
1048
|
+
} catch {
|
|
1049
|
+
return false;
|
|
1050
|
+
}
|
|
1051
|
+
const data = new TextEncoder().encode(`${id}.${timestamp}.${input.body}`);
|
|
1052
|
+
for (const part of signatures.split(" ")) {
|
|
1053
|
+
const [version, value] = part.split(",", 2);
|
|
1054
|
+
if (version !== "v1" || !value) continue;
|
|
1055
|
+
let mac;
|
|
1056
|
+
try {
|
|
1057
|
+
mac = fromBase64(value);
|
|
1058
|
+
} catch {
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
if (await crypto.subtle.verify("HMAC", key, mac, data)) return true;
|
|
1062
|
+
}
|
|
1063
|
+
return false;
|
|
1064
|
+
}
|
|
1065
|
+
var PRIVATE_HOSTNAME = /(^|\.)(localhost|local|internal|localdomain|home\.arpa)$/i;
|
|
1066
|
+
function isPrivateHost(hostname) {
|
|
1067
|
+
const host = hostname.toLowerCase().replace(/\.$/, "");
|
|
1068
|
+
if (PRIVATE_HOSTNAME.test(host)) return true;
|
|
1069
|
+
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
1070
|
+
if (v4) return isPrivateV4(v4.slice(1).map(Number));
|
|
1071
|
+
if (host.startsWith("[") && host.endsWith("]")) {
|
|
1072
|
+
const v6 = host.slice(1, -1);
|
|
1073
|
+
if (v6 === "::" || v6 === "::1") return true;
|
|
1074
|
+
const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(v6);
|
|
1075
|
+
if (mapped) {
|
|
1076
|
+
const hi = parseInt(mapped[1], 16);
|
|
1077
|
+
const lo = parseInt(mapped[2], 16);
|
|
1078
|
+
return isPrivateV4([hi >> 8, hi & 255, lo >> 8, lo & 255]);
|
|
1079
|
+
}
|
|
1080
|
+
const first = parseInt(v6.split(":")[0] || "0", 16);
|
|
1081
|
+
return (first & 65024) === 64512 || (first & 65472) === 65152;
|
|
1082
|
+
}
|
|
1083
|
+
return false;
|
|
1084
|
+
}
|
|
1085
|
+
function isPrivateV4([a, b]) {
|
|
1086
|
+
return a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 198 && (b === 18 || b === 19) || a >= 224;
|
|
1087
|
+
}
|
|
1088
|
+
function checkWebhookUrl(raw, options = {}) {
|
|
1089
|
+
if (!raw) throw new DeliveryError("The webhook destination has no url");
|
|
1090
|
+
let url;
|
|
1091
|
+
try {
|
|
1092
|
+
url = new URL(raw);
|
|
1093
|
+
} catch {
|
|
1094
|
+
throw new DeliveryError("The webhook url is not a valid URL");
|
|
1095
|
+
}
|
|
1096
|
+
if (url.protocol !== "https:" && !(options.allowInsecureUrls && url.protocol === "http:")) {
|
|
1097
|
+
throw new DeliveryError("The webhook url must use https");
|
|
1098
|
+
}
|
|
1099
|
+
if (url.username || url.password) throw new DeliveryError("The webhook url must not contain credentials");
|
|
1100
|
+
if (!options.allowPrivateHosts && isPrivateHost(url.hostname)) {
|
|
1101
|
+
throw new DeliveryError("The webhook url points at a private or local address");
|
|
1102
|
+
}
|
|
1103
|
+
return url;
|
|
1104
|
+
}
|
|
1105
|
+
function webhookPayload(submission, definition, options = {}) {
|
|
1106
|
+
const sensitive = options.sensitive ?? "include";
|
|
1107
|
+
const { row } = responseRow(submission, definition, { sensitive });
|
|
1108
|
+
const answers = {};
|
|
1109
|
+
for (const block of definition.blocks) {
|
|
1110
|
+
if (block.kind !== "field") continue;
|
|
1111
|
+
const field = block;
|
|
1112
|
+
const value = own(submission.answers, field.id);
|
|
1113
|
+
if (value === void 0 || withheld(definition, field)) continue;
|
|
1114
|
+
if (isSensitive(field) && sensitive !== "include") {
|
|
1115
|
+
if (sensitive === "mask") answers[field.id] = "[sensitive]";
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
answers[field.id] = value;
|
|
1119
|
+
}
|
|
1120
|
+
const inputs = visibleInputs(submission, definition, sensitive);
|
|
1121
|
+
const pick = (source, allowed) => {
|
|
1122
|
+
const out = {};
|
|
1123
|
+
for (const [name, value] of Object.entries(source ?? {})) if (own(allowed, name) !== void 0) out[name] = value;
|
|
1124
|
+
return out;
|
|
1125
|
+
};
|
|
1126
|
+
return {
|
|
1127
|
+
type: options.type ?? "form.submitted",
|
|
1128
|
+
timestamp: new Date(options.now ?? Date.now()).toISOString(),
|
|
1129
|
+
data: {
|
|
1130
|
+
form: { id: definition.id, title: definition.title },
|
|
1131
|
+
submission: {
|
|
1132
|
+
id: submission.id,
|
|
1133
|
+
answers,
|
|
1134
|
+
hidden: pick(submission.hidden, inputs),
|
|
1135
|
+
context: pick(submission.context, inputs),
|
|
1136
|
+
ending: submission.ending ?? null,
|
|
1137
|
+
submittedAt: submission.submittedAt ?? null,
|
|
1138
|
+
meta: { ...submission.meta },
|
|
1139
|
+
tags: [...submission.tags ?? []]
|
|
1140
|
+
},
|
|
1141
|
+
row
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
function webhookDestination(options) {
|
|
1146
|
+
return {
|
|
1147
|
+
kind: "webhook",
|
|
1148
|
+
async deliver(submission, definition, config, deliveryOptions = {}) {
|
|
1149
|
+
try {
|
|
1150
|
+
const url = checkWebhookUrl(configString(config, "url"), options);
|
|
1151
|
+
const secret = await resolveOption("The webhook secret", options.secret, config);
|
|
1152
|
+
if (!secret) throw new DeliveryError("The webhook secret is not set");
|
|
1153
|
+
const now = options.now?.() ?? Date.now();
|
|
1154
|
+
const id = deliveryOptions.deliveryId ?? submission.id;
|
|
1155
|
+
const timestamp = Math.floor(now / 1e3);
|
|
1156
|
+
const body = JSON.stringify(
|
|
1157
|
+
webhookPayload(submission, definition, { sensitive: options.sensitive, type: options.type, now })
|
|
1158
|
+
);
|
|
1159
|
+
const response = await timedFetch(
|
|
1160
|
+
options,
|
|
1161
|
+
url.toString(),
|
|
1162
|
+
{
|
|
1163
|
+
method: "POST",
|
|
1164
|
+
redirect: "manual",
|
|
1165
|
+
headers: {
|
|
1166
|
+
"content-type": "application/json",
|
|
1167
|
+
"user-agent": "genesis-forms-webhook/1",
|
|
1168
|
+
"webhook-id": id,
|
|
1169
|
+
"webhook-timestamp": String(timestamp),
|
|
1170
|
+
"webhook-signature": await signWebhook(secret, id, timestamp, body)
|
|
1171
|
+
},
|
|
1172
|
+
body
|
|
1173
|
+
},
|
|
1174
|
+
deliveryOptions.signal
|
|
1175
|
+
);
|
|
1176
|
+
discard(response);
|
|
1177
|
+
if (response.status >= 200 && response.status < 300) return { ok: true };
|
|
1178
|
+
if (response.status === 0 || response.status >= 300 && response.status < 400) {
|
|
1179
|
+
throw new DeliveryError(`Webhook answered ${response.status || "a redirect"}: redirects are not followed`, {
|
|
1180
|
+
...response.status ? { status: response.status } : {}
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
throw httpError("Webhook", response);
|
|
1184
|
+
} catch (error) {
|
|
1185
|
+
return deliveryFailure(error);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
export { DEDUPE_WINDOW, DEFAULT_SHEET_NAME, DeliveryError, GOOGLE_TOKEN_URL, HEADER_SCAN_ROWS, MAX_RECIPIENTS, REQUEST_TIMEOUT_MS, RESEND_API, SENSITIVE_MASK, SHEETS_API, SHEETS_SCOPE, answerCell, checkWebhookUrl, clearGoogleTokenCache, columnLetter, deliveryFailure, emailBody, emailDestination, escapeFormula, escapeHtml, googleAccessToken, headerSafe, httpError, idempotencyKey, isPrivateHost, isRetryableStatus, isSafeAddress, isSafeFrom, mergeHeader, renderEmail, resendProvider, responseRow, retryAfterSeconds, rowFor, sheetsDestination, signJwt, signWebhook, verifyWebhook, visibleInputs, webhookDestination, webhookPayload };
|
|
1192
|
+
//# sourceMappingURL=destinations.js.map
|
|
1193
|
+
//# sourceMappingURL=destinations.js.map
|