@drawbridge/drawbridge-utils 0.0.157 → 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.
@@ -855,7 +855,53 @@ var attentive_default2 = {
855
855
  };
856
856
 
857
857
  // lib/connections/providers/drawbridge.js
858
- import { createHmac, timingSafeEqual } from "crypto";
858
+ import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
859
+
860
+ // lib/connections/inbound.js
861
+ import { createHmac, createVerify, timingSafeEqual } from "crypto";
862
+ var verifySignature = ({ body, descriptor, headers, secret }) => {
863
+ if (!secret) {
864
+ throw Object.assign(new Error("Missing webhook secret: " + descriptor.signature.secret), { status: 500 });
865
+ }
866
+ const provided = headers[descriptor.headers.signature];
867
+ if (!provided) {
868
+ throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
869
+ }
870
+ const digest = createHmac(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
871
+ const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
872
+ const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
873
+ if (digestBuffer.length !== providedBuffer.length || !timingSafeEqual(digestBuffer, providedBuffer)) {
874
+ throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
875
+ }
876
+ return JSON.parse(body.toString());
877
+ };
878
+ var REPLAY_TOLERANCE_SECONDS = 5 * 60;
879
+ var asPem = (key) => [
880
+ "-----BEGIN PUBLIC KEY-----",
881
+ ...key.match(/.{1,64}/g) || [],
882
+ "-----END PUBLIC KEY-----"
883
+ ].join("\n");
884
+ var verifyEcdsa = ({ body, descriptor, headers, secret }) => {
885
+ if (!secret) {
886
+ throw Object.assign(new Error("Missing webhook key: " + descriptor.signature.secret), { status: 500 });
887
+ }
888
+ const provided = headers[descriptor.headers.signature];
889
+ const timestamp = headers[descriptor.headers.timestamp];
890
+ if (!provided || !timestamp) {
891
+ throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
892
+ }
893
+ const age = Math.abs(Math.floor(Date.now() / 1e3) - Number(timestamp));
894
+ if (!(age <= REPLAY_TOLERANCE_SECONDS)) {
895
+ throw Object.assign(new Error("Stale webhook signature"), { status: 401 });
896
+ }
897
+ const payload = Buffer.concat([Buffer.from(String(timestamp), "utf8"), body]);
898
+ const verified = createVerify("sha256").update(payload).verify(asPem(secret), Buffer.from(provided, "base64"));
899
+ if (!verified) {
900
+ throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
901
+ }
902
+ return JSON.parse(body.toString());
903
+ };
904
+ var readEventHeader = ({ descriptor, headers }) => headers[descriptor.headers.event];
859
905
 
860
906
  // lib/http.js
861
907
  var DEFAULT_TIMEOUT_MS = 15e3;
@@ -1746,6 +1792,144 @@ var MINIMUM_ACTION_CENTS = Math.round(
1746
1792
  // lib/connections/providers/drawbridge.js
1747
1793
  var STOP_KEYWORDS = ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"];
1748
1794
  var START_KEYWORDS = ["START", "UNSTOP", "YES"];
1795
+ var sendgridInbound = {
1796
+ headers: {
1797
+ signature: "x-twilio-email-event-webhook-signature",
1798
+ timestamp: "x-twilio-email-event-webhook-timestamp"
1799
+ },
1800
+ signature: {
1801
+ secret: "SENDGRID_EVENT_WEBHOOK_KEY"
1802
+ }
1803
+ };
1804
+ var REFUSALS = /* @__PURE__ */ new Set(["blocked", "bounce", "deferred", "dropped", "spamreport"]);
1805
+ var DELIVERY = {
1806
+ queued: 10,
1807
+ sent: 20,
1808
+ deferred: 30,
1809
+ blocked: 40,
1810
+ delivered: 50,
1811
+ bounced: 60,
1812
+ complained: 70
1813
+ };
1814
+ var fromSendgrid = (event) => {
1815
+ if ((event == null ? void 0 : event.event) === "bounce") return event.type === "blocked" ? "blocked" : "bounced";
1816
+ return {
1817
+ blocked: "blocked",
1818
+ deferred: "deferred",
1819
+ delivered: "delivered",
1820
+ dropped: "bounced",
1821
+ processed: "queued",
1822
+ spamreport: "complained"
1823
+ }[event == null ? void 0 : event.event] || null;
1824
+ };
1825
+ var fromTwilio = (status) => ({
1826
+ delivered: "delivered",
1827
+ failed: "bounced",
1828
+ queued: "queued",
1829
+ sending: "sent",
1830
+ sent: "sent",
1831
+ undelivered: "bounced"
1832
+ })[String(status || "").toLowerCase()] || null;
1833
+ var deliveryWrite = ({ at, code: code2, notification, permanent, provider, reason, status }) => {
1834
+ const rank = DELIVERY[status];
1835
+ if (!notification || !rank) return null;
1836
+ return {
1837
+ collection: "notification",
1838
+ data: {
1839
+ $set: {
1840
+ "meta.delivery": {
1841
+ at: at || /* @__PURE__ */ new Date(),
1842
+ ...code2 !== void 0 && code2 !== null && { code: String(code2) },
1843
+ ...permanent !== void 0 && { permanent },
1844
+ provider,
1845
+ rank,
1846
+ ...reason && { reason: String(reason) },
1847
+ status
1848
+ }
1849
+ }
1850
+ },
1851
+ operation: "update",
1852
+ query: {
1853
+ id: notification,
1854
+ // NEVER an upsert. A callback naming a notification we do not have is a
1855
+ // vendor talking about someone else's message, not a document to create.
1856
+ $or: [
1857
+ { "meta.delivery.rank": { $exists: false } },
1858
+ { "meta.delivery.rank": { $lt: rank } }
1859
+ ]
1860
+ }
1861
+ };
1862
+ };
1863
+ var summariseEvents = (events) => {
1864
+ const list = Array.isArray(events) ? events : [];
1865
+ if (!list.length) return { message: "SendGrid delivered an empty event batch.", skipped: true };
1866
+ const counts = {};
1867
+ for (const { event } of list) {
1868
+ const name = event || "unknown";
1869
+ counts[name] = (counts[name] || 0) + 1;
1870
+ }
1871
+ const tally = Object.entries(counts).map(([name, count]) => count + " " + name).join(", ");
1872
+ const refused = list.filter(({ event }) => REFUSALS.has(event)).slice(0, 5).map(({ email, reason, response, status }) => email + " \u2014 " + (reason || response || status || "no reason given"));
1873
+ const writes = list.map((event) => {
1874
+ var _a;
1875
+ return deliveryWrite({
1876
+ at: event.timestamp ? new Date(event.timestamp * 1e3) : void 0,
1877
+ code: event.status,
1878
+ // Read BOTH shapes. SendGrid echoes custom_args as siblings of `email`
1879
+ // and `event`, which is why it warns about colliding with reserved
1880
+ // names — but this is the one thing in the design that cannot be proved
1881
+ // until a real event arrives, and reading the nested form too costs a
1882
+ // token and removes the only silent failure left here.
1883
+ notification: event.notification || ((_a = event.custom_args) == null ? void 0 : _a.notification),
1884
+ // Only a bounce carries the distinction, and only a real one is
1885
+ // permanent — `blocked` is this attempt refused, not this address dead.
1886
+ ...event.event === "bounce" && { permanent: event.type !== "blocked" },
1887
+ provider: { slug: "sendgrid", id: event.sg_message_id || null },
1888
+ reason: event.reason || event.response,
1889
+ status: fromSendgrid(event)
1890
+ });
1891
+ }).filter(Boolean);
1892
+ return {
1893
+ message: [list.length + " SendGrid event(s): " + tally, ...refused].join(" | "),
1894
+ ...writes.length ? { writes } : { skipped: true }
1895
+ };
1896
+ };
1897
+ var statusCallback = (data2) => {
1898
+ const status = fromTwilio((data2 == null ? void 0 : data2.MessageStatus) || (data2 == null ? void 0 : data2.SmsStatus));
1899
+ if (!status) return { message: "Twilio sent a status this does not map: " + ((data2 == null ? void 0 : data2.MessageStatus) || "none"), skipped: true };
1900
+ const rank = DELIVERY[status];
1901
+ const write = {
1902
+ collection: "notification",
1903
+ data: {
1904
+ $set: {
1905
+ "meta.delivery": {
1906
+ at: /* @__PURE__ */ new Date(),
1907
+ ...(data2 == null ? void 0 : data2.ErrorCode) && { code: String(data2.ErrorCode) },
1908
+ // `failed` never left Twilio and `undelivered` was refused by the
1909
+ // carrier. Both are dead ends for this handset, so neither is
1910
+ // reported as a temporary condition.
1911
+ ...status === "bounced" && { permanent: true },
1912
+ provider: { slug: "twilio", id: (data2 == null ? void 0 : data2.MessageSid) || null },
1913
+ rank,
1914
+ ...(data2 == null ? void 0 : data2.ErrorMessage) && { reason: String(data2.ErrorMessage) },
1915
+ status
1916
+ }
1917
+ }
1918
+ },
1919
+ operation: "update",
1920
+ query: {
1921
+ "meta.delivery.provider.id": data2 == null ? void 0 : data2.MessageSid,
1922
+ $or: [
1923
+ { "meta.delivery.rank": { $exists: false } },
1924
+ { "meta.delivery.rank": { $lt: rank } }
1925
+ ]
1926
+ }
1927
+ };
1928
+ return {
1929
+ message: "Twilio " + status + " for " + ((data2 == null ? void 0 : data2.MessageSid) || "an unnamed message") + ((data2 == null ? void 0 : data2.ErrorCode) ? " (" + data2.ErrorCode + ")" : ""),
1930
+ ...(data2 == null ? void 0 : data2.MessageSid) ? { writes: [write] } : { skipped: true }
1931
+ };
1932
+ };
1749
1933
  var interpolate = (template, data2) => {
1750
1934
  if (!template) return template;
1751
1935
  return template.replace(/\{\{(\w+)\}\}/g, (_, key) => (data2 == null ? void 0 : data2[key]) != null ? String(data2[key]) : "{{" + key + "}}");
@@ -1989,9 +2173,16 @@ var drawbridge_default2 = {
1989
2173
  // other vendor, and the bespoke webhooks route + the handler that lived in
1990
2174
  // sync's buffer table are gone.
1991
2175
  inbound: {
1992
- // EVERY POST ON THIS CHANNEL IS AN INBOUND MESSAGE. Twilio sends no
1993
- // topic header the registered url IS the topic.
1994
- event: () => "message.inbound",
2176
+ // THE REGISTERED URL IS THE TOPIC. Neither vendor sends a topic header,
2177
+ // so the channel it arrived on names the event — `sms` is one inbound
2178
+ // message, `email` is a batch of SendGrid delivery events.
2179
+ //
2180
+ // Defaulted because the drain calls `event()` with nothing when it only
2181
+ // wants the SMS name.
2182
+ event: ({ channel } = {}) => ({
2183
+ email: "email.events",
2184
+ "sms-status": "sms.status"
2185
+ })[channel] || "message.inbound",
1995
2186
  // STOP AND START, AS DESCRIBED WRITES. A withdrawn number is withdrawn
1996
2187
  // for everyone — organization : null is the platform floor canSend()
1997
2188
  // reads on every send path. $setOnInsert + upsert so webhook replays
@@ -1999,6 +2190,8 @@ var drawbridge_default2 = {
1999
2190
  // organization } index instead of erroring.
2000
2191
  process: ({ context }) => {
2001
2192
  var _a, _b;
2193
+ if ((context == null ? void 0 : context.topic) === "email.events") return summariseEvents(context == null ? void 0 : context.data);
2194
+ if ((context == null ? void 0 : context.topic) === "sms.status") return statusCallback(context == null ? void 0 : context.data);
2002
2195
  const keyword = String(((_a = context == null ? void 0 : context.data) == null ? void 0 : _a.Body) || "").trim().toUpperCase();
2003
2196
  const address = toE164((_b = context == null ? void 0 : context.data) == null ? void 0 : _b.From);
2004
2197
  if (!address) return { message: "Inbound SMS carried no usable sender number.", skipped: true };
@@ -2044,9 +2237,14 @@ var drawbridge_default2 = {
2044
2237
  }
2045
2238
  return { message: "A real reply, not a keyword \u2014 nothing to record.", skipped: true };
2046
2239
  },
2047
- receive: ({ payload }) => ({
2240
+ // A SendGrid batch carries many sg_message_ids and no single identity, so
2241
+ // it names no provider id. That means no dedupe key for a redelivery —
2242
+ // acceptable because SendGrid only retries a non-2xx, and the cost of a
2243
+ // duplicate here is a second buffer row rather than a repeated side
2244
+ // effect. Revisit if this ever writes suppressions.
2245
+ receive: ({ channel, payload }) => ({
2048
2246
  data: payload,
2049
- provider: { id: (payload == null ? void 0 : payload.MessageSid) || null }
2247
+ provider: { id: channel === "email" ? null : (payload == null ? void 0 : payload.MessageSid) || null }
2050
2248
  }),
2051
2249
  // TWILIO'S SCHEME, from docs.twilio.com/usage/security: take the full
2052
2250
  // registered url, sort the POST parameters alphabetically (Unix-style,
@@ -2055,13 +2253,14 @@ var drawbridge_default2 = {
2055
2253
  // X-Twilio-Signature. It signs the URL rather than the raw body, which
2056
2254
  // is exactly why the shared body-HMAC verifier cannot cover it and this
2057
2255
  // hook exists.
2058
- verify: ({ body, headers, secret, url }) => {
2256
+ verify: ({ body, channel, headers, secret, url }) => {
2257
+ if (channel === "email") return verifyEcdsa({ body, descriptor: sendgridInbound, headers, secret });
2059
2258
  if (!secret) throw Object.assign(new Error("Missing webhook secret: TWILIO_AUTH_TOKEN"), { status: 500 });
2060
2259
  const params = new URLSearchParams(String(body || ""));
2061
2260
  const signed = url + [...params.keys()].sort().map((key) => key + params.get(key)).join("");
2062
- const expected = createHmac("sha1", secret).update(signed).digest("base64");
2261
+ const expected = createHmac2("sha1", secret).update(signed).digest("base64");
2063
2262
  const provided = String((headers == null ? void 0 : headers["x-twilio-signature"]) || "");
2064
- const matches = expected.length === provided.length && timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
2263
+ const matches = expected.length === provided.length && timingSafeEqual2(Buffer.from(expected), Buffer.from(provided));
2065
2264
  if (!matches) throw Object.assign(new Error("Invalid Twilio signature"), { status: 401 });
2066
2265
  return Object.fromEntries(params);
2067
2266
  }
@@ -2245,15 +2444,30 @@ var drawbridge_default2 = {
2245
2444
  },
2246
2445
  icon: drawbridge_default,
2247
2446
  // WHERE TWILIO PUTS THINGS on an inbound request, and WHICH credential
2248
- // verifies it. `secret` names the drawbridge provider's smsToken the route
2249
- // resolves the name to the stored value and hands it to verify.
2447
+ // verifies each channel. The route resolves the NAME to the stored value and
2448
+ // hands it to verify; this manifest never reads a store.
2449
+ //
2450
+ // `signature.channels` because this is the one manifest that receives from two
2451
+ // different vendors — Twilio on `sms`, SendGrid on `email` — which need
2452
+ // different credentials and share no scheme. A manifest whose channels all
2453
+ // verify the same way keeps declaring `signature` flat, and the route falls
2454
+ // back to it; Shopify's two channels do exactly that.
2250
2455
  inbound: {
2251
2456
  headers: {
2252
2457
  id: "i-twilio-idempotency-token",
2253
2458
  signature: "x-twilio-signature"
2254
2459
  },
2255
2460
  signature: {
2256
- secret: "TWILIO_AUTH_TOKEN"
2461
+ channels: {
2462
+ email: sendgridInbound.signature,
2463
+ sms: { secret: "TWILIO_AUTH_TOKEN" },
2464
+ // Outbound delivery receipts, which Twilio signs exactly as it signs
2465
+ // an inbound message — same scheme, same credential, different url.
2466
+ // Separate channel because the payload is a MessageStatus rather than
2467
+ // a message, and conflating them would put a STOP keyword check on a
2468
+ // delivery receipt.
2469
+ "sms-status": { secret: "TWILIO_AUTH_TOKEN" }
2470
+ }
2257
2471
  }
2258
2472
  },
2259
2473
  // PRIVATE: never in the catalog, always available to the builder.
@@ -2285,6 +2499,13 @@ var drawbridge_default2 = {
2285
2499
  // Optional: SENDGRID_SEND_FROM_ADDRESS is not boot-required in sync
2286
2500
  // either. Unset, it degrades to the account sender rather than
2287
2501
  // refusing to start.
2502
+ // NOT redacted, because it is a PUBLIC key. Marking it secret would
2503
+ // be theatre, and it would stop an operator reading back the value
2504
+ // they need to compare against the SendGrid console.
2505
+ //
2506
+ // Optional: absent, the event webhook answers 500 on verify rather
2507
+ // than accepting unverified deliveries, and nothing else notices.
2508
+ { 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." },
2288
2509
  { 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." }
2289
2510
  ],
2290
2511
  icon: sendgrid_default,
@@ -3399,26 +3620,6 @@ var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
3399
3620
  <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"/>
3400
3621
  </svg>`;
3401
3622
 
3402
- // lib/connections/inbound.js
3403
- import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
3404
- var verifySignature = ({ body, descriptor, headers, secret }) => {
3405
- if (!secret) {
3406
- throw Object.assign(new Error("Missing webhook secret: " + descriptor.signature.secret), { status: 500 });
3407
- }
3408
- const provided = headers[descriptor.headers.signature];
3409
- if (!provided) {
3410
- throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
3411
- }
3412
- const digest = createHmac2(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
3413
- const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
3414
- const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
3415
- if (digestBuffer.length !== providedBuffer.length || !timingSafeEqual2(digestBuffer, providedBuffer)) {
3416
- throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
3417
- }
3418
- return JSON.parse(body.toString());
3419
- };
3420
- var readEventHeader = ({ descriptor, headers }) => headers[descriptor.headers.event];
3421
-
3422
3623
  // lib/email.js
3423
3624
  var GMAIL_DOMAINS = /* @__PURE__ */ new Set(["gmail.com", "googlemail.com"]);
3424
3625
  var toCanonicalEmail = (value) => {