@drawbridge/drawbridge-utils 0.0.157 → 0.0.159

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.
@@ -931,7 +931,53 @@ var attentive_default2 = {
931
931
  };
932
932
 
933
933
  // lib/connections/providers/drawbridge.js
934
+ var import_node_crypto3 = require("crypto");
935
+
936
+ // lib/connections/inbound.js
934
937
  var import_node_crypto2 = require("crypto");
938
+ var verifySignature = ({ body, descriptor, headers, secret }) => {
939
+ if (!secret) {
940
+ throw Object.assign(new Error("Missing webhook secret: " + descriptor.signature.secret), { status: 500 });
941
+ }
942
+ const provided = headers[descriptor.headers.signature];
943
+ if (!provided) {
944
+ throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
945
+ }
946
+ const digest = (0, import_node_crypto2.createHmac)(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
947
+ const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
948
+ const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
949
+ if (digestBuffer.length !== providedBuffer.length || !(0, import_node_crypto2.timingSafeEqual)(digestBuffer, providedBuffer)) {
950
+ throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
951
+ }
952
+ return JSON.parse(body.toString());
953
+ };
954
+ var REPLAY_TOLERANCE_SECONDS = 5 * 60;
955
+ var asPem = (key) => [
956
+ "-----BEGIN PUBLIC KEY-----",
957
+ ...key.match(/.{1,64}/g) || [],
958
+ "-----END PUBLIC KEY-----"
959
+ ].join("\n");
960
+ var verifyEcdsa = ({ body, descriptor, headers, secret }) => {
961
+ if (!secret) {
962
+ throw Object.assign(new Error("Missing webhook key: " + descriptor.signature.secret), { status: 500 });
963
+ }
964
+ const provided = headers[descriptor.headers.signature];
965
+ const timestamp = headers[descriptor.headers.timestamp];
966
+ if (!provided || !timestamp) {
967
+ throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
968
+ }
969
+ const age = Math.abs(Math.floor(Date.now() / 1e3) - Number(timestamp));
970
+ if (!(age <= REPLAY_TOLERANCE_SECONDS)) {
971
+ throw Object.assign(new Error("Stale webhook signature"), { status: 401 });
972
+ }
973
+ const payload = Buffer.concat([Buffer.from(String(timestamp), "utf8"), body]);
974
+ const verified = (0, import_node_crypto2.createVerify)("sha256").update(payload).verify(asPem(secret), Buffer.from(provided, "base64"));
975
+ if (!verified) {
976
+ throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
977
+ }
978
+ return JSON.parse(body.toString());
979
+ };
980
+ var readEventHeader = ({ descriptor, headers }) => headers[descriptor.headers.event];
935
981
 
936
982
  // lib/http.js
937
983
  var DEFAULT_TIMEOUT_MS = 15e3;
@@ -1822,6 +1868,144 @@ var MINIMUM_ACTION_CENTS = Math.round(
1822
1868
  // lib/connections/providers/drawbridge.js
1823
1869
  var STOP_KEYWORDS = ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"];
1824
1870
  var START_KEYWORDS = ["START", "UNSTOP", "YES"];
1871
+ var sendgridInbound = {
1872
+ headers: {
1873
+ signature: "x-twilio-email-event-webhook-signature",
1874
+ timestamp: "x-twilio-email-event-webhook-timestamp"
1875
+ },
1876
+ signature: {
1877
+ secret: "SENDGRID_EVENT_WEBHOOK_KEY"
1878
+ }
1879
+ };
1880
+ var REFUSALS = /* @__PURE__ */ new Set(["blocked", "bounce", "deferred", "dropped", "spamreport"]);
1881
+ var DELIVERY = {
1882
+ queued: 10,
1883
+ sent: 20,
1884
+ deferred: 30,
1885
+ blocked: 40,
1886
+ delivered: 50,
1887
+ bounced: 60,
1888
+ complained: 70
1889
+ };
1890
+ var fromSendgrid = (event) => {
1891
+ if ((event == null ? void 0 : event.event) === "bounce") return event.type === "blocked" ? "blocked" : "bounced";
1892
+ return {
1893
+ blocked: "blocked",
1894
+ deferred: "deferred",
1895
+ delivered: "delivered",
1896
+ dropped: "bounced",
1897
+ processed: "queued",
1898
+ spamreport: "complained"
1899
+ }[event == null ? void 0 : event.event] || null;
1900
+ };
1901
+ var fromTwilio = (status) => ({
1902
+ delivered: "delivered",
1903
+ failed: "bounced",
1904
+ queued: "queued",
1905
+ sending: "sent",
1906
+ sent: "sent",
1907
+ undelivered: "bounced"
1908
+ })[String(status || "").toLowerCase()] || null;
1909
+ var deliveryWrite = ({ at, code: code2, notification, permanent, provider, reason, status }) => {
1910
+ const rank = DELIVERY[status];
1911
+ if (!notification || !rank) return null;
1912
+ return {
1913
+ collection: "notification",
1914
+ data: {
1915
+ $set: {
1916
+ "meta.delivery": {
1917
+ at: at || /* @__PURE__ */ new Date(),
1918
+ ...code2 !== void 0 && code2 !== null && { code: String(code2) },
1919
+ ...permanent !== void 0 && { permanent },
1920
+ provider,
1921
+ rank,
1922
+ ...reason && { reason: String(reason) },
1923
+ status
1924
+ }
1925
+ }
1926
+ },
1927
+ operation: "update",
1928
+ query: {
1929
+ id: notification,
1930
+ // NEVER an upsert. A callback naming a notification we do not have is a
1931
+ // vendor talking about someone else's message, not a document to create.
1932
+ $or: [
1933
+ { "meta.delivery.rank": { $exists: false } },
1934
+ { "meta.delivery.rank": { $lt: rank } }
1935
+ ]
1936
+ }
1937
+ };
1938
+ };
1939
+ var summariseEvents = (events) => {
1940
+ const list = Array.isArray(events) ? events : [];
1941
+ if (!list.length) return { message: "SendGrid delivered an empty event batch.", skipped: true };
1942
+ const counts = {};
1943
+ for (const { event } of list) {
1944
+ const name = event || "unknown";
1945
+ counts[name] = (counts[name] || 0) + 1;
1946
+ }
1947
+ const tally = Object.entries(counts).map(([name, count]) => count + " " + name).join(", ");
1948
+ const refused = list.filter(({ event }) => REFUSALS.has(event)).slice(0, 5).map(({ email, reason, response, status }) => email + " \u2014 " + (reason || response || status || "no reason given"));
1949
+ const writes = list.map((event) => {
1950
+ var _a;
1951
+ return deliveryWrite({
1952
+ at: event.timestamp ? new Date(event.timestamp * 1e3) : void 0,
1953
+ code: event.status,
1954
+ // Read BOTH shapes. SendGrid echoes custom_args as siblings of `email`
1955
+ // and `event`, which is why it warns about colliding with reserved
1956
+ // names — but this is the one thing in the design that cannot be proved
1957
+ // until a real event arrives, and reading the nested form too costs a
1958
+ // token and removes the only silent failure left here.
1959
+ notification: event.notification || ((_a = event.custom_args) == null ? void 0 : _a.notification),
1960
+ // Only a bounce carries the distinction, and only a real one is
1961
+ // permanent — `blocked` is this attempt refused, not this address dead.
1962
+ ...event.event === "bounce" && { permanent: event.type !== "blocked" },
1963
+ provider: { slug: "sendgrid", id: event.sg_message_id || null },
1964
+ reason: event.reason || event.response,
1965
+ status: fromSendgrid(event)
1966
+ });
1967
+ }).filter(Boolean);
1968
+ return {
1969
+ message: [list.length + " SendGrid event(s): " + tally, ...refused].join(" | "),
1970
+ ...writes.length ? { writes } : { skipped: true }
1971
+ };
1972
+ };
1973
+ var statusCallback = (data2) => {
1974
+ const status = fromTwilio((data2 == null ? void 0 : data2.MessageStatus) || (data2 == null ? void 0 : data2.SmsStatus));
1975
+ if (!status) return { message: "Twilio sent a status this does not map: " + ((data2 == null ? void 0 : data2.MessageStatus) || "none"), skipped: true };
1976
+ const rank = DELIVERY[status];
1977
+ const write = {
1978
+ collection: "notification",
1979
+ data: {
1980
+ $set: {
1981
+ "meta.delivery": {
1982
+ at: /* @__PURE__ */ new Date(),
1983
+ ...(data2 == null ? void 0 : data2.ErrorCode) && { code: String(data2.ErrorCode) },
1984
+ // `failed` never left Twilio and `undelivered` was refused by the
1985
+ // carrier. Both are dead ends for this handset, so neither is
1986
+ // reported as a temporary condition.
1987
+ ...status === "bounced" && { permanent: true },
1988
+ provider: { slug: "twilio", id: (data2 == null ? void 0 : data2.MessageSid) || null },
1989
+ rank,
1990
+ ...(data2 == null ? void 0 : data2.ErrorMessage) && { reason: String(data2.ErrorMessage) },
1991
+ status
1992
+ }
1993
+ }
1994
+ },
1995
+ operation: "update",
1996
+ query: {
1997
+ "meta.delivery.provider.id": data2 == null ? void 0 : data2.MessageSid,
1998
+ $or: [
1999
+ { "meta.delivery.rank": { $exists: false } },
2000
+ { "meta.delivery.rank": { $lt: rank } }
2001
+ ]
2002
+ }
2003
+ };
2004
+ return {
2005
+ message: "Twilio " + status + " for " + ((data2 == null ? void 0 : data2.MessageSid) || "an unnamed message") + ((data2 == null ? void 0 : data2.ErrorCode) ? " (" + data2.ErrorCode + ")" : ""),
2006
+ ...(data2 == null ? void 0 : data2.MessageSid) ? { writes: [write] } : { skipped: true }
2007
+ };
2008
+ };
1825
2009
  var interpolate = (template, data2) => {
1826
2010
  if (!template) return template;
1827
2011
  return template.replace(/\{\{(\w+)\}\}/g, (_, key) => (data2 == null ? void 0 : data2[key]) != null ? String(data2[key]) : "{{" + key + "}}");
@@ -2065,18 +2249,27 @@ var drawbridge_default2 = {
2065
2249
  // other vendor, and the bespoke webhooks route + the handler that lived in
2066
2250
  // sync's buffer table are gone.
2067
2251
  inbound: {
2068
- // EVERY POST ON THIS CHANNEL IS AN INBOUND MESSAGE. Twilio sends no
2069
- // topic header the registered url IS the topic.
2070
- event: () => "message.inbound",
2252
+ // THE REGISTERED URL IS THE TOPIC. Neither vendor sends a topic header,
2253
+ // so the channel it arrived on names the event — `sms` is one inbound
2254
+ // message, `email` is a batch of SendGrid delivery events.
2255
+ //
2256
+ // Defaulted because the drain calls `event()` with nothing when it only
2257
+ // wants the SMS name.
2258
+ event: ({ channel } = {}) => ({
2259
+ email: "email.events",
2260
+ "sms-status": "sms.status"
2261
+ })[channel] || "message.inbound",
2071
2262
  // STOP AND START, AS DESCRIBED WRITES. A withdrawn number is withdrawn
2072
2263
  // for everyone — organization : null is the platform floor canSend()
2073
2264
  // reads on every send path. $setOnInsert + upsert so webhook replays
2074
2265
  // and repeat STOPs collapse onto the unique { channel, address,
2075
2266
  // organization } index instead of erroring.
2076
2267
  process: ({ context }) => {
2077
- var _a, _b;
2078
- const keyword = String(((_a = context == null ? void 0 : context.data) == null ? void 0 : _a.Body) || "").trim().toUpperCase();
2079
- const address = toE164((_b = context == null ? void 0 : context.data) == null ? void 0 : _b.From);
2268
+ var _a, _b, _c;
2269
+ if ((context == null ? void 0 : context.topic) === "email.events") return summariseEvents((_a = context == null ? void 0 : context.data) == null ? void 0 : _a.events);
2270
+ if ((context == null ? void 0 : context.topic) === "sms.status") return statusCallback(context == null ? void 0 : context.data);
2271
+ const keyword = String(((_b = context == null ? void 0 : context.data) == null ? void 0 : _b.Body) || "").trim().toUpperCase();
2272
+ const address = toE164((_c = context == null ? void 0 : context.data) == null ? void 0 : _c.From);
2080
2273
  if (!address) return { message: "Inbound SMS carried no usable sender number.", skipped: true };
2081
2274
  if (STOP_KEYWORDS.includes(keyword)) {
2082
2275
  return {
@@ -2120,9 +2313,24 @@ var drawbridge_default2 = {
2120
2313
  }
2121
2314
  return { message: "A real reply, not a keyword \u2014 nothing to record.", skipped: true };
2122
2315
  },
2123
- receive: ({ payload }) => ({
2124
- data: payload,
2125
- provider: { id: (payload == null ? void 0 : payload.MessageSid) || null }
2316
+ // A SendGrid batch carries many sg_message_ids and no single identity, so
2317
+ // it names no provider id. That means no dedupe key for a redelivery —
2318
+ // acceptable because SendGrid only retries a non-2xx, and the cost of a
2319
+ // duplicate here is a second buffer row rather than a repeated side
2320
+ // effect. Revisit if this ever writes suppressions.
2321
+ //
2322
+ // THE BATCH IS WRAPPED, and it has to be. SendGrid posts a JSON ARRAY
2323
+ // where every other vendor posts an object, and drawbridge-api's
2324
+ // schema/buffer.js declares `data : { bsonType : 'object' }` — which in
2325
+ // JSON Schema does NOT match an array. Writing the array raw fails
2326
+ // validation, answers 500, and puts SendGrid into a retry loop for a
2327
+ // webhook we would never accept.
2328
+ //
2329
+ // Wrapped here rather than widening the schema: `data` stays one shape
2330
+ // for every consumer, and the vendor that is odd carries the oddity.
2331
+ receive: ({ channel, payload }) => ({
2332
+ data: channel === "email" ? { events: payload } : payload,
2333
+ provider: { id: channel === "email" ? null : (payload == null ? void 0 : payload.MessageSid) || null }
2126
2334
  }),
2127
2335
  // TWILIO'S SCHEME, from docs.twilio.com/usage/security: take the full
2128
2336
  // registered url, sort the POST parameters alphabetically (Unix-style,
@@ -2131,13 +2339,14 @@ var drawbridge_default2 = {
2131
2339
  // X-Twilio-Signature. It signs the URL rather than the raw body, which
2132
2340
  // is exactly why the shared body-HMAC verifier cannot cover it and this
2133
2341
  // hook exists.
2134
- verify: ({ body, headers, secret, url }) => {
2342
+ verify: ({ body, channel, headers, secret, url }) => {
2343
+ if (channel === "email") return verifyEcdsa({ body, descriptor: sendgridInbound, headers, secret });
2135
2344
  if (!secret) throw Object.assign(new Error("Missing webhook secret: TWILIO_AUTH_TOKEN"), { status: 500 });
2136
2345
  const params = new URLSearchParams(String(body || ""));
2137
2346
  const signed = url + [...params.keys()].sort().map((key) => key + params.get(key)).join("");
2138
- const expected = (0, import_node_crypto2.createHmac)("sha1", secret).update(signed).digest("base64");
2347
+ const expected = (0, import_node_crypto3.createHmac)("sha1", secret).update(signed).digest("base64");
2139
2348
  const provided = String((headers == null ? void 0 : headers["x-twilio-signature"]) || "");
2140
- const matches = expected.length === provided.length && (0, import_node_crypto2.timingSafeEqual)(Buffer.from(expected), Buffer.from(provided));
2349
+ const matches = expected.length === provided.length && (0, import_node_crypto3.timingSafeEqual)(Buffer.from(expected), Buffer.from(provided));
2141
2350
  if (!matches) throw Object.assign(new Error("Invalid Twilio signature"), { status: 401 });
2142
2351
  return Object.fromEntries(params);
2143
2352
  }
@@ -2321,15 +2530,30 @@ var drawbridge_default2 = {
2321
2530
  },
2322
2531
  icon: drawbridge_default,
2323
2532
  // WHERE TWILIO PUTS THINGS on an inbound request, and WHICH credential
2324
- // verifies it. `secret` names the drawbridge provider's smsToken the route
2325
- // resolves the name to the stored value and hands it to verify.
2533
+ // verifies each channel. The route resolves the NAME to the stored value and
2534
+ // hands it to verify; this manifest never reads a store.
2535
+ //
2536
+ // `signature.channels` because this is the one manifest that receives from two
2537
+ // different vendors — Twilio on `sms`, SendGrid on `email` — which need
2538
+ // different credentials and share no scheme. A manifest whose channels all
2539
+ // verify the same way keeps declaring `signature` flat, and the route falls
2540
+ // back to it; Shopify's two channels do exactly that.
2326
2541
  inbound: {
2327
2542
  headers: {
2328
2543
  id: "i-twilio-idempotency-token",
2329
2544
  signature: "x-twilio-signature"
2330
2545
  },
2331
2546
  signature: {
2332
- secret: "TWILIO_AUTH_TOKEN"
2547
+ channels: {
2548
+ email: sendgridInbound.signature,
2549
+ sms: { secret: "TWILIO_AUTH_TOKEN" },
2550
+ // Outbound delivery receipts, which Twilio signs exactly as it signs
2551
+ // an inbound message — same scheme, same credential, different url.
2552
+ // Separate channel because the payload is a MessageStatus rather than
2553
+ // a message, and conflating them would put a STOP keyword check on a
2554
+ // delivery receipt.
2555
+ "sms-status": { secret: "TWILIO_AUTH_TOKEN" }
2556
+ }
2333
2557
  }
2334
2558
  },
2335
2559
  // PRIVATE: never in the catalog, always available to the builder.
@@ -2361,6 +2585,13 @@ var drawbridge_default2 = {
2361
2585
  // Optional: SENDGRID_SEND_FROM_ADDRESS is not boot-required in sync
2362
2586
  // either. Unset, it degrades to the account sender rather than
2363
2587
  // refusing to start.
2588
+ // NOT redacted, because it is a PUBLIC key. Marking it secret would
2589
+ // be theatre, and it would stop an operator reading back the value
2590
+ // they need to compare against the SendGrid console.
2591
+ //
2592
+ // Optional: absent, the event webhook answers 500 on verify rather
2593
+ // than accepting unverified deliveries, and nothing else notices.
2594
+ { 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." },
2364
2595
  { 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." }
2365
2596
  ],
2366
2597
  icon: sendgrid_default,
@@ -3107,7 +3338,7 @@ var klaviyo_default2 = {
3107
3338
  };
3108
3339
 
3109
3340
  // lib/connections/providers/mailchimp.js
3110
- var import_node_crypto3 = require("crypto");
3341
+ var import_node_crypto4 = require("crypto");
3111
3342
 
3112
3343
  // lib/connections/icons/mailchimp.js
3113
3344
  var mailchimp_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -3139,7 +3370,7 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
3139
3370
  }
3140
3371
  return response.status === 204 ? null : response.json();
3141
3372
  };
3142
- var subscriberHash = (email) => (0, import_node_crypto3.createHash)("md5").update(String(email).trim().toLowerCase()).digest("hex");
3373
+ var subscriberHash = (email) => (0, import_node_crypto4.createHash)("md5").update(String(email).trim().toLowerCase()).digest("hex");
3143
3374
  var mailchimp_default2 = {
3144
3375
  // OAUTH 2, authorization code. Every url below is quoted from
3145
3376
  // mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ rather
@@ -3475,26 +3706,6 @@ var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
3475
3706
  <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"/>
3476
3707
  </svg>`;
3477
3708
 
3478
- // lib/connections/inbound.js
3479
- var import_node_crypto4 = require("crypto");
3480
- var verifySignature = ({ body, descriptor, headers, secret }) => {
3481
- if (!secret) {
3482
- throw Object.assign(new Error("Missing webhook secret: " + descriptor.signature.secret), { status: 500 });
3483
- }
3484
- const provided = headers[descriptor.headers.signature];
3485
- if (!provided) {
3486
- throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
3487
- }
3488
- const digest = (0, import_node_crypto4.createHmac)(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
3489
- const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
3490
- const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
3491
- if (digestBuffer.length !== providedBuffer.length || !(0, import_node_crypto4.timingSafeEqual)(digestBuffer, providedBuffer)) {
3492
- throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
3493
- }
3494
- return JSON.parse(body.toString());
3495
- };
3496
- var readEventHeader = ({ descriptor, headers }) => headers[descriptor.headers.event];
3497
-
3498
3709
  // lib/email.js
3499
3710
  var GMAIL_DOMAINS = /* @__PURE__ */ new Set(["gmail.com", "googlemail.com"]);
3500
3711
  var toCanonicalEmail = (value) => {