@drawbridge/drawbridge-utils 0.0.156 → 0.0.158

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.
@@ -837,7 +837,53 @@ var attentive_default2 = {
837
837
  };
838
838
 
839
839
  // lib/connections/providers/drawbridge.js
840
+ var import_node_crypto3 = require("crypto");
841
+
842
+ // lib/connections/inbound.js
840
843
  var import_node_crypto2 = require("crypto");
844
+ var verifySignature = ({ body, descriptor, headers, secret }) => {
845
+ if (!secret) {
846
+ throw Object.assign(new Error("Missing webhook secret: " + descriptor.signature.secret), { status: 500 });
847
+ }
848
+ const provided = headers[descriptor.headers.signature];
849
+ if (!provided) {
850
+ throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
851
+ }
852
+ const digest = (0, import_node_crypto2.createHmac)(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
853
+ const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
854
+ const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
855
+ if (digestBuffer.length !== providedBuffer.length || !(0, import_node_crypto2.timingSafeEqual)(digestBuffer, providedBuffer)) {
856
+ throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
857
+ }
858
+ return JSON.parse(body.toString());
859
+ };
860
+ var REPLAY_TOLERANCE_SECONDS = 5 * 60;
861
+ var asPem = (key) => [
862
+ "-----BEGIN PUBLIC KEY-----",
863
+ ...key.match(/.{1,64}/g) || [],
864
+ "-----END PUBLIC KEY-----"
865
+ ].join("\n");
866
+ var verifyEcdsa = ({ body, descriptor, headers, secret }) => {
867
+ if (!secret) {
868
+ throw Object.assign(new Error("Missing webhook key: " + descriptor.signature.secret), { status: 500 });
869
+ }
870
+ const provided = headers[descriptor.headers.signature];
871
+ const timestamp = headers[descriptor.headers.timestamp];
872
+ if (!provided || !timestamp) {
873
+ throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
874
+ }
875
+ const age = Math.abs(Math.floor(Date.now() / 1e3) - Number(timestamp));
876
+ if (!(age <= REPLAY_TOLERANCE_SECONDS)) {
877
+ throw Object.assign(new Error("Stale webhook signature"), { status: 401 });
878
+ }
879
+ const payload = Buffer.concat([Buffer.from(String(timestamp), "utf8"), body]);
880
+ const verified = (0, import_node_crypto2.createVerify)("sha256").update(payload).verify(asPem(secret), Buffer.from(provided, "base64"));
881
+ if (!verified) {
882
+ throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
883
+ }
884
+ return JSON.parse(body.toString());
885
+ };
886
+ var readEventHeader = ({ descriptor, headers }) => headers[descriptor.headers.event];
841
887
 
842
888
  // lib/http.js
843
889
  var DEFAULT_TIMEOUT_MS = 15e3;
@@ -1728,6 +1774,144 @@ var MINIMUM_ACTION_CENTS = Math.round(
1728
1774
  // lib/connections/providers/drawbridge.js
1729
1775
  var STOP_KEYWORDS = ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"];
1730
1776
  var START_KEYWORDS = ["START", "UNSTOP", "YES"];
1777
+ var sendgridInbound = {
1778
+ headers: {
1779
+ signature: "x-twilio-email-event-webhook-signature",
1780
+ timestamp: "x-twilio-email-event-webhook-timestamp"
1781
+ },
1782
+ signature: {
1783
+ secret: "SENDGRID_EVENT_WEBHOOK_KEY"
1784
+ }
1785
+ };
1786
+ var REFUSALS = /* @__PURE__ */ new Set(["blocked", "bounce", "deferred", "dropped", "spamreport"]);
1787
+ var DELIVERY = {
1788
+ queued: 10,
1789
+ sent: 20,
1790
+ deferred: 30,
1791
+ blocked: 40,
1792
+ delivered: 50,
1793
+ bounced: 60,
1794
+ complained: 70
1795
+ };
1796
+ var fromSendgrid = (event) => {
1797
+ if ((event == null ? void 0 : event.event) === "bounce") return event.type === "blocked" ? "blocked" : "bounced";
1798
+ return {
1799
+ blocked: "blocked",
1800
+ deferred: "deferred",
1801
+ delivered: "delivered",
1802
+ dropped: "bounced",
1803
+ processed: "queued",
1804
+ spamreport: "complained"
1805
+ }[event == null ? void 0 : event.event] || null;
1806
+ };
1807
+ var fromTwilio = (status) => ({
1808
+ delivered: "delivered",
1809
+ failed: "bounced",
1810
+ queued: "queued",
1811
+ sending: "sent",
1812
+ sent: "sent",
1813
+ undelivered: "bounced"
1814
+ })[String(status || "").toLowerCase()] || null;
1815
+ var deliveryWrite = ({ at, code: code2, notification, permanent, provider, reason, status }) => {
1816
+ const rank = DELIVERY[status];
1817
+ if (!notification || !rank) return null;
1818
+ return {
1819
+ collection: "notification",
1820
+ data: {
1821
+ $set: {
1822
+ "meta.delivery": {
1823
+ at: at || /* @__PURE__ */ new Date(),
1824
+ ...code2 !== void 0 && code2 !== null && { code: String(code2) },
1825
+ ...permanent !== void 0 && { permanent },
1826
+ provider,
1827
+ rank,
1828
+ ...reason && { reason: String(reason) },
1829
+ status
1830
+ }
1831
+ }
1832
+ },
1833
+ operation: "update",
1834
+ query: {
1835
+ id: notification,
1836
+ // NEVER an upsert. A callback naming a notification we do not have is a
1837
+ // vendor talking about someone else's message, not a document to create.
1838
+ $or: [
1839
+ { "meta.delivery.rank": { $exists: false } },
1840
+ { "meta.delivery.rank": { $lt: rank } }
1841
+ ]
1842
+ }
1843
+ };
1844
+ };
1845
+ var summariseEvents = (events) => {
1846
+ const list = Array.isArray(events) ? events : [];
1847
+ if (!list.length) return { message: "SendGrid delivered an empty event batch.", skipped: true };
1848
+ const counts = {};
1849
+ for (const { event } of list) {
1850
+ const name = event || "unknown";
1851
+ counts[name] = (counts[name] || 0) + 1;
1852
+ }
1853
+ const tally = Object.entries(counts).map(([name, count]) => count + " " + name).join(", ");
1854
+ const refused = list.filter(({ event }) => REFUSALS.has(event)).slice(0, 5).map(({ email, reason, response, status }) => email + " \u2014 " + (reason || response || status || "no reason given"));
1855
+ const writes = list.map((event) => {
1856
+ var _a;
1857
+ return deliveryWrite({
1858
+ at: event.timestamp ? new Date(event.timestamp * 1e3) : void 0,
1859
+ code: event.status,
1860
+ // Read BOTH shapes. SendGrid echoes custom_args as siblings of `email`
1861
+ // and `event`, which is why it warns about colliding with reserved
1862
+ // names — but this is the one thing in the design that cannot be proved
1863
+ // until a real event arrives, and reading the nested form too costs a
1864
+ // token and removes the only silent failure left here.
1865
+ notification: event.notification || ((_a = event.custom_args) == null ? void 0 : _a.notification),
1866
+ // Only a bounce carries the distinction, and only a real one is
1867
+ // permanent — `blocked` is this attempt refused, not this address dead.
1868
+ ...event.event === "bounce" && { permanent: event.type !== "blocked" },
1869
+ provider: { slug: "sendgrid", id: event.sg_message_id || null },
1870
+ reason: event.reason || event.response,
1871
+ status: fromSendgrid(event)
1872
+ });
1873
+ }).filter(Boolean);
1874
+ return {
1875
+ message: [list.length + " SendGrid event(s): " + tally, ...refused].join(" | "),
1876
+ ...writes.length ? { writes } : { skipped: true }
1877
+ };
1878
+ };
1879
+ var statusCallback = (data2) => {
1880
+ const status = fromTwilio((data2 == null ? void 0 : data2.MessageStatus) || (data2 == null ? void 0 : data2.SmsStatus));
1881
+ if (!status) return { message: "Twilio sent a status this does not map: " + ((data2 == null ? void 0 : data2.MessageStatus) || "none"), skipped: true };
1882
+ const rank = DELIVERY[status];
1883
+ const write = {
1884
+ collection: "notification",
1885
+ data: {
1886
+ $set: {
1887
+ "meta.delivery": {
1888
+ at: /* @__PURE__ */ new Date(),
1889
+ ...(data2 == null ? void 0 : data2.ErrorCode) && { code: String(data2.ErrorCode) },
1890
+ // `failed` never left Twilio and `undelivered` was refused by the
1891
+ // carrier. Both are dead ends for this handset, so neither is
1892
+ // reported as a temporary condition.
1893
+ ...status === "bounced" && { permanent: true },
1894
+ provider: { slug: "twilio", id: (data2 == null ? void 0 : data2.MessageSid) || null },
1895
+ rank,
1896
+ ...(data2 == null ? void 0 : data2.ErrorMessage) && { reason: String(data2.ErrorMessage) },
1897
+ status
1898
+ }
1899
+ }
1900
+ },
1901
+ operation: "update",
1902
+ query: {
1903
+ "meta.delivery.provider.id": data2 == null ? void 0 : data2.MessageSid,
1904
+ $or: [
1905
+ { "meta.delivery.rank": { $exists: false } },
1906
+ { "meta.delivery.rank": { $lt: rank } }
1907
+ ]
1908
+ }
1909
+ };
1910
+ return {
1911
+ message: "Twilio " + status + " for " + ((data2 == null ? void 0 : data2.MessageSid) || "an unnamed message") + ((data2 == null ? void 0 : data2.ErrorCode) ? " (" + data2.ErrorCode + ")" : ""),
1912
+ ...(data2 == null ? void 0 : data2.MessageSid) ? { writes: [write] } : { skipped: true }
1913
+ };
1914
+ };
1731
1915
  var interpolate = (template, data2) => {
1732
1916
  if (!template) return template;
1733
1917
  return template.replace(/\{\{(\w+)\}\}/g, (_, key) => (data2 == null ? void 0 : data2[key]) != null ? String(data2[key]) : "{{" + key + "}}");
@@ -1971,9 +2155,16 @@ var drawbridge_default2 = {
1971
2155
  // other vendor, and the bespoke webhooks route + the handler that lived in
1972
2156
  // sync's buffer table are gone.
1973
2157
  inbound: {
1974
- // EVERY POST ON THIS CHANNEL IS AN INBOUND MESSAGE. Twilio sends no
1975
- // topic header the registered url IS the topic.
1976
- event: () => "message.inbound",
2158
+ // THE REGISTERED URL IS THE TOPIC. Neither vendor sends a topic header,
2159
+ // so the channel it arrived on names the event — `sms` is one inbound
2160
+ // message, `email` is a batch of SendGrid delivery events.
2161
+ //
2162
+ // Defaulted because the drain calls `event()` with nothing when it only
2163
+ // wants the SMS name.
2164
+ event: ({ channel } = {}) => ({
2165
+ email: "email.events",
2166
+ "sms-status": "sms.status"
2167
+ })[channel] || "message.inbound",
1977
2168
  // STOP AND START, AS DESCRIBED WRITES. A withdrawn number is withdrawn
1978
2169
  // for everyone — organization : null is the platform floor canSend()
1979
2170
  // reads on every send path. $setOnInsert + upsert so webhook replays
@@ -1981,6 +2172,8 @@ var drawbridge_default2 = {
1981
2172
  // organization } index instead of erroring.
1982
2173
  process: ({ context }) => {
1983
2174
  var _a, _b;
2175
+ if ((context == null ? void 0 : context.topic) === "email.events") return summariseEvents(context == null ? void 0 : context.data);
2176
+ if ((context == null ? void 0 : context.topic) === "sms.status") return statusCallback(context == null ? void 0 : context.data);
1984
2177
  const keyword = String(((_a = context == null ? void 0 : context.data) == null ? void 0 : _a.Body) || "").trim().toUpperCase();
1985
2178
  const address = toE164((_b = context == null ? void 0 : context.data) == null ? void 0 : _b.From);
1986
2179
  if (!address) return { message: "Inbound SMS carried no usable sender number.", skipped: true };
@@ -2026,9 +2219,14 @@ var drawbridge_default2 = {
2026
2219
  }
2027
2220
  return { message: "A real reply, not a keyword \u2014 nothing to record.", skipped: true };
2028
2221
  },
2029
- receive: ({ payload }) => ({
2222
+ // A SendGrid batch carries many sg_message_ids and no single identity, so
2223
+ // it names no provider id. That means no dedupe key for a redelivery —
2224
+ // acceptable because SendGrid only retries a non-2xx, and the cost of a
2225
+ // duplicate here is a second buffer row rather than a repeated side
2226
+ // effect. Revisit if this ever writes suppressions.
2227
+ receive: ({ channel, payload }) => ({
2030
2228
  data: payload,
2031
- provider: { id: (payload == null ? void 0 : payload.MessageSid) || null }
2229
+ provider: { id: channel === "email" ? null : (payload == null ? void 0 : payload.MessageSid) || null }
2032
2230
  }),
2033
2231
  // TWILIO'S SCHEME, from docs.twilio.com/usage/security: take the full
2034
2232
  // registered url, sort the POST parameters alphabetically (Unix-style,
@@ -2037,13 +2235,14 @@ var drawbridge_default2 = {
2037
2235
  // X-Twilio-Signature. It signs the URL rather than the raw body, which
2038
2236
  // is exactly why the shared body-HMAC verifier cannot cover it and this
2039
2237
  // hook exists.
2040
- verify: ({ body, headers, secret, url }) => {
2238
+ verify: ({ body, channel, headers, secret, url }) => {
2239
+ if (channel === "email") return verifyEcdsa({ body, descriptor: sendgridInbound, headers, secret });
2041
2240
  if (!secret) throw Object.assign(new Error("Missing webhook secret: TWILIO_AUTH_TOKEN"), { status: 500 });
2042
2241
  const params = new URLSearchParams(String(body || ""));
2043
2242
  const signed = url + [...params.keys()].sort().map((key) => key + params.get(key)).join("");
2044
- const expected = (0, import_node_crypto2.createHmac)("sha1", secret).update(signed).digest("base64");
2243
+ const expected = (0, import_node_crypto3.createHmac)("sha1", secret).update(signed).digest("base64");
2045
2244
  const provided = String((headers == null ? void 0 : headers["x-twilio-signature"]) || "");
2046
- const matches = expected.length === provided.length && (0, import_node_crypto2.timingSafeEqual)(Buffer.from(expected), Buffer.from(provided));
2245
+ const matches = expected.length === provided.length && (0, import_node_crypto3.timingSafeEqual)(Buffer.from(expected), Buffer.from(provided));
2047
2246
  if (!matches) throw Object.assign(new Error("Invalid Twilio signature"), { status: 401 });
2048
2247
  return Object.fromEntries(params);
2049
2248
  }
@@ -2227,15 +2426,30 @@ var drawbridge_default2 = {
2227
2426
  },
2228
2427
  icon: drawbridge_default,
2229
2428
  // WHERE TWILIO PUTS THINGS on an inbound request, and WHICH credential
2230
- // verifies it. `secret` names the drawbridge provider's smsToken the route
2231
- // resolves the name to the stored value and hands it to verify.
2429
+ // verifies each channel. The route resolves the NAME to the stored value and
2430
+ // hands it to verify; this manifest never reads a store.
2431
+ //
2432
+ // `signature.channels` because this is the one manifest that receives from two
2433
+ // different vendors — Twilio on `sms`, SendGrid on `email` — which need
2434
+ // different credentials and share no scheme. A manifest whose channels all
2435
+ // verify the same way keeps declaring `signature` flat, and the route falls
2436
+ // back to it; Shopify's two channels do exactly that.
2232
2437
  inbound: {
2233
2438
  headers: {
2234
2439
  id: "i-twilio-idempotency-token",
2235
2440
  signature: "x-twilio-signature"
2236
2441
  },
2237
2442
  signature: {
2238
- secret: "TWILIO_AUTH_TOKEN"
2443
+ channels: {
2444
+ email: sendgridInbound.signature,
2445
+ sms: { secret: "TWILIO_AUTH_TOKEN" },
2446
+ // Outbound delivery receipts, which Twilio signs exactly as it signs
2447
+ // an inbound message — same scheme, same credential, different url.
2448
+ // Separate channel because the payload is a MessageStatus rather than
2449
+ // a message, and conflating them would put a STOP keyword check on a
2450
+ // delivery receipt.
2451
+ "sms-status": { secret: "TWILIO_AUTH_TOKEN" }
2452
+ }
2239
2453
  }
2240
2454
  },
2241
2455
  // PRIVATE: never in the catalog, always available to the builder.
@@ -2267,6 +2481,13 @@ var drawbridge_default2 = {
2267
2481
  // Optional: SENDGRID_SEND_FROM_ADDRESS is not boot-required in sync
2268
2482
  // either. Unset, it degrades to the account sender rather than
2269
2483
  // refusing to start.
2484
+ // NOT redacted, because it is a PUBLIC key. Marking it secret would
2485
+ // be theatre, and it would stop an operator reading back the value
2486
+ // they need to compare against the SendGrid console.
2487
+ //
2488
+ // Optional: absent, the event webhook answers 500 on verify rather
2489
+ // than accepting unverified deliveries, and nothing else notices.
2490
+ { input: "text", key: "eventKey", credential: "SENDGRID_EVENT_WEBHOOK_KEY", label: "Event webhook key", message: "SendGrid, Settings, Mail Settings, Event Webhook \u2014 the verification key shown once signature verification is enabled. Public, not a secret." },
2270
2491
  { input: "email", key: "leadSender", credential: "SENDGRID_SEND_FROM_ADDRESS", label: "Lead sender", message: "The default for lead-facing mail when a merchant has not verified their own domain." }
2271
2492
  ],
2272
2493
  icon: sendgrid_default,
@@ -3013,7 +3234,7 @@ var klaviyo_default2 = {
3013
3234
  };
3014
3235
 
3015
3236
  // lib/connections/providers/mailchimp.js
3016
- var import_node_crypto3 = require("crypto");
3237
+ var import_node_crypto4 = require("crypto");
3017
3238
 
3018
3239
  // lib/connections/icons/mailchimp.js
3019
3240
  var mailchimp_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -3045,7 +3266,7 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
3045
3266
  }
3046
3267
  return response.status === 204 ? null : response.json();
3047
3268
  };
3048
- var subscriberHash = (email) => (0, import_node_crypto3.createHash)("md5").update(String(email).trim().toLowerCase()).digest("hex");
3269
+ var subscriberHash = (email) => (0, import_node_crypto4.createHash)("md5").update(String(email).trim().toLowerCase()).digest("hex");
3049
3270
  var mailchimp_default2 = {
3050
3271
  // OAUTH 2, authorization code. Every url below is quoted from
3051
3272
  // mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ rather
@@ -3381,26 +3602,6 @@ var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
3381
3602
  <path d="M300.325 382.771L368 365.96C368 365.96 338.904 169.185 338.689 167.892C338.473 166.599 337.396 165.737 336.318 165.737C335.24 165.737 316.274 165.306 316.274 165.306C316.274 165.306 304.636 154.099 300.325 149.788V382.771Z" fill="white"/>
3382
3603
  </svg>`;
3383
3604
 
3384
- // lib/connections/inbound.js
3385
- var import_node_crypto4 = require("crypto");
3386
- var verifySignature = ({ body, descriptor, headers, secret }) => {
3387
- if (!secret) {
3388
- throw Object.assign(new Error("Missing webhook secret: " + descriptor.signature.secret), { status: 500 });
3389
- }
3390
- const provided = headers[descriptor.headers.signature];
3391
- if (!provided) {
3392
- throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
3393
- }
3394
- const digest = (0, import_node_crypto4.createHmac)(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
3395
- const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
3396
- const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
3397
- if (digestBuffer.length !== providedBuffer.length || !(0, import_node_crypto4.timingSafeEqual)(digestBuffer, providedBuffer)) {
3398
- throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
3399
- }
3400
- return JSON.parse(body.toString());
3401
- };
3402
- var readEventHeader = ({ descriptor, headers }) => headers[descriptor.headers.event];
3403
-
3404
3605
  // lib/email.js
3405
3606
  var GMAIL_DOMAINS = /* @__PURE__ */ new Set(["gmail.com", "googlemail.com"]);
3406
3607
  var toCanonicalEmail = (value) => {
@@ -4792,9 +4993,14 @@ var isBlockedIPv4 = (ip) => {
4792
4993
  if (a >= 224) return true;
4793
4994
  return false;
4794
4995
  };
4996
+ var transition = new import_net.default.BlockList();
4997
+ transition.addSubnet("64:ff9b::", 96, "ipv6");
4998
+ transition.addSubnet("2002::", 16, "ipv6");
4999
+ transition.addSubnet("2001::", 32, "ipv6");
4795
5000
  var isBlockedIPv6 = (ip) => {
4796
5001
  const lower = ip.toLowerCase();
4797
5002
  if (lower === "::1" || lower === "::") return true;
5003
+ if (transition.check(ip, "ipv6")) return true;
4798
5004
  if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
4799
5005
  if (/^fe[89ab]/.test(lower)) return true;
4800
5006
  if (lower.startsWith("ff")) return true;
@@ -4853,12 +5059,13 @@ var pinnedAgent = async (url) => {
4853
5059
  var safeRequest = async ({
4854
5060
  body,
4855
5061
  headers = {},
5062
+ maxContentLength,
4856
5063
  method = "GET",
4857
5064
  query,
4858
5065
  timeout = DEFAULT_TIMEOUT_MS2,
4859
5066
  type = "json",
4860
5067
  url
4861
- }) => {
5068
+ }, { transport = axios } = {}) => {
4862
5069
  const full = new URL(url);
4863
5070
  if (query) {
4864
5071
  Object.entries(query).forEach(([key, value]) => full.searchParams.set(key, value));
@@ -4866,7 +5073,7 @@ var safeRequest = async ({
4866
5073
  const { protocol, agent } = await pinnedAgent(full.toString());
4867
5074
  const isForm = type === "form";
4868
5075
  try {
4869
- const response = await axios({
5076
+ const response = await transport({
4870
5077
  method,
4871
5078
  url: full.toString(),
4872
5079
  headers: {
@@ -4876,6 +5083,7 @@ var safeRequest = async ({
4876
5083
  ...body !== void 0 && {
4877
5084
  data: isForm ? new URLSearchParams(body).toString() : body
4878
5085
  },
5086
+ ...maxContentLength !== void 0 && { maxContentLength },
4879
5087
  timeout,
4880
5088
  maxRedirects: 0,
4881
5089
  httpAgent: protocol === "http:" ? agent : void 0,
@@ -4898,6 +5106,19 @@ var safeRequest = async ({
4898
5106
  };
4899
5107
 
4900
5108
  // lib/connections/providers/webhook.js
5109
+ var RESPONSE_LIMIT = 64 * 1024;
5110
+ var signature = ({ body, settings }) => {
5111
+ var _a;
5112
+ const timestamp = Math.floor(Date.now() / 1e3);
5113
+ const payload = timestamp + "." + JSON.stringify(body);
5114
+ const previous = ((_a = settings.previous) == null ? void 0 : _a.secret) && new Date(settings.previous.until) > /* @__PURE__ */ new Date() ? [settings.previous.secret] : [];
5115
+ return [
5116
+ "t=" + timestamp,
5117
+ ...[settings.secret, ...previous].map(
5118
+ (secret) => "v1=" + import_node_crypto6.default.createHmac("sha256", secret).update(payload).digest("hex")
5119
+ )
5120
+ ].join(",");
5121
+ };
4901
5122
  var webhook_default = {
4902
5123
  // Connecting GENERATES the secret rather than storing one the merchant typed,
4903
5124
  // so the buttons say what actually happens.
@@ -4928,7 +5149,9 @@ var webhook_default = {
4928
5149
  "Press Connect. Drawbridge generates a signing secret and shows it here.",
4929
5150
  "Copy the secret into your own endpoint.",
4930
5151
  "Add a Send webhook step to a workflow and put your endpoint URL on it. The URL belongs to the step rather than the connection, so one connection can serve several endpoints.",
4931
- "On each request, compute HMAC-SHA256 of the raw body using the secret and compare it against the X-Drawbridge-Signature header before acting on the payload."
5152
+ "On each request, read the X-Drawbridge-Signature header \u2014 `t=<unix seconds>,v1=<hex>` \u2014 and compute HMAC-SHA256 of `<t>.<raw request body>` with the secret. Use the raw bytes, not a re-serialised parse.",
5153
+ "Compare your result against each v1 value with a constant-time comparison, and reject any request whose timestamp is more than five minutes old.",
5154
+ "After you regenerate the secret, the previous one keeps signing for 24 hours (as a second v1), so update your endpoint within that window."
4932
5155
  ]
4933
5156
  },
4934
5157
  // Nothing to be exclusive with — there is no second webhook vendor, and a
@@ -4996,13 +5219,14 @@ var webhook_default = {
4996
5219
  const { headers = {}, method = "POST", url } = step.settings || {};
4997
5220
  const request2 = { method, url: url || null };
4998
5221
  if (!url) return { message: "Outgoing webhook URL is not configured for this step.", request: request2, response: { skipped: true }, skipped: true };
5222
+ if (!/^https:\/\//i.test(url)) return { message: "Outgoing webhook URL must use https.", request: request2, response: { skipped: true }, skipped: true };
4999
5223
  const body = lead || context;
5000
5224
  request2.body = body;
5001
5225
  const outgoing = { ...headers };
5002
5226
  if (settings == null ? void 0 : settings.secret) {
5003
- outgoing["X-Drawbridge-Signature"] = "sha256=" + import_node_crypto6.default.createHmac("sha256", settings.secret).update(JSON.stringify(body)).digest("hex");
5227
+ outgoing["X-Drawbridge-Signature"] = signature({ body, settings });
5004
5228
  }
5005
- const response = await send2({ body, headers: outgoing, method, url });
5229
+ const response = await send2({ body, headers: outgoing, maxContentLength: RESPONSE_LIMIT, method, url });
5006
5230
  return { message: "Webhook POSTed to " + url + ".", request: request2, response: response || { delivered: true } };
5007
5231
  }
5008
5232
  }
@@ -5051,7 +5275,7 @@ var webhook_default = {
5051
5275
  // and a task repeating it talks over the button.
5052
5276
  tasks: ({ settings, status } = {}) => ["disconnected", "error"].includes(status) ? [] : (settings == null ? void 0 : settings.secret) ? [
5053
5277
  {
5054
- message: "Compute HMAC-SHA256( secret, body ) and compare against the X-Drawbridge-Signature header to confirm each payload.",
5278
+ message: 'Compute HMAC-SHA256( secret, t + "." + raw body ) and constant-time compare it against a v1 in the X-Drawbridge-Signature header; reject timestamps older than five minutes.',
5055
5279
  title: "Requests must be verified",
5056
5280
  type: "warning"
5057
5281
  }