@marktiderman/genesis-forms 0.7.0 → 0.8.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.
@@ -74,10 +74,10 @@ async function timedFetch(deps, url, init, signal) {
74
74
  throw error;
75
75
  }
76
76
  }
77
- async function resolveOption(what, option, config) {
77
+ async function resolveOption(what, option, config, context = {}) {
78
78
  if (typeof option !== "function") return option;
79
79
  try {
80
- return await option(config);
80
+ return await option(config, context);
81
81
  } catch (error) {
82
82
  if (error instanceof DeliveryError) throw error;
83
83
  throw new DeliveryError(`${what} could not be resolved`);
@@ -774,6 +774,53 @@ for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldTyp
774
774
  function own(record, key) {
775
775
  return Object.prototype.hasOwnProperty.call(record, key) ? record[key] : void 0;
776
776
  }
777
+ function resolve(operand, scope) {
778
+ if ("field" in operand) return own(scope.values, operand.field);
779
+ if ("hidden" in operand) return own(scope.hidden, operand.hidden);
780
+ return operand.value;
781
+ }
782
+ function deepEqual(a, b) {
783
+ if (a === b) return true;
784
+ if (Array.isArray(a) && Array.isArray(b)) {
785
+ return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
786
+ }
787
+ return false;
788
+ }
789
+ function evaluate(condition, scope) {
790
+ if ("all" in condition) return condition.all.every((c) => evaluate(c, scope));
791
+ if ("any" in condition) return condition.any.some((c) => evaluate(c, scope));
792
+ if ("not" in condition) return !evaluate(condition.not, scope);
793
+ const left = resolve(condition.left, scope);
794
+ const right = condition.right ? resolve(condition.right, scope) : void 0;
795
+ const field = "field" in condition.left ? scope.fields.get(condition.left.field) : void 0;
796
+ const unanswered = isEmptyValue(left, field);
797
+ switch (condition.op) {
798
+ case "answered":
799
+ return !unanswered;
800
+ case "empty":
801
+ return unanswered;
802
+ case "eq":
803
+ return !unanswered && deepEqual(left, right);
804
+ case "neq":
805
+ return unanswered || !deepEqual(left, right);
806
+ case "lt":
807
+ case "lte":
808
+ case "gt":
809
+ case "gte": {
810
+ if (typeof left !== "number" || typeof right !== "number") return false;
811
+ if (condition.op === "lt") return left < right;
812
+ if (condition.op === "lte") return left <= right;
813
+ if (condition.op === "gt") return left > right;
814
+ return left >= right;
815
+ }
816
+ case "in":
817
+ return !unanswered && Array.isArray(right) && right.some((r) => deepEqual(r, left));
818
+ case "contains":
819
+ if (unanswered) return false;
820
+ if (Array.isArray(left)) return left.some((l) => deepEqual(l, right));
821
+ return typeof left === "string" && typeof right === "string" && left.toLowerCase().includes(right.toLowerCase());
822
+ }
823
+ }
777
824
  function withheld(def, field) {
778
825
  return def.settings?.anonymous === true && field?.identifying === true;
779
826
  }
@@ -1141,9 +1188,20 @@ function sheetsDestination(options) {
1141
1188
  async function run(items, config, deliveryOptions) {
1142
1189
  if (!items.length) return { ok: true };
1143
1190
  const { spreadsheetId, sheetName } = configOf(config);
1144
- const account = await resolveOption("The sheet destination's account", options.account, config);
1145
1191
  const signal = deliveryOptions.signal;
1146
- const token = await googleAccessToken(account, { ...options, signal });
1192
+ const context = { destinationId: deliveryOptions.destinationId };
1193
+ let account = null;
1194
+ let token;
1195
+ if (options.accessToken) {
1196
+ token = await resolveOption("The sheet destination's access token", options.accessToken, config, context);
1197
+ if (typeof token !== "string" || !token) throw new DeliveryError("The sheet destination has no access token");
1198
+ } else if (options.account) {
1199
+ account = await resolveOption("The sheet destination's account", options.account, config, context);
1200
+ token = await googleAccessToken(account, { ...options, signal });
1201
+ } else {
1202
+ throw new DeliveryError("The sheet destination has neither an account nor an access token");
1203
+ }
1204
+ const sharedWith = account ? `is the sheet shared with ${account.clientEmail}?` : "is the sheet still connected to its Google account?";
1147
1205
  const base = `${SHEETS_API}/${encodeURIComponent(spreadsheetId)}`;
1148
1206
  const escape = options.escapeFormulas === true;
1149
1207
  const call = async (what, url, init = {}) => {
@@ -1155,12 +1213,12 @@ function sheetsDestination(options) {
1155
1213
  );
1156
1214
  if (!response.ok) {
1157
1215
  discard(response);
1158
- if (what === "append" && (response.status === 400 || response.status === 404)) {
1216
+ if ((what === "append" || what === "update rows") && (response.status === 400 || response.status === 404)) {
1159
1217
  throw new StaleSheetError(`Google Sheets append answered ${response.status}`, { status: response.status });
1160
1218
  }
1161
1219
  if (response.status === 403 || response.status === 404) {
1162
1220
  throw new DeliveryError(
1163
- `Google Sheets ${what} answered ${response.status}: is the sheet shared with ${account.clientEmail}?`,
1221
+ `Google Sheets ${what} answered ${response.status}: ${sharedWith}`,
1164
1222
  { status: response.status }
1165
1223
  );
1166
1224
  }
@@ -1256,14 +1314,45 @@ function sheetsDestination(options) {
1256
1314
  }
1257
1315
  let skipped = [];
1258
1316
  async function append(state2) {
1259
- let pending = items.map((item, i) => ({ id: item.submission.id, row: rows[i].row }));
1260
- if (deliveryOptions.redelivery) {
1317
+ const byId = /* @__PURE__ */ new Map();
1318
+ items.forEach((item, i) => {
1319
+ const id = item.submission.id;
1320
+ const update = (item.update ?? deliveryOptions.update) === true || byId.get(id)?.update === true;
1321
+ byId.delete(id);
1322
+ byId.set(id, { id, row: rows[i].row, update });
1323
+ });
1324
+ let pending = [...byId.values()];
1325
+ const updating = pending.some((p) => p.update);
1326
+ const overwrite = [];
1327
+ skipped = [];
1328
+ if (deliveryOptions.redelivery || updating) {
1261
1329
  const column = columnLetter(state2.header.indexOf("id"));
1262
- const from = state2.last_row > 0 ? Math.max(2, state2.last_row - DEDUPE_WINDOW) : 2;
1330
+ const from = !updating && state2.last_row > 0 ? Math.max(2, state2.last_row - DEDUPE_WINDOW) : 2;
1263
1331
  const ids = await call("read ids", range(`${column}${from}:${column}`));
1264
- const present = new Set((ids.values ?? []).map((r) => String(r?.[0] ?? "")));
1265
- skipped = pending.filter((p) => present.has(p.id)).map((p) => p.id);
1266
- pending = pending.filter((p) => !present.has(p.id));
1332
+ const rowOf = /* @__PURE__ */ new Map();
1333
+ (ids.values ?? []).forEach((r, i) => {
1334
+ const id = String(r?.[0] ?? "");
1335
+ if (id && !rowOf.has(id)) rowOf.set(id, from + i);
1336
+ });
1337
+ pending = pending.filter((p) => {
1338
+ const at = rowOf.get(p.id);
1339
+ if (at === void 0) return true;
1340
+ if (p.update) overwrite.push({ at, row: p.row });
1341
+ else skipped.push(p.id);
1342
+ return false;
1343
+ });
1344
+ }
1345
+ if (overwrite.length) {
1346
+ await call("update rows", `${base}/values:batchUpdate`, {
1347
+ method: "POST",
1348
+ body: JSON.stringify({
1349
+ valueInputOption: "RAW",
1350
+ data: overwrite.map((o) => ({
1351
+ range: `${quoteSheet(sheetName)}!A${o.at}`,
1352
+ values: [rowFor(state2.header, o.row, escape)]
1353
+ }))
1354
+ })
1355
+ });
1267
1356
  }
1268
1357
  if (!pending.length) return state2;
1269
1358
  const result = await call(
@@ -1298,11 +1387,147 @@ function sheetsDestination(options) {
1298
1387
  };
1299
1388
  return {
1300
1389
  kind: "sheet",
1301
- deliver: (submission, definition, config, deliveryOptions = {}) => guarded([{ submission, definition, deliveryId: deliveryOptions.deliveryId }], config, deliveryOptions),
1390
+ deliver: (submission, definition, config, deliveryOptions = {}) => guarded(
1391
+ [{ submission, definition, deliveryId: deliveryOptions.deliveryId, update: deliveryOptions.update }],
1392
+ config,
1393
+ deliveryOptions
1394
+ ),
1302
1395
  deliverBatch: (items, config, deliveryOptions = {}) => guarded(items, config, deliveryOptions)
1303
1396
  };
1304
1397
  }
1305
1398
 
1399
+ // src/destinations/hook-url.ts
1400
+ var MAX_HOOK_URL_LENGTH = 2048;
1401
+ var SLACK_HOSTS = /* @__PURE__ */ new Set(["hooks.slack.com"]);
1402
+ var HookUrlError = class extends DeliveryError {
1403
+ name = "HookUrlError";
1404
+ };
1405
+ var NOT_PUBLIC = "The address must be on the public internet.";
1406
+ var INTERNAL_SUFFIXES = [".localhost", ".local", ".internal", ".intranet", ".lan", ".home.arpa"];
1407
+ function ipv4Bytes(ip) {
1408
+ const parts = ip.split(".");
1409
+ if (parts.length !== 4) return null;
1410
+ const bytes = parts.map((p) => /^\d{1,3}$/.test(p) ? Number(p) : NaN);
1411
+ return bytes.every((b) => Number.isInteger(b) && b >= 0 && b <= 255) ? bytes : null;
1412
+ }
1413
+ function publicIpv4(b) {
1414
+ const [a, c] = [b[0], b[1]];
1415
+ if (a === 0 || a === 10 || a === 127) return false;
1416
+ if (a === 100 && c >= 64 && c <= 127) return false;
1417
+ if (a === 169 && c === 254) return false;
1418
+ if (a === 172 && c >= 16 && c <= 31) return false;
1419
+ if (a === 192 && c === 168) return false;
1420
+ if (a === 192 && c === 0 && (b[2] === 0 || b[2] === 2)) return false;
1421
+ if (a === 198 && (c === 18 || c === 19)) return false;
1422
+ if (a === 198 && c === 51 && b[2] === 100) return false;
1423
+ if (a === 203 && c === 0 && b[2] === 113) return false;
1424
+ if (a >= 224) return false;
1425
+ return true;
1426
+ }
1427
+ function ipv6Bytes(ip) {
1428
+ let text = ip.replace(/^\[|\]$/g, "").toLowerCase();
1429
+ const zone = text.indexOf("%");
1430
+ if (zone >= 0) text = text.slice(0, zone);
1431
+ let tail = [];
1432
+ const lastColon = text.lastIndexOf(":");
1433
+ if (text.slice(lastColon + 1).includes(".")) {
1434
+ const v4 = ipv4Bytes(text.slice(lastColon + 1));
1435
+ if (!v4) return null;
1436
+ tail = v4;
1437
+ text = text.slice(0, lastColon + 1) + "0:0";
1438
+ }
1439
+ const halves = text.split("::");
1440
+ if (halves.length > 2) return null;
1441
+ const groups = (s) => s === "" ? [] : s.split(":");
1442
+ const head = groups(halves[0]);
1443
+ const rest = halves.length === 2 ? groups(halves[1]) : [];
1444
+ const missing = 8 - head.length - rest.length;
1445
+ if (halves.length === 1 ? missing !== 0 : missing < 1) return null;
1446
+ const all = [...head, ...Array(halves.length === 2 ? missing : 0).fill("0"), ...rest];
1447
+ const bytes = [];
1448
+ for (const g of all) {
1449
+ if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
1450
+ const n = parseInt(g, 16);
1451
+ bytes.push(n >> 8, n & 255);
1452
+ }
1453
+ if (tail.length) bytes.splice(12, 4, ...tail);
1454
+ return bytes;
1455
+ }
1456
+ function publicIpv6(b) {
1457
+ const zeros = (from, to) => b.slice(from, to).every((x) => x === 0);
1458
+ if (zeros(0, 16)) return false;
1459
+ if (zeros(0, 15) && b[15] === 1) return false;
1460
+ if (zeros(0, 10) && b[10] === 255 && b[11] === 255) return publicIpv4(b.slice(12));
1461
+ if (zeros(0, 12)) return publicIpv4(b.slice(12));
1462
+ if (b[0] === 0 && b[1] === 100 && b[2] === 255 && b[3] === 155 && zeros(4, 12)) return publicIpv4(b.slice(12));
1463
+ if ((b[0] & 254) === 252) return false;
1464
+ if (b[0] === 254 && (b[1] & 192) === 128) return false;
1465
+ if (b[0] === 254 && (b[1] & 192) === 192) return false;
1466
+ if (b[0] === 255) return false;
1467
+ if (b[0] === 32 && b[1] === 1 && b[2] === 13 && b[3] === 184) return false;
1468
+ if (b[0] === 32 && b[1] === 1 && b[2] === 0 && b[3] === 0) return false;
1469
+ if (b[0] === 32 && b[1] === 2) return publicIpv4(b.slice(2, 6));
1470
+ if (b[0] === 1 && zeros(1, 8)) return false;
1471
+ return true;
1472
+ }
1473
+ function isPublicAddress(ip) {
1474
+ const v4 = ipv4Bytes(ip);
1475
+ if (v4) return publicIpv4(v4);
1476
+ const v6 = ipv6Bytes(ip);
1477
+ return v6 ? publicIpv6(v6) : false;
1478
+ }
1479
+ function isIpLiteral(host) {
1480
+ return ipv4Bytes(host) !== null || host.startsWith("[");
1481
+ }
1482
+ function parseHookUrl(raw, kind = "webhook") {
1483
+ const text = raw.trim();
1484
+ if (!text) throw new HookUrlError("Paste the address to send responses to.");
1485
+ if (text.length > MAX_HOOK_URL_LENGTH) throw new HookUrlError("That address is too long.");
1486
+ let url;
1487
+ try {
1488
+ url = new URL(text);
1489
+ } catch {
1490
+ throw new HookUrlError("That doesn't look like a web address.");
1491
+ }
1492
+ if (url.protocol !== "https:") throw new HookUrlError("The address must start with https://.");
1493
+ if (url.username || url.password) throw new HookUrlError("The address can't include a user name or password.");
1494
+ if (url.port && url.port !== "443") throw new HookUrlError("The address must use the standard https port.");
1495
+ url.hash = "";
1496
+ const host = url.hostname.replace(/\.$/, "");
1497
+ if (kind === "slack") {
1498
+ if (!SLACK_HOSTS.has(host) || !url.pathname.startsWith("/services/")) {
1499
+ throw new HookUrlError("Use a Slack incoming webhook address, which starts with https://hooks.slack.com/services/.");
1500
+ }
1501
+ return url;
1502
+ }
1503
+ if (isIpLiteral(host)) {
1504
+ if (!isPublicAddress(host)) throw new HookUrlError(NOT_PUBLIC);
1505
+ return url;
1506
+ }
1507
+ if (!host.includes(".") || host === "localhost" || INTERNAL_SUFFIXES.some((suffix) => host.endsWith(suffix))) {
1508
+ throw new HookUrlError(NOT_PUBLIC);
1509
+ }
1510
+ return url;
1511
+ }
1512
+ async function checkHookHost(url, resolve2) {
1513
+ const host = url.hostname.replace(/\.$/, "");
1514
+ if (isIpLiteral(host)) {
1515
+ if (!isPublicAddress(host)) throw new HookUrlError(NOT_PUBLIC);
1516
+ return;
1517
+ }
1518
+ let addresses;
1519
+ try {
1520
+ addresses = await resolve2(host);
1521
+ } catch {
1522
+ throw new HookUrlError(`${host} couldn't be found`, { retryable: true });
1523
+ }
1524
+ if (!addresses.length) throw new HookUrlError(`${host} couldn't be found`, { retryable: true });
1525
+ if (!addresses.every(isPublicAddress)) throw new HookUrlError(NOT_PUBLIC);
1526
+ }
1527
+ function hookHost(url) {
1528
+ return url.hostname.replace(/\.$/, "").toLowerCase();
1529
+ }
1530
+
1306
1531
  // src/destinations/webhook.ts
1307
1532
  var MIN_SECRET_BYTES = 16;
1308
1533
  var STRICT_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
@@ -1320,6 +1545,9 @@ function secretKey(secret) {
1320
1545
  async function hmacKey(secret, usage) {
1321
1546
  return crypto.subtle.importKey("raw", secretKey(secret), { name: "HMAC", hash: "SHA-256" }, false, [usage]);
1322
1547
  }
1548
+ function newWebhookSecret() {
1549
+ return `whsec_${base64(crypto.getRandomValues(new Uint8Array(32)))}`;
1550
+ }
1323
1551
  async function signWebhook(secret, id, timestamp, body) {
1324
1552
  const key = await hmacKey(secret, "sign");
1325
1553
  const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${id}.${timestamp}.${body}`));
@@ -1442,15 +1670,17 @@ function webhookDestination(options) {
1442
1670
  kind: "webhook",
1443
1671
  async deliver(submission, definition, config, deliveryOptions = {}) {
1444
1672
  try {
1445
- const url = checkWebhookUrl(configString(config, "url"), options);
1446
- const secret = await resolveOption("The webhook secret", options.secret, config);
1673
+ const context = { destinationId: deliveryOptions.destinationId };
1674
+ const raw = options.url ? await resolveOption("The webhook url", options.url, config, context) : configString(config, "url");
1675
+ const url = checkWebhookUrl(typeof raw === "string" ? raw.trim() : "", options);
1676
+ if (options.resolveHost && !options.allowPrivateHosts) await checkHookHost(url, options.resolveHost);
1677
+ const secret = await resolveOption("The webhook secret", options.secret, config, context);
1447
1678
  if (!secret) throw new DeliveryError("The webhook secret is not set");
1448
1679
  const now = options.now?.() ?? Date.now();
1449
1680
  const id = deliveryOptions.deliveryId ?? submission.id;
1450
1681
  const timestamp = Math.floor(now / 1e3);
1451
- const body = JSON.stringify(
1452
- webhookPayload(submission, definition, { sensitive: options.sensitive, type: options.type, now })
1453
- );
1682
+ const type = options.type ?? (deliveryOptions.update ? "form.updated" : void 0);
1683
+ const body = JSON.stringify(webhookPayload(submission, definition, { sensitive: options.sensitive, type, now }));
1454
1684
  const response = await timedFetch(
1455
1685
  options,
1456
1686
  url.toString(),
@@ -1483,43 +1713,453 @@ function webhookDestination(options) {
1483
1713
  };
1484
1714
  }
1485
1715
 
1716
+ // src/destinations/slack.ts
1717
+ var SLACK_VALUE_LIMIT = 1500;
1718
+ function escapeSlack(text) {
1719
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1720
+ }
1721
+ var cut = (text) => text.length > SLACK_VALUE_LIMIT ? `${text.slice(0, SLACK_VALUE_LIMIT)}\u2026` : text;
1722
+ function slackMessage(submission, definition, options = {}) {
1723
+ const title = definition.title || definition.id;
1724
+ const heading = options.heading ?? `${options.update ? "Updated response" : "New response"}: ${title}`;
1725
+ const lines = [`*${escapeSlack(heading)}*`];
1726
+ for (const [label, value] of options.lines ?? []) {
1727
+ if (value.trim()) lines.push(`*${escapeSlack(label)}*: ${escapeSlack(cut(value))}`);
1728
+ }
1729
+ const { row, labels } = responseRow(submission, definition, { sensitive: options.sensitive ?? "mask" });
1730
+ const omit = new Set(options.omit ?? []);
1731
+ let answered = 0;
1732
+ for (const [key, label] of Object.entries(labels)) {
1733
+ const text = String(row[key] ?? "");
1734
+ if (!text || omit.has(key)) continue;
1735
+ answered++;
1736
+ lines.push(`*${escapeSlack(label || key)}*: ${escapeSlack(cut(text))}`);
1737
+ }
1738
+ if (!answered) lines.push("_No answers._");
1739
+ const when = submission.submittedAt ? ` \xB7 ${escapeSlack(submission.submittedAt)}` : "";
1740
+ const link = options.link;
1741
+ lines.push(
1742
+ link && /^https?:\/\//i.test(link.url) ? `<${link.url.replace(/[<>|]/g, "")}|${escapeSlack(link.text ?? "Open the response").replace(/[|]/g, "")}>${when}` : `Response ${escapeSlack(submission.id)}${when}`
1743
+ );
1744
+ return { text: lines.join("\n") };
1745
+ }
1746
+ function slackDestination(options = {}) {
1747
+ return {
1748
+ kind: "slack",
1749
+ async deliver(submission, definition, config, deliveryOptions = {}) {
1750
+ try {
1751
+ const context = { destinationId: deliveryOptions.destinationId };
1752
+ const raw = options.url ? await resolveOption("The Slack webhook url", options.url, config, context) : configString(config, "url");
1753
+ if (typeof raw !== "string" || !raw.trim()) throw new DeliveryError("The Slack destination has no url");
1754
+ const url = parseHookUrl(raw, "slack");
1755
+ if (options.resolveHost) await checkHookHost(url, options.resolveHost);
1756
+ const update = deliveryOptions.update === true;
1757
+ const messageOptions = options.message ? await resolveOption(
1758
+ "The Slack message",
1759
+ () => options.message({ submission, definition, config, update, destinationId: deliveryOptions.destinationId }),
1760
+ config
1761
+ ) : {};
1762
+ const body = JSON.stringify(slackMessage(submission, definition, { update, ...messageOptions }));
1763
+ const response = await timedFetch(
1764
+ options,
1765
+ url.toString(),
1766
+ {
1767
+ method: "POST",
1768
+ redirect: "manual",
1769
+ headers: { "content-type": "application/json", "user-agent": "genesis-forms-slack/1" },
1770
+ body
1771
+ },
1772
+ deliveryOptions.signal
1773
+ );
1774
+ discard(response);
1775
+ if (response.status >= 200 && response.status < 300) return { ok: true };
1776
+ if (response.status === 0 || response.status >= 300 && response.status < 400) {
1777
+ throw new DeliveryError(`Slack answered ${response.status || "a redirect"}: redirects are not followed`, {
1778
+ ...response.status ? { status: response.status } : {}
1779
+ });
1780
+ }
1781
+ throw httpError("Slack", response);
1782
+ } catch (error) {
1783
+ return deliveryFailure(error);
1784
+ }
1785
+ }
1786
+ };
1787
+ }
1788
+
1789
+ // src/destinations/condition.ts
1790
+ var OPERATORS = /* @__PURE__ */ new Set(["eq", "neq", "lt", "lte", "gt", "gte", "in", "contains", "answered", "empty"]);
1791
+ var MAX_CONDITION_DEPTH = 10;
1792
+ var isObject2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1793
+ function isOperand(v) {
1794
+ if (!isObject2(v)) return false;
1795
+ const keys = Object.keys(v);
1796
+ if (keys.length !== 1) return false;
1797
+ if (keys[0] === "field" || keys[0] === "hidden") return typeof v[keys[0]] === "string";
1798
+ return keys[0] === "value";
1799
+ }
1800
+ function isCondition(v, depth = 0) {
1801
+ if (!isObject2(v) || depth > MAX_CONDITION_DEPTH) return false;
1802
+ if ("all" in v) return Array.isArray(v["all"]) && v["all"].every((c) => isCondition(c, depth + 1));
1803
+ if ("any" in v) return Array.isArray(v["any"]) && v["any"].every((c) => isCondition(c, depth + 1));
1804
+ if ("not" in v) return isCondition(v["not"], depth + 1);
1805
+ return typeof v["op"] === "string" && OPERATORS.has(v["op"]) && isOperand(v["left"]) && (v["right"] === void 0 || isOperand(v["right"]));
1806
+ }
1807
+ function conditionOf(config) {
1808
+ const when = config["when"];
1809
+ if (when === void 0 || when === null) return null;
1810
+ if (!isCondition(when)) throw new DeliveryError("The destination's condition can't be read");
1811
+ return when;
1812
+ }
1813
+ function passesCondition(submission, definition, config) {
1814
+ const condition = conditionOf(config);
1815
+ if (!condition) return true;
1816
+ const fields = /* @__PURE__ */ new Map();
1817
+ for (const block of definition.blocks) if (block.kind === "field") fields.set(block.id, block);
1818
+ const hidden = /* @__PURE__ */ Object.create(null);
1819
+ for (const source of [submission.hidden, submission.context]) {
1820
+ for (const name of Object.keys(source ?? {})) {
1821
+ const value = own(source, name);
1822
+ if (value !== void 0) hidden[name] = value;
1823
+ }
1824
+ }
1825
+ return evaluate(condition, { values: submission.answers, hidden, fields });
1826
+ }
1827
+
1828
+ // src/destinations/google-oauth.ts
1829
+ var DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file";
1830
+ async function refreshGoogleAccessToken(client, refreshToken, options = {}) {
1831
+ if (!client.clientId || !client.clientSecret) throw new DeliveryError("The Google OAuth client id or secret is not set");
1832
+ if (!refreshToken) throw new DeliveryError("This sheet's Google connection is gone. Connect the sheet again.");
1833
+ const response = await timedFetch(
1834
+ options,
1835
+ GOOGLE_TOKEN_URL,
1836
+ {
1837
+ method: "POST",
1838
+ // The body holds the refresh token and client secret: never follow a redirect with it.
1839
+ redirect: "error",
1840
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1841
+ body: new URLSearchParams({
1842
+ grant_type: "refresh_token",
1843
+ refresh_token: refreshToken,
1844
+ client_id: client.clientId,
1845
+ client_secret: client.clientSecret
1846
+ }).toString()
1847
+ },
1848
+ options.signal
1849
+ );
1850
+ if (!response.ok) {
1851
+ const body2 = jsonObject(await response.json().catch(() => null));
1852
+ if (response.status === 400 && body2.error === "invalid_grant") {
1853
+ throw new DeliveryError("Google access to this sheet was removed. Connect the sheet again.", { status: 400 });
1854
+ }
1855
+ discard(response);
1856
+ throw httpError("Google token endpoint", response);
1857
+ }
1858
+ const body = jsonObject(await response.json().catch(() => null));
1859
+ if (typeof body.access_token !== "string" || !body.access_token) {
1860
+ throw new DeliveryError("Google token endpoint returned no access token", { retryable: true });
1861
+ }
1862
+ return {
1863
+ accessToken: body.access_token,
1864
+ expiresIn: typeof body.expires_in === "number" && body.expires_in > 0 ? body.expires_in : 3600,
1865
+ scope: typeof body.scope === "string" ? body.scope : DRIVE_FILE_SCOPE
1866
+ };
1867
+ }
1868
+ function jsonObject(value) {
1869
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
1870
+ }
1871
+ var cache2 = /* @__PURE__ */ new Map();
1872
+ function clearGoogleOAuthTokenCache() {
1873
+ cache2.clear();
1874
+ }
1875
+ async function googleOAuthAccessToken(client, refreshToken, options = {}) {
1876
+ if (!refreshToken) throw new DeliveryError("This sheet's Google connection is gone. Connect the sheet again.");
1877
+ const now = options.now?.() ?? Date.now();
1878
+ const key = await sha256Hex(`${client.clientId}
1879
+ ${refreshToken}`);
1880
+ const hit = cache2.get(key);
1881
+ if (hit && hit.expiresAt - 6e4 > now) return hit.token;
1882
+ const fresh = await refreshGoogleAccessToken(client, refreshToken, options);
1883
+ cache2.set(key, { token: fresh.accessToken, expiresAt: now + fresh.expiresIn * 1e3 });
1884
+ return fresh.accessToken;
1885
+ }
1886
+
1887
+ // src/destinations/secret-box.ts
1888
+ var enc = new TextEncoder();
1889
+ function secretKeyBytes(key) {
1890
+ if (typeof key !== "string") {
1891
+ if (key.length !== 32) throw new DeliveryError("The encryption key must be 32 bytes");
1892
+ return new Uint8Array(key);
1893
+ }
1894
+ const text = key.trim().replace(/=+$/, "");
1895
+ if (!text) throw new DeliveryError("The encryption key is not set");
1896
+ let bytes;
1897
+ try {
1898
+ bytes = fromBase64(text + "=".repeat((4 - text.length % 4) % 4));
1899
+ } catch {
1900
+ throw new DeliveryError("The encryption key is not base64");
1901
+ }
1902
+ if (bytes.length !== 32) throw new DeliveryError("The encryption key must be 32 bytes");
1903
+ return bytes;
1904
+ }
1905
+ async function deriveSecretKey(key, info) {
1906
+ if (!info) throw new DeliveryError("A sealing key needs its purpose (info)");
1907
+ const base = await crypto.subtle.importKey("raw", secretKeyBytes(key), "HKDF", false, ["deriveKey"]);
1908
+ return crypto.subtle.deriveKey(
1909
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: enc.encode(info) },
1910
+ base,
1911
+ { name: "AES-GCM", length: 256 },
1912
+ false,
1913
+ ["encrypt", "decrypt"]
1914
+ );
1915
+ }
1916
+ async function sealSecret(key, plaintext, aad) {
1917
+ const iv = crypto.getRandomValues(new Uint8Array(12));
1918
+ const sealed = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: enc.encode(aad) }, key, enc.encode(plaintext));
1919
+ return `v1.${base64url(iv)}.${base64url(new Uint8Array(sealed))}`;
1920
+ }
1921
+ async function openSecret(key, sealed, aad) {
1922
+ const [version, iv, body, extra] = sealed.split(".");
1923
+ if (version !== "v1" || !iv || !body || extra !== void 0) throw new DeliveryError("The stored secret is not in a known format");
1924
+ try {
1925
+ const plain = await crypto.subtle.decrypt(
1926
+ { name: "AES-GCM", iv: padded(iv), additionalData: enc.encode(aad) },
1927
+ key,
1928
+ padded(body)
1929
+ );
1930
+ return new TextDecoder().decode(plain);
1931
+ } catch {
1932
+ throw new DeliveryError("The stored secret could not be decrypted");
1933
+ }
1934
+ }
1935
+ function padded(text) {
1936
+ return fromBase64(text + "=".repeat((4 - text.length % 4) % 4));
1937
+ }
1938
+
1939
+ // src/destinations/runner.ts
1940
+ var DEFAULT_MAX_ATTEMPTS = 8;
1941
+ var DEFAULT_MAX_DEFERRALS = 24;
1942
+ var DEFAULT_BATCH_SIZE = 25;
1943
+ function defaultBackoff(attempt) {
1944
+ return Math.min(6 * 3600, 60 * 2 ** Math.max(0, attempt - 1));
1945
+ }
1946
+ function deliveryOutcome(result, attempt, options = {}) {
1947
+ if (result.ok) return result.notSent ? { status: "skipped", reason: result.notSent.slice(0, 500) } : { status: "sent" };
1948
+ const http = result.status !== void 0 ? { httpStatus: result.status } : {};
1949
+ if (!result.retryable) return { status: "failed", error: result.error, ...http };
1950
+ const now = options.now ?? Date.now();
1951
+ if (result.retryAfterSeconds !== void 0 && (options.deferrals ?? 0) < (options.maxDeferrals ?? DEFAULT_MAX_DEFERRALS)) {
1952
+ const seconds2 = Math.max(1, Math.ceil(result.retryAfterSeconds));
1953
+ return { status: "retry", error: result.error, retryAt: new Date(now + seconds2 * 1e3), retryAfterSeconds: seconds2, counted: false, ...http };
1954
+ }
1955
+ const max = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
1956
+ if (attempt >= max) return { status: "failed", error: `${result.error} (gave up after ${attempt} attempts)`.slice(0, 500), ...http };
1957
+ const seconds = Math.max(1, Math.ceil((options.backoff ?? defaultBackoff)(attempt)));
1958
+ return { status: "retry", error: result.error, retryAt: new Date(now + seconds * 1e3), retryAfterSeconds: seconds, counted: true, ...http };
1959
+ }
1960
+ function createDeliveryRunner(options) {
1961
+ const log = (message) => {
1962
+ try {
1963
+ options.log?.(message);
1964
+ } catch {
1965
+ }
1966
+ };
1967
+ const now = () => options.now?.() ?? Date.now();
1968
+ async function send(group, signal) {
1969
+ const first = group[0];
1970
+ const destination = options.destinations[first.kind];
1971
+ if (!destination) {
1972
+ return group.map(() => ({ ok: false, retryable: false, error: `No destination for kind ${first.kind}` }));
1973
+ }
1974
+ const results = group.map((d) => {
1975
+ try {
1976
+ return passesCondition(d.submission, d.definition, d.config) ? null : "filtered";
1977
+ } catch (error) {
1978
+ return deliveryFailure(error);
1979
+ }
1980
+ });
1981
+ const due = group.filter((_, i) => results[i] === null);
1982
+ let state = first.state;
1983
+ const keep = (result) => {
1984
+ if (result.ok && result.state) state = result.state;
1985
+ };
1986
+ const guard = async (call) => {
1987
+ try {
1988
+ return await call();
1989
+ } catch (error) {
1990
+ return deliveryFailure(error);
1991
+ }
1992
+ };
1993
+ const base = { state, signal, destinationId: first.destinationId };
1994
+ let sent;
1995
+ if (due.length > 1 && destination.deliverBatch) {
1996
+ const items = due.map((d) => ({ submission: d.submission, definition: d.definition, deliveryId: d.id, update: d.update }));
1997
+ const result = await guard(
1998
+ () => destination.deliverBatch(items, first.config, { ...base, redelivery: due.some((d) => d.redelivery) })
1999
+ );
2000
+ keep(result);
2001
+ sent = due.map(() => result);
2002
+ } else {
2003
+ sent = [];
2004
+ for (const d of due) {
2005
+ const result = await guard(
2006
+ () => destination.deliver(d.submission, d.definition, d.config, {
2007
+ ...base,
2008
+ state,
2009
+ deliveryId: d.id,
2010
+ redelivery: d.redelivery,
2011
+ update: d.update
2012
+ })
2013
+ );
2014
+ keep(result);
2015
+ sent.push(result);
2016
+ }
2017
+ }
2018
+ if (state !== first.state && state && options.saveState) {
2019
+ try {
2020
+ await options.saveState(first.destinationId, state);
2021
+ } catch {
2022
+ log(`Saving destination ${first.destinationId}'s state failed`);
2023
+ }
2024
+ }
2025
+ let next = 0;
2026
+ return results.map((r) => r ?? sent[next++]);
2027
+ }
2028
+ return {
2029
+ async run(runOptions = {}) {
2030
+ const report = {
2031
+ claimed: 0,
2032
+ sent: 0,
2033
+ filtered: 0,
2034
+ skipped: 0,
2035
+ retrying: 0,
2036
+ deferred: 0,
2037
+ failed: 0,
2038
+ stale: 0,
2039
+ unrecorded: 0
2040
+ };
2041
+ const limit = runOptions.limit ?? options.batchSize ?? DEFAULT_BATCH_SIZE;
2042
+ const claimed = await options.claim({ limit, filter: runOptions.filter });
2043
+ report.claimed = claimed.length;
2044
+ const groups = /* @__PURE__ */ new Map();
2045
+ for (const delivery of claimed) {
2046
+ const list = groups.get(delivery.destinationId) ?? [];
2047
+ list.push(delivery);
2048
+ groups.set(delivery.destinationId, list);
2049
+ }
2050
+ await Promise.all(
2051
+ [...groups.values()].map(async (group) => {
2052
+ const results = await send(group, runOptions.signal);
2053
+ const at = now();
2054
+ await Promise.all(
2055
+ group.map(async (delivery, i) => {
2056
+ const result = results[i];
2057
+ const outcome = result === "filtered" ? { status: "filtered" } : deliveryOutcome(result, delivery.attempt, {
2058
+ now: at,
2059
+ maxAttempts: options.maxAttempts,
2060
+ backoff: options.backoff,
2061
+ deferrals: delivery.deferrals,
2062
+ maxDeferrals: options.maxDeferrals
2063
+ });
2064
+ if (outcome.status === "sent" && result !== "filtered" && result.ok && result.skipped?.includes(delivery.submission.id)) {
2065
+ outcome.alreadyThere = true;
2066
+ }
2067
+ if (outcome.status === "failed" || outcome.status === "retry") {
2068
+ log(`Delivery ${delivery.id} (${delivery.kind}) ${outcome.status === "failed" ? "failed" : "will be retried"}: ${outcome.error}`);
2069
+ } else if (outcome.status === "skipped") {
2070
+ log(`Delivery ${delivery.id} (${delivery.kind}) skipped: ${outcome.reason}`);
2071
+ }
2072
+ let recorded;
2073
+ try {
2074
+ recorded = await options.record(delivery, outcome);
2075
+ } catch {
2076
+ report.unrecorded++;
2077
+ log(`Recording delivery ${delivery.id} failed`);
2078
+ return;
2079
+ }
2080
+ if (recorded === false) report.stale++;
2081
+ else if (outcome.status === "sent") report.sent++;
2082
+ else if (outcome.status === "filtered") report.filtered++;
2083
+ else if (outcome.status === "skipped") report.skipped++;
2084
+ else if (outcome.status === "failed") report.failed++;
2085
+ else if (outcome.counted) report.retrying++;
2086
+ else report.deferred++;
2087
+ })
2088
+ );
2089
+ })
2090
+ );
2091
+ return report;
2092
+ }
2093
+ };
2094
+ }
2095
+
1486
2096
  exports.DEDUPE_WINDOW = DEDUPE_WINDOW;
2097
+ exports.DEFAULT_BATCH_SIZE = DEFAULT_BATCH_SIZE;
2098
+ exports.DEFAULT_MAX_ATTEMPTS = DEFAULT_MAX_ATTEMPTS;
2099
+ exports.DEFAULT_MAX_DEFERRALS = DEFAULT_MAX_DEFERRALS;
1487
2100
  exports.DEFAULT_SHEET_NAME = DEFAULT_SHEET_NAME;
2101
+ exports.DRIVE_FILE_SCOPE = DRIVE_FILE_SCOPE;
1488
2102
  exports.DeliveryError = DeliveryError;
1489
2103
  exports.GOOGLE_TOKEN_URL = GOOGLE_TOKEN_URL;
1490
2104
  exports.HEADER_SCAN_ROWS = HEADER_SCAN_ROWS;
2105
+ exports.HookUrlError = HookUrlError;
2106
+ exports.MAX_CONDITION_DEPTH = MAX_CONDITION_DEPTH;
2107
+ exports.MAX_HOOK_URL_LENGTH = MAX_HOOK_URL_LENGTH;
1491
2108
  exports.MAX_RECIPIENTS = MAX_RECIPIENTS;
1492
2109
  exports.REQUEST_TIMEOUT_MS = REQUEST_TIMEOUT_MS;
1493
2110
  exports.RESEND_API = RESEND_API;
1494
2111
  exports.SENSITIVE_MASK = SENSITIVE_MASK;
1495
2112
  exports.SHEETS_API = SHEETS_API;
1496
2113
  exports.SHEETS_SCOPE = SHEETS_SCOPE;
2114
+ exports.SLACK_HOSTS = SLACK_HOSTS;
2115
+ exports.SLACK_VALUE_LIMIT = SLACK_VALUE_LIMIT;
1497
2116
  exports.answerCell = answerCell;
2117
+ exports.checkHookHost = checkHookHost;
1498
2118
  exports.checkWebhookUrl = checkWebhookUrl;
2119
+ exports.clearGoogleOAuthTokenCache = clearGoogleOAuthTokenCache;
1499
2120
  exports.clearGoogleTokenCache = clearGoogleTokenCache;
1500
2121
  exports.columnLetter = columnLetter;
2122
+ exports.conditionOf = conditionOf;
2123
+ exports.createDeliveryRunner = createDeliveryRunner;
2124
+ exports.defaultBackoff = defaultBackoff;
1501
2125
  exports.deliveryFailure = deliveryFailure;
2126
+ exports.deliveryOutcome = deliveryOutcome;
2127
+ exports.deriveSecretKey = deriveSecretKey;
1502
2128
  exports.emailBody = emailBody;
1503
2129
  exports.emailDestination = emailDestination;
1504
2130
  exports.escapeFormula = escapeFormula;
1505
2131
  exports.escapeHtml = escapeHtml;
2132
+ exports.escapeSlack = escapeSlack;
1506
2133
  exports.googleAccessToken = googleAccessToken;
2134
+ exports.googleOAuthAccessToken = googleOAuthAccessToken;
1507
2135
  exports.headerSafe = headerSafe;
2136
+ exports.hookHost = hookHost;
1508
2137
  exports.httpError = httpError;
1509
2138
  exports.idempotencyKey = idempotencyKey;
2139
+ exports.isCondition = isCondition;
1510
2140
  exports.isPrivateHost = isPrivateHost;
2141
+ exports.isPublicAddress = isPublicAddress;
1511
2142
  exports.isRetryableStatus = isRetryableStatus;
1512
2143
  exports.isSafeAddress = isSafeAddress;
1513
2144
  exports.isSafeFrom = isSafeFrom;
1514
2145
  exports.mergeHeader = mergeHeader;
2146
+ exports.newWebhookSecret = newWebhookSecret;
2147
+ exports.openSecret = openSecret;
2148
+ exports.parseHookUrl = parseHookUrl;
2149
+ exports.passesCondition = passesCondition;
2150
+ exports.refreshGoogleAccessToken = refreshGoogleAccessToken;
1515
2151
  exports.renderEmail = renderEmail;
1516
2152
  exports.resendProvider = resendProvider;
1517
2153
  exports.responseRow = responseRow;
1518
2154
  exports.retryAfterSeconds = retryAfterSeconds;
1519
2155
  exports.rowFor = rowFor;
2156
+ exports.sealSecret = sealSecret;
2157
+ exports.secretKeyBytes = secretKeyBytes;
1520
2158
  exports.sheetsDestination = sheetsDestination;
1521
2159
  exports.signJwt = signJwt;
1522
2160
  exports.signWebhook = signWebhook;
2161
+ exports.slackDestination = slackDestination;
2162
+ exports.slackMessage = slackMessage;
1523
2163
  exports.verifyWebhook = verifyWebhook;
1524
2164
  exports.visibleInputs = visibleInputs;
1525
2165
  exports.webhookDestination = webhookDestination;