@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.
- package/dist/axios.cjs +5 -0
- package/dist/axios.d.cts +14 -0
- package/dist/axios.d.ts +14 -0
- package/dist/axios.js +5 -0
- package/dist/connections/index.cjs +263 -39
- package/dist/connections/index.d.cts +466 -82
- package/dist/connections/index.d.ts +466 -82
- package/dist/connections/index.js +262 -38
- package/dist/providers.cjs +263 -39
- package/dist/providers.js +262 -38
- package/dist/safe-http.cjs +9 -2
- package/dist/safe-http.d.cts +9 -2
- package/dist/safe-http.d.ts +9 -2
- package/dist/safe-http.js +9 -2
- package/dist/sendgrid.cjs +18 -1
- package/dist/sendgrid.d.cts +24 -1
- package/dist/sendgrid.d.ts +24 -1
- package/dist/sendgrid.js +18 -1
- package/package.json +1 -1
package/dist/providers.js
CHANGED
|
@@ -791,7 +791,53 @@ var attentive_default2 = {
|
|
|
791
791
|
};
|
|
792
792
|
|
|
793
793
|
// lib/connections/providers/drawbridge.js
|
|
794
|
-
import { createHmac, timingSafeEqual } from "crypto";
|
|
794
|
+
import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
795
|
+
|
|
796
|
+
// lib/connections/inbound.js
|
|
797
|
+
import { createHmac, createVerify, timingSafeEqual } from "crypto";
|
|
798
|
+
var verifySignature = ({ body, descriptor, headers, secret }) => {
|
|
799
|
+
if (!secret) {
|
|
800
|
+
throw Object.assign(new Error("Missing webhook secret: " + descriptor.signature.secret), { status: 500 });
|
|
801
|
+
}
|
|
802
|
+
const provided = headers[descriptor.headers.signature];
|
|
803
|
+
if (!provided) {
|
|
804
|
+
throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
|
|
805
|
+
}
|
|
806
|
+
const digest = createHmac(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
|
|
807
|
+
const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
|
|
808
|
+
const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
|
|
809
|
+
if (digestBuffer.length !== providedBuffer.length || !timingSafeEqual(digestBuffer, providedBuffer)) {
|
|
810
|
+
throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
|
|
811
|
+
}
|
|
812
|
+
return JSON.parse(body.toString());
|
|
813
|
+
};
|
|
814
|
+
var REPLAY_TOLERANCE_SECONDS = 5 * 60;
|
|
815
|
+
var asPem = (key) => [
|
|
816
|
+
"-----BEGIN PUBLIC KEY-----",
|
|
817
|
+
...key.match(/.{1,64}/g) || [],
|
|
818
|
+
"-----END PUBLIC KEY-----"
|
|
819
|
+
].join("\n");
|
|
820
|
+
var verifyEcdsa = ({ body, descriptor, headers, secret }) => {
|
|
821
|
+
if (!secret) {
|
|
822
|
+
throw Object.assign(new Error("Missing webhook key: " + descriptor.signature.secret), { status: 500 });
|
|
823
|
+
}
|
|
824
|
+
const provided = headers[descriptor.headers.signature];
|
|
825
|
+
const timestamp = headers[descriptor.headers.timestamp];
|
|
826
|
+
if (!provided || !timestamp) {
|
|
827
|
+
throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
|
|
828
|
+
}
|
|
829
|
+
const age = Math.abs(Math.floor(Date.now() / 1e3) - Number(timestamp));
|
|
830
|
+
if (!(age <= REPLAY_TOLERANCE_SECONDS)) {
|
|
831
|
+
throw Object.assign(new Error("Stale webhook signature"), { status: 401 });
|
|
832
|
+
}
|
|
833
|
+
const payload = Buffer.concat([Buffer.from(String(timestamp), "utf8"), body]);
|
|
834
|
+
const verified = createVerify("sha256").update(payload).verify(asPem(secret), Buffer.from(provided, "base64"));
|
|
835
|
+
if (!verified) {
|
|
836
|
+
throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
|
|
837
|
+
}
|
|
838
|
+
return JSON.parse(body.toString());
|
|
839
|
+
};
|
|
840
|
+
var readEventHeader = ({ descriptor, headers }) => headers[descriptor.headers.event];
|
|
795
841
|
|
|
796
842
|
// lib/http.js
|
|
797
843
|
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
@@ -1682,6 +1728,144 @@ var MINIMUM_ACTION_CENTS = Math.round(
|
|
|
1682
1728
|
// lib/connections/providers/drawbridge.js
|
|
1683
1729
|
var STOP_KEYWORDS = ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"];
|
|
1684
1730
|
var START_KEYWORDS = ["START", "UNSTOP", "YES"];
|
|
1731
|
+
var sendgridInbound = {
|
|
1732
|
+
headers: {
|
|
1733
|
+
signature: "x-twilio-email-event-webhook-signature",
|
|
1734
|
+
timestamp: "x-twilio-email-event-webhook-timestamp"
|
|
1735
|
+
},
|
|
1736
|
+
signature: {
|
|
1737
|
+
secret: "SENDGRID_EVENT_WEBHOOK_KEY"
|
|
1738
|
+
}
|
|
1739
|
+
};
|
|
1740
|
+
var REFUSALS = /* @__PURE__ */ new Set(["blocked", "bounce", "deferred", "dropped", "spamreport"]);
|
|
1741
|
+
var DELIVERY = {
|
|
1742
|
+
queued: 10,
|
|
1743
|
+
sent: 20,
|
|
1744
|
+
deferred: 30,
|
|
1745
|
+
blocked: 40,
|
|
1746
|
+
delivered: 50,
|
|
1747
|
+
bounced: 60,
|
|
1748
|
+
complained: 70
|
|
1749
|
+
};
|
|
1750
|
+
var fromSendgrid = (event) => {
|
|
1751
|
+
if ((event == null ? void 0 : event.event) === "bounce") return event.type === "blocked" ? "blocked" : "bounced";
|
|
1752
|
+
return {
|
|
1753
|
+
blocked: "blocked",
|
|
1754
|
+
deferred: "deferred",
|
|
1755
|
+
delivered: "delivered",
|
|
1756
|
+
dropped: "bounced",
|
|
1757
|
+
processed: "queued",
|
|
1758
|
+
spamreport: "complained"
|
|
1759
|
+
}[event == null ? void 0 : event.event] || null;
|
|
1760
|
+
};
|
|
1761
|
+
var fromTwilio = (status) => ({
|
|
1762
|
+
delivered: "delivered",
|
|
1763
|
+
failed: "bounced",
|
|
1764
|
+
queued: "queued",
|
|
1765
|
+
sending: "sent",
|
|
1766
|
+
sent: "sent",
|
|
1767
|
+
undelivered: "bounced"
|
|
1768
|
+
})[String(status || "").toLowerCase()] || null;
|
|
1769
|
+
var deliveryWrite = ({ at, code: code2, notification, permanent, provider, reason, status }) => {
|
|
1770
|
+
const rank = DELIVERY[status];
|
|
1771
|
+
if (!notification || !rank) return null;
|
|
1772
|
+
return {
|
|
1773
|
+
collection: "notification",
|
|
1774
|
+
data: {
|
|
1775
|
+
$set: {
|
|
1776
|
+
"meta.delivery": {
|
|
1777
|
+
at: at || /* @__PURE__ */ new Date(),
|
|
1778
|
+
...code2 !== void 0 && code2 !== null && { code: String(code2) },
|
|
1779
|
+
...permanent !== void 0 && { permanent },
|
|
1780
|
+
provider,
|
|
1781
|
+
rank,
|
|
1782
|
+
...reason && { reason: String(reason) },
|
|
1783
|
+
status
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
},
|
|
1787
|
+
operation: "update",
|
|
1788
|
+
query: {
|
|
1789
|
+
id: notification,
|
|
1790
|
+
// NEVER an upsert. A callback naming a notification we do not have is a
|
|
1791
|
+
// vendor talking about someone else's message, not a document to create.
|
|
1792
|
+
$or: [
|
|
1793
|
+
{ "meta.delivery.rank": { $exists: false } },
|
|
1794
|
+
{ "meta.delivery.rank": { $lt: rank } }
|
|
1795
|
+
]
|
|
1796
|
+
}
|
|
1797
|
+
};
|
|
1798
|
+
};
|
|
1799
|
+
var summariseEvents = (events) => {
|
|
1800
|
+
const list = Array.isArray(events) ? events : [];
|
|
1801
|
+
if (!list.length) return { message: "SendGrid delivered an empty event batch.", skipped: true };
|
|
1802
|
+
const counts = {};
|
|
1803
|
+
for (const { event } of list) {
|
|
1804
|
+
const name = event || "unknown";
|
|
1805
|
+
counts[name] = (counts[name] || 0) + 1;
|
|
1806
|
+
}
|
|
1807
|
+
const tally = Object.entries(counts).map(([name, count]) => count + " " + name).join(", ");
|
|
1808
|
+
const refused = list.filter(({ event }) => REFUSALS.has(event)).slice(0, 5).map(({ email, reason, response, status }) => email + " \u2014 " + (reason || response || status || "no reason given"));
|
|
1809
|
+
const writes = list.map((event) => {
|
|
1810
|
+
var _a;
|
|
1811
|
+
return deliveryWrite({
|
|
1812
|
+
at: event.timestamp ? new Date(event.timestamp * 1e3) : void 0,
|
|
1813
|
+
code: event.status,
|
|
1814
|
+
// Read BOTH shapes. SendGrid echoes custom_args as siblings of `email`
|
|
1815
|
+
// and `event`, which is why it warns about colliding with reserved
|
|
1816
|
+
// names — but this is the one thing in the design that cannot be proved
|
|
1817
|
+
// until a real event arrives, and reading the nested form too costs a
|
|
1818
|
+
// token and removes the only silent failure left here.
|
|
1819
|
+
notification: event.notification || ((_a = event.custom_args) == null ? void 0 : _a.notification),
|
|
1820
|
+
// Only a bounce carries the distinction, and only a real one is
|
|
1821
|
+
// permanent — `blocked` is this attempt refused, not this address dead.
|
|
1822
|
+
...event.event === "bounce" && { permanent: event.type !== "blocked" },
|
|
1823
|
+
provider: { slug: "sendgrid", id: event.sg_message_id || null },
|
|
1824
|
+
reason: event.reason || event.response,
|
|
1825
|
+
status: fromSendgrid(event)
|
|
1826
|
+
});
|
|
1827
|
+
}).filter(Boolean);
|
|
1828
|
+
return {
|
|
1829
|
+
message: [list.length + " SendGrid event(s): " + tally, ...refused].join(" | "),
|
|
1830
|
+
...writes.length ? { writes } : { skipped: true }
|
|
1831
|
+
};
|
|
1832
|
+
};
|
|
1833
|
+
var statusCallback = (data2) => {
|
|
1834
|
+
const status = fromTwilio((data2 == null ? void 0 : data2.MessageStatus) || (data2 == null ? void 0 : data2.SmsStatus));
|
|
1835
|
+
if (!status) return { message: "Twilio sent a status this does not map: " + ((data2 == null ? void 0 : data2.MessageStatus) || "none"), skipped: true };
|
|
1836
|
+
const rank = DELIVERY[status];
|
|
1837
|
+
const write = {
|
|
1838
|
+
collection: "notification",
|
|
1839
|
+
data: {
|
|
1840
|
+
$set: {
|
|
1841
|
+
"meta.delivery": {
|
|
1842
|
+
at: /* @__PURE__ */ new Date(),
|
|
1843
|
+
...(data2 == null ? void 0 : data2.ErrorCode) && { code: String(data2.ErrorCode) },
|
|
1844
|
+
// `failed` never left Twilio and `undelivered` was refused by the
|
|
1845
|
+
// carrier. Both are dead ends for this handset, so neither is
|
|
1846
|
+
// reported as a temporary condition.
|
|
1847
|
+
...status === "bounced" && { permanent: true },
|
|
1848
|
+
provider: { slug: "twilio", id: (data2 == null ? void 0 : data2.MessageSid) || null },
|
|
1849
|
+
rank,
|
|
1850
|
+
...(data2 == null ? void 0 : data2.ErrorMessage) && { reason: String(data2.ErrorMessage) },
|
|
1851
|
+
status
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
},
|
|
1855
|
+
operation: "update",
|
|
1856
|
+
query: {
|
|
1857
|
+
"meta.delivery.provider.id": data2 == null ? void 0 : data2.MessageSid,
|
|
1858
|
+
$or: [
|
|
1859
|
+
{ "meta.delivery.rank": { $exists: false } },
|
|
1860
|
+
{ "meta.delivery.rank": { $lt: rank } }
|
|
1861
|
+
]
|
|
1862
|
+
}
|
|
1863
|
+
};
|
|
1864
|
+
return {
|
|
1865
|
+
message: "Twilio " + status + " for " + ((data2 == null ? void 0 : data2.MessageSid) || "an unnamed message") + ((data2 == null ? void 0 : data2.ErrorCode) ? " (" + data2.ErrorCode + ")" : ""),
|
|
1866
|
+
...(data2 == null ? void 0 : data2.MessageSid) ? { writes: [write] } : { skipped: true }
|
|
1867
|
+
};
|
|
1868
|
+
};
|
|
1685
1869
|
var interpolate = (template, data2) => {
|
|
1686
1870
|
if (!template) return template;
|
|
1687
1871
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => (data2 == null ? void 0 : data2[key]) != null ? String(data2[key]) : "{{" + key + "}}");
|
|
@@ -1925,9 +2109,16 @@ var drawbridge_default2 = {
|
|
|
1925
2109
|
// other vendor, and the bespoke webhooks route + the handler that lived in
|
|
1926
2110
|
// sync's buffer table are gone.
|
|
1927
2111
|
inbound: {
|
|
1928
|
-
//
|
|
1929
|
-
//
|
|
1930
|
-
|
|
2112
|
+
// THE REGISTERED URL IS THE TOPIC. Neither vendor sends a topic header,
|
|
2113
|
+
// so the channel it arrived on names the event — `sms` is one inbound
|
|
2114
|
+
// message, `email` is a batch of SendGrid delivery events.
|
|
2115
|
+
//
|
|
2116
|
+
// Defaulted because the drain calls `event()` with nothing when it only
|
|
2117
|
+
// wants the SMS name.
|
|
2118
|
+
event: ({ channel } = {}) => ({
|
|
2119
|
+
email: "email.events",
|
|
2120
|
+
"sms-status": "sms.status"
|
|
2121
|
+
})[channel] || "message.inbound",
|
|
1931
2122
|
// STOP AND START, AS DESCRIBED WRITES. A withdrawn number is withdrawn
|
|
1932
2123
|
// for everyone — organization : null is the platform floor canSend()
|
|
1933
2124
|
// reads on every send path. $setOnInsert + upsert so webhook replays
|
|
@@ -1935,6 +2126,8 @@ var drawbridge_default2 = {
|
|
|
1935
2126
|
// organization } index instead of erroring.
|
|
1936
2127
|
process: ({ context }) => {
|
|
1937
2128
|
var _a, _b;
|
|
2129
|
+
if ((context == null ? void 0 : context.topic) === "email.events") return summariseEvents(context == null ? void 0 : context.data);
|
|
2130
|
+
if ((context == null ? void 0 : context.topic) === "sms.status") return statusCallback(context == null ? void 0 : context.data);
|
|
1938
2131
|
const keyword = String(((_a = context == null ? void 0 : context.data) == null ? void 0 : _a.Body) || "").trim().toUpperCase();
|
|
1939
2132
|
const address = toE164((_b = context == null ? void 0 : context.data) == null ? void 0 : _b.From);
|
|
1940
2133
|
if (!address) return { message: "Inbound SMS carried no usable sender number.", skipped: true };
|
|
@@ -1980,9 +2173,14 @@ var drawbridge_default2 = {
|
|
|
1980
2173
|
}
|
|
1981
2174
|
return { message: "A real reply, not a keyword \u2014 nothing to record.", skipped: true };
|
|
1982
2175
|
},
|
|
1983
|
-
|
|
2176
|
+
// A SendGrid batch carries many sg_message_ids and no single identity, so
|
|
2177
|
+
// it names no provider id. That means no dedupe key for a redelivery —
|
|
2178
|
+
// acceptable because SendGrid only retries a non-2xx, and the cost of a
|
|
2179
|
+
// duplicate here is a second buffer row rather than a repeated side
|
|
2180
|
+
// effect. Revisit if this ever writes suppressions.
|
|
2181
|
+
receive: ({ channel, payload }) => ({
|
|
1984
2182
|
data: payload,
|
|
1985
|
-
provider: { id: (payload == null ? void 0 : payload.MessageSid) || null }
|
|
2183
|
+
provider: { id: channel === "email" ? null : (payload == null ? void 0 : payload.MessageSid) || null }
|
|
1986
2184
|
}),
|
|
1987
2185
|
// TWILIO'S SCHEME, from docs.twilio.com/usage/security: take the full
|
|
1988
2186
|
// registered url, sort the POST parameters alphabetically (Unix-style,
|
|
@@ -1991,13 +2189,14 @@ var drawbridge_default2 = {
|
|
|
1991
2189
|
// X-Twilio-Signature. It signs the URL rather than the raw body, which
|
|
1992
2190
|
// is exactly why the shared body-HMAC verifier cannot cover it and this
|
|
1993
2191
|
// hook exists.
|
|
1994
|
-
verify: ({ body, headers, secret, url }) => {
|
|
2192
|
+
verify: ({ body, channel, headers, secret, url }) => {
|
|
2193
|
+
if (channel === "email") return verifyEcdsa({ body, descriptor: sendgridInbound, headers, secret });
|
|
1995
2194
|
if (!secret) throw Object.assign(new Error("Missing webhook secret: TWILIO_AUTH_TOKEN"), { status: 500 });
|
|
1996
2195
|
const params = new URLSearchParams(String(body || ""));
|
|
1997
2196
|
const signed = url + [...params.keys()].sort().map((key) => key + params.get(key)).join("");
|
|
1998
|
-
const expected =
|
|
2197
|
+
const expected = createHmac2("sha1", secret).update(signed).digest("base64");
|
|
1999
2198
|
const provided = String((headers == null ? void 0 : headers["x-twilio-signature"]) || "");
|
|
2000
|
-
const matches = expected.length === provided.length &&
|
|
2199
|
+
const matches = expected.length === provided.length && timingSafeEqual2(Buffer.from(expected), Buffer.from(provided));
|
|
2001
2200
|
if (!matches) throw Object.assign(new Error("Invalid Twilio signature"), { status: 401 });
|
|
2002
2201
|
return Object.fromEntries(params);
|
|
2003
2202
|
}
|
|
@@ -2181,15 +2380,30 @@ var drawbridge_default2 = {
|
|
|
2181
2380
|
},
|
|
2182
2381
|
icon: drawbridge_default,
|
|
2183
2382
|
// WHERE TWILIO PUTS THINGS on an inbound request, and WHICH credential
|
|
2184
|
-
// verifies
|
|
2185
|
-
//
|
|
2383
|
+
// verifies each channel. The route resolves the NAME to the stored value and
|
|
2384
|
+
// hands it to verify; this manifest never reads a store.
|
|
2385
|
+
//
|
|
2386
|
+
// `signature.channels` because this is the one manifest that receives from two
|
|
2387
|
+
// different vendors — Twilio on `sms`, SendGrid on `email` — which need
|
|
2388
|
+
// different credentials and share no scheme. A manifest whose channels all
|
|
2389
|
+
// verify the same way keeps declaring `signature` flat, and the route falls
|
|
2390
|
+
// back to it; Shopify's two channels do exactly that.
|
|
2186
2391
|
inbound: {
|
|
2187
2392
|
headers: {
|
|
2188
2393
|
id: "i-twilio-idempotency-token",
|
|
2189
2394
|
signature: "x-twilio-signature"
|
|
2190
2395
|
},
|
|
2191
2396
|
signature: {
|
|
2192
|
-
|
|
2397
|
+
channels: {
|
|
2398
|
+
email: sendgridInbound.signature,
|
|
2399
|
+
sms: { secret: "TWILIO_AUTH_TOKEN" },
|
|
2400
|
+
// Outbound delivery receipts, which Twilio signs exactly as it signs
|
|
2401
|
+
// an inbound message — same scheme, same credential, different url.
|
|
2402
|
+
// Separate channel because the payload is a MessageStatus rather than
|
|
2403
|
+
// a message, and conflating them would put a STOP keyword check on a
|
|
2404
|
+
// delivery receipt.
|
|
2405
|
+
"sms-status": { secret: "TWILIO_AUTH_TOKEN" }
|
|
2406
|
+
}
|
|
2193
2407
|
}
|
|
2194
2408
|
},
|
|
2195
2409
|
// PRIVATE: never in the catalog, always available to the builder.
|
|
@@ -2221,6 +2435,13 @@ var drawbridge_default2 = {
|
|
|
2221
2435
|
// Optional: SENDGRID_SEND_FROM_ADDRESS is not boot-required in sync
|
|
2222
2436
|
// either. Unset, it degrades to the account sender rather than
|
|
2223
2437
|
// refusing to start.
|
|
2438
|
+
// NOT redacted, because it is a PUBLIC key. Marking it secret would
|
|
2439
|
+
// be theatre, and it would stop an operator reading back the value
|
|
2440
|
+
// they need to compare against the SendGrid console.
|
|
2441
|
+
//
|
|
2442
|
+
// Optional: absent, the event webhook answers 500 on verify rather
|
|
2443
|
+
// than accepting unverified deliveries, and nothing else notices.
|
|
2444
|
+
{ 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." },
|
|
2224
2445
|
{ 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." }
|
|
2225
2446
|
],
|
|
2226
2447
|
icon: sendgrid_default,
|
|
@@ -3335,26 +3556,6 @@ var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
|
|
|
3335
3556
|
<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"/>
|
|
3336
3557
|
</svg>`;
|
|
3337
3558
|
|
|
3338
|
-
// lib/connections/inbound.js
|
|
3339
|
-
import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
3340
|
-
var verifySignature = ({ body, descriptor, headers, secret }) => {
|
|
3341
|
-
if (!secret) {
|
|
3342
|
-
throw Object.assign(new Error("Missing webhook secret: " + descriptor.signature.secret), { status: 500 });
|
|
3343
|
-
}
|
|
3344
|
-
const provided = headers[descriptor.headers.signature];
|
|
3345
|
-
if (!provided) {
|
|
3346
|
-
throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
|
|
3347
|
-
}
|
|
3348
|
-
const digest = createHmac2(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
|
|
3349
|
-
const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
|
|
3350
|
-
const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
|
|
3351
|
-
if (digestBuffer.length !== providedBuffer.length || !timingSafeEqual2(digestBuffer, providedBuffer)) {
|
|
3352
|
-
throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
|
|
3353
|
-
}
|
|
3354
|
-
return JSON.parse(body.toString());
|
|
3355
|
-
};
|
|
3356
|
-
var readEventHeader = ({ descriptor, headers }) => headers[descriptor.headers.event];
|
|
3357
|
-
|
|
3358
3559
|
// lib/email.js
|
|
3359
3560
|
var GMAIL_DOMAINS = /* @__PURE__ */ new Set(["gmail.com", "googlemail.com"]);
|
|
3360
3561
|
var toCanonicalEmail = (value) => {
|
|
@@ -4746,9 +4947,14 @@ var isBlockedIPv4 = (ip) => {
|
|
|
4746
4947
|
if (a >= 224) return true;
|
|
4747
4948
|
return false;
|
|
4748
4949
|
};
|
|
4950
|
+
var transition = new net.BlockList();
|
|
4951
|
+
transition.addSubnet("64:ff9b::", 96, "ipv6");
|
|
4952
|
+
transition.addSubnet("2002::", 16, "ipv6");
|
|
4953
|
+
transition.addSubnet("2001::", 32, "ipv6");
|
|
4749
4954
|
var isBlockedIPv6 = (ip) => {
|
|
4750
4955
|
const lower = ip.toLowerCase();
|
|
4751
4956
|
if (lower === "::1" || lower === "::") return true;
|
|
4957
|
+
if (transition.check(ip, "ipv6")) return true;
|
|
4752
4958
|
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
4753
4959
|
if (/^fe[89ab]/.test(lower)) return true;
|
|
4754
4960
|
if (lower.startsWith("ff")) return true;
|
|
@@ -4807,12 +5013,13 @@ var pinnedAgent = async (url) => {
|
|
|
4807
5013
|
var safeRequest = async ({
|
|
4808
5014
|
body,
|
|
4809
5015
|
headers = {},
|
|
5016
|
+
maxContentLength,
|
|
4810
5017
|
method = "GET",
|
|
4811
5018
|
query,
|
|
4812
5019
|
timeout = DEFAULT_TIMEOUT_MS2,
|
|
4813
5020
|
type = "json",
|
|
4814
5021
|
url
|
|
4815
|
-
}) => {
|
|
5022
|
+
}, { transport = axios } = {}) => {
|
|
4816
5023
|
const full = new URL(url);
|
|
4817
5024
|
if (query) {
|
|
4818
5025
|
Object.entries(query).forEach(([key, value]) => full.searchParams.set(key, value));
|
|
@@ -4820,7 +5027,7 @@ var safeRequest = async ({
|
|
|
4820
5027
|
const { protocol, agent } = await pinnedAgent(full.toString());
|
|
4821
5028
|
const isForm = type === "form";
|
|
4822
5029
|
try {
|
|
4823
|
-
const response = await
|
|
5030
|
+
const response = await transport({
|
|
4824
5031
|
method,
|
|
4825
5032
|
url: full.toString(),
|
|
4826
5033
|
headers: {
|
|
@@ -4830,6 +5037,7 @@ var safeRequest = async ({
|
|
|
4830
5037
|
...body !== void 0 && {
|
|
4831
5038
|
data: isForm ? new URLSearchParams(body).toString() : body
|
|
4832
5039
|
},
|
|
5040
|
+
...maxContentLength !== void 0 && { maxContentLength },
|
|
4833
5041
|
timeout,
|
|
4834
5042
|
maxRedirects: 0,
|
|
4835
5043
|
httpAgent: protocol === "http:" ? agent : void 0,
|
|
@@ -4852,6 +5060,19 @@ var safeRequest = async ({
|
|
|
4852
5060
|
};
|
|
4853
5061
|
|
|
4854
5062
|
// lib/connections/providers/webhook.js
|
|
5063
|
+
var RESPONSE_LIMIT = 64 * 1024;
|
|
5064
|
+
var signature = ({ body, settings }) => {
|
|
5065
|
+
var _a;
|
|
5066
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
5067
|
+
const payload = timestamp + "." + JSON.stringify(body);
|
|
5068
|
+
const previous = ((_a = settings.previous) == null ? void 0 : _a.secret) && new Date(settings.previous.until) > /* @__PURE__ */ new Date() ? [settings.previous.secret] : [];
|
|
5069
|
+
return [
|
|
5070
|
+
"t=" + timestamp,
|
|
5071
|
+
...[settings.secret, ...previous].map(
|
|
5072
|
+
(secret) => "v1=" + crypto3.createHmac("sha256", secret).update(payload).digest("hex")
|
|
5073
|
+
)
|
|
5074
|
+
].join(",");
|
|
5075
|
+
};
|
|
4855
5076
|
var webhook_default = {
|
|
4856
5077
|
// Connecting GENERATES the secret rather than storing one the merchant typed,
|
|
4857
5078
|
// so the buttons say what actually happens.
|
|
@@ -4882,7 +5103,9 @@ var webhook_default = {
|
|
|
4882
5103
|
"Press Connect. Drawbridge generates a signing secret and shows it here.",
|
|
4883
5104
|
"Copy the secret into your own endpoint.",
|
|
4884
5105
|
"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.",
|
|
4885
|
-
"On each request, compute HMAC-SHA256 of
|
|
5106
|
+
"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.",
|
|
5107
|
+
"Compare your result against each v1 value with a constant-time comparison, and reject any request whose timestamp is more than five minutes old.",
|
|
5108
|
+
"After you regenerate the secret, the previous one keeps signing for 24 hours (as a second v1), so update your endpoint within that window."
|
|
4886
5109
|
]
|
|
4887
5110
|
},
|
|
4888
5111
|
// Nothing to be exclusive with — there is no second webhook vendor, and a
|
|
@@ -4950,13 +5173,14 @@ var webhook_default = {
|
|
|
4950
5173
|
const { headers = {}, method = "POST", url } = step.settings || {};
|
|
4951
5174
|
const request2 = { method, url: url || null };
|
|
4952
5175
|
if (!url) return { message: "Outgoing webhook URL is not configured for this step.", request: request2, response: { skipped: true }, skipped: true };
|
|
5176
|
+
if (!/^https:\/\//i.test(url)) return { message: "Outgoing webhook URL must use https.", request: request2, response: { skipped: true }, skipped: true };
|
|
4953
5177
|
const body = lead || context;
|
|
4954
5178
|
request2.body = body;
|
|
4955
5179
|
const outgoing = { ...headers };
|
|
4956
5180
|
if (settings == null ? void 0 : settings.secret) {
|
|
4957
|
-
outgoing["X-Drawbridge-Signature"] =
|
|
5181
|
+
outgoing["X-Drawbridge-Signature"] = signature({ body, settings });
|
|
4958
5182
|
}
|
|
4959
|
-
const response = await send2({ body, headers: outgoing, method, url });
|
|
5183
|
+
const response = await send2({ body, headers: outgoing, maxContentLength: RESPONSE_LIMIT, method, url });
|
|
4960
5184
|
return { message: "Webhook POSTed to " + url + ".", request: request2, response: response || { delivered: true } };
|
|
4961
5185
|
}
|
|
4962
5186
|
}
|
|
@@ -5005,7 +5229,7 @@ var webhook_default = {
|
|
|
5005
5229
|
// and a task repeating it talks over the button.
|
|
5006
5230
|
tasks: ({ settings, status } = {}) => ["disconnected", "error"].includes(status) ? [] : (settings == null ? void 0 : settings.secret) ? [
|
|
5007
5231
|
{
|
|
5008
|
-
message:
|
|
5232
|
+
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.',
|
|
5009
5233
|
title: "Requests must be verified",
|
|
5010
5234
|
type: "warning"
|
|
5011
5235
|
}
|
package/dist/safe-http.cjs
CHANGED
|
@@ -64,9 +64,14 @@ var isBlockedIPv4 = (ip) => {
|
|
|
64
64
|
if (a >= 224) return true;
|
|
65
65
|
return false;
|
|
66
66
|
};
|
|
67
|
+
var transition = new import_net.default.BlockList();
|
|
68
|
+
transition.addSubnet("64:ff9b::", 96, "ipv6");
|
|
69
|
+
transition.addSubnet("2002::", 16, "ipv6");
|
|
70
|
+
transition.addSubnet("2001::", 32, "ipv6");
|
|
67
71
|
var isBlockedIPv6 = (ip) => {
|
|
68
72
|
const lower = ip.toLowerCase();
|
|
69
73
|
if (lower === "::1" || lower === "::") return true;
|
|
74
|
+
if (transition.check(ip, "ipv6")) return true;
|
|
70
75
|
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
71
76
|
if (/^fe[89ab]/.test(lower)) return true;
|
|
72
77
|
if (lower.startsWith("ff")) return true;
|
|
@@ -128,12 +133,13 @@ var pinnedAgent = async (url) => {
|
|
|
128
133
|
var safeRequest = async ({
|
|
129
134
|
body,
|
|
130
135
|
headers = {},
|
|
136
|
+
maxContentLength,
|
|
131
137
|
method = "GET",
|
|
132
138
|
query,
|
|
133
139
|
timeout = DEFAULT_TIMEOUT_MS,
|
|
134
140
|
type = "json",
|
|
135
141
|
url
|
|
136
|
-
}) => {
|
|
142
|
+
}, { transport = axios } = {}) => {
|
|
137
143
|
const full = new URL(url);
|
|
138
144
|
if (query) {
|
|
139
145
|
Object.entries(query).forEach(([key, value]) => full.searchParams.set(key, value));
|
|
@@ -141,7 +147,7 @@ var safeRequest = async ({
|
|
|
141
147
|
const { protocol, agent } = await pinnedAgent(full.toString());
|
|
142
148
|
const isForm = type === "form";
|
|
143
149
|
try {
|
|
144
|
-
const response = await
|
|
150
|
+
const response = await transport({
|
|
145
151
|
method,
|
|
146
152
|
url: full.toString(),
|
|
147
153
|
headers: {
|
|
@@ -151,6 +157,7 @@ var safeRequest = async ({
|
|
|
151
157
|
...body !== void 0 && {
|
|
152
158
|
data: isForm ? new URLSearchParams(body).toString() : body
|
|
153
159
|
},
|
|
160
|
+
...maxContentLength !== void 0 && { maxContentLength },
|
|
154
161
|
timeout,
|
|
155
162
|
maxRedirects: 0,
|
|
156
163
|
httpAgent: protocol === "http:" ? agent : void 0,
|
package/dist/safe-http.d.cts
CHANGED
|
@@ -103,15 +103,21 @@ const pinnedAgent = async ( url ) => {
|
|
|
103
103
|
// but pins the vetted IP and refuses redirects. A webhook endpoint that
|
|
104
104
|
// 3xx-redirects is treated as a failure — an intentional trade for closing the
|
|
105
105
|
// redirect-to-internal vector.
|
|
106
|
+
//
|
|
107
|
+
// `maxContentLength` bounds the RESPONSE. Unset, the transport's default stands
|
|
108
|
+
// (video assets and scrape targets are legitimately large); a caller that only
|
|
109
|
+
// owes the far side a status — an outgoing webhook — sets it, because whatever
|
|
110
|
+
// comes back is buffered whole in the worker. `transport` is the test seam.
|
|
106
111
|
const safeRequest = async ({
|
|
107
112
|
body,
|
|
108
113
|
headers = {},
|
|
114
|
+
maxContentLength,
|
|
109
115
|
method = 'GET',
|
|
110
116
|
query,
|
|
111
117
|
timeout = DEFAULT_TIMEOUT_MS,
|
|
112
118
|
type = 'json',
|
|
113
119
|
url
|
|
114
|
-
}) => {
|
|
120
|
+
}, { transport = axios } = {}) => {
|
|
115
121
|
|
|
116
122
|
const full = new URL( url );
|
|
117
123
|
|
|
@@ -126,7 +132,7 @@ const safeRequest = async ({
|
|
|
126
132
|
|
|
127
133
|
try {
|
|
128
134
|
|
|
129
|
-
const response = await
|
|
135
|
+
const response = await transport({
|
|
130
136
|
method,
|
|
131
137
|
url : full.toString(),
|
|
132
138
|
headers : {
|
|
@@ -136,6 +142,7 @@ const safeRequest = async ({
|
|
|
136
142
|
...( body !== undefined && {
|
|
137
143
|
data : isForm ? new URLSearchParams( body ).toString() : body
|
|
138
144
|
}),
|
|
145
|
+
...( maxContentLength !== undefined && { maxContentLength } ),
|
|
139
146
|
timeout,
|
|
140
147
|
maxRedirects : 0,
|
|
141
148
|
httpAgent : protocol === 'http:' ? agent : undefined,
|
package/dist/safe-http.d.ts
CHANGED
|
@@ -103,15 +103,21 @@ const pinnedAgent = async ( url ) => {
|
|
|
103
103
|
// but pins the vetted IP and refuses redirects. A webhook endpoint that
|
|
104
104
|
// 3xx-redirects is treated as a failure — an intentional trade for closing the
|
|
105
105
|
// redirect-to-internal vector.
|
|
106
|
+
//
|
|
107
|
+
// `maxContentLength` bounds the RESPONSE. Unset, the transport's default stands
|
|
108
|
+
// (video assets and scrape targets are legitimately large); a caller that only
|
|
109
|
+
// owes the far side a status — an outgoing webhook — sets it, because whatever
|
|
110
|
+
// comes back is buffered whole in the worker. `transport` is the test seam.
|
|
106
111
|
const safeRequest = async ({
|
|
107
112
|
body,
|
|
108
113
|
headers = {},
|
|
114
|
+
maxContentLength,
|
|
109
115
|
method = 'GET',
|
|
110
116
|
query,
|
|
111
117
|
timeout = DEFAULT_TIMEOUT_MS,
|
|
112
118
|
type = 'json',
|
|
113
119
|
url
|
|
114
|
-
}) => {
|
|
120
|
+
}, { transport = axios } = {}) => {
|
|
115
121
|
|
|
116
122
|
const full = new URL( url );
|
|
117
123
|
|
|
@@ -126,7 +132,7 @@ const safeRequest = async ({
|
|
|
126
132
|
|
|
127
133
|
try {
|
|
128
134
|
|
|
129
|
-
const response = await
|
|
135
|
+
const response = await transport({
|
|
130
136
|
method,
|
|
131
137
|
url : full.toString(),
|
|
132
138
|
headers : {
|
|
@@ -136,6 +142,7 @@ const safeRequest = async ({
|
|
|
136
142
|
...( body !== undefined && {
|
|
137
143
|
data : isForm ? new URLSearchParams( body ).toString() : body
|
|
138
144
|
}),
|
|
145
|
+
...( maxContentLength !== undefined && { maxContentLength } ),
|
|
139
146
|
timeout,
|
|
140
147
|
maxRedirects : 0,
|
|
141
148
|
httpAgent : protocol === 'http:' ? agent : undefined,
|
package/dist/safe-http.js
CHANGED
|
@@ -28,9 +28,14 @@ var isBlockedIPv4 = (ip) => {
|
|
|
28
28
|
if (a >= 224) return true;
|
|
29
29
|
return false;
|
|
30
30
|
};
|
|
31
|
+
var transition = new net.BlockList();
|
|
32
|
+
transition.addSubnet("64:ff9b::", 96, "ipv6");
|
|
33
|
+
transition.addSubnet("2002::", 16, "ipv6");
|
|
34
|
+
transition.addSubnet("2001::", 32, "ipv6");
|
|
31
35
|
var isBlockedIPv6 = (ip) => {
|
|
32
36
|
const lower = ip.toLowerCase();
|
|
33
37
|
if (lower === "::1" || lower === "::") return true;
|
|
38
|
+
if (transition.check(ip, "ipv6")) return true;
|
|
34
39
|
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
35
40
|
if (/^fe[89ab]/.test(lower)) return true;
|
|
36
41
|
if (lower.startsWith("ff")) return true;
|
|
@@ -92,12 +97,13 @@ var pinnedAgent = async (url) => {
|
|
|
92
97
|
var safeRequest = async ({
|
|
93
98
|
body,
|
|
94
99
|
headers = {},
|
|
100
|
+
maxContentLength,
|
|
95
101
|
method = "GET",
|
|
96
102
|
query,
|
|
97
103
|
timeout = DEFAULT_TIMEOUT_MS,
|
|
98
104
|
type = "json",
|
|
99
105
|
url
|
|
100
|
-
}) => {
|
|
106
|
+
}, { transport = axios } = {}) => {
|
|
101
107
|
const full = new URL(url);
|
|
102
108
|
if (query) {
|
|
103
109
|
Object.entries(query).forEach(([key, value]) => full.searchParams.set(key, value));
|
|
@@ -105,7 +111,7 @@ var safeRequest = async ({
|
|
|
105
111
|
const { protocol, agent } = await pinnedAgent(full.toString());
|
|
106
112
|
const isForm = type === "form";
|
|
107
113
|
try {
|
|
108
|
-
const response = await
|
|
114
|
+
const response = await transport({
|
|
109
115
|
method,
|
|
110
116
|
url: full.toString(),
|
|
111
117
|
headers: {
|
|
@@ -115,6 +121,7 @@ var safeRequest = async ({
|
|
|
115
121
|
...body !== void 0 && {
|
|
116
122
|
data: isForm ? new URLSearchParams(body).toString() : body
|
|
117
123
|
},
|
|
124
|
+
...maxContentLength !== void 0 && { maxContentLength },
|
|
118
125
|
timeout,
|
|
119
126
|
maxRedirects: 0,
|
|
120
127
|
httpAgent: protocol === "http:" ? agent : void 0,
|
package/dist/sendgrid.cjs
CHANGED
|
@@ -115,11 +115,27 @@ var sendgrid = {
|
|
|
115
115
|
// `headers` (optional) carries the List-Unsubscribe pair on lead-facing
|
|
116
116
|
// commercial sends; omitted, the request body is byte-identical to the
|
|
117
117
|
// pre-opt-out-floor shape so system mail is untouched.
|
|
118
|
-
|
|
118
|
+
// `args` (optional) rides out as SendGrid's custom_args and comes BACK on every
|
|
119
|
+
// Event Webhook event for the message. That is what makes a delivery event
|
|
120
|
+
// answerable: an event carries the recipient and the receiving server's
|
|
121
|
+
// reason, but nothing about who we sent as or why, so without these a bounce
|
|
122
|
+
// cannot be told from a lead send or a member one.
|
|
123
|
+
//
|
|
124
|
+
// Cheaper than the alternative, which was storing SendGrid's message id on the
|
|
125
|
+
// notification and joining on it — a schema-gated field, and schema-gated
|
|
126
|
+
// fields have to ship api-first and booted. This needs no collection to change.
|
|
127
|
+
//
|
|
128
|
+
// String values only, 10,000 bytes total (SendGrid's limit). One caveat worth
|
|
129
|
+
// knowing when reading events: a bounce delivered asynchronously against the
|
|
130
|
+
// Return-Path does not carry them.
|
|
131
|
+
send: async ({ apiKey, args, from, headers, html, request: request2, subject, text, to }) => {
|
|
119
132
|
var _a, _b;
|
|
120
133
|
try {
|
|
121
134
|
if (!(from == null ? void 0 : from.email)) throw new Error("SendGrid sender missing \u2014 pass from.email (the drawbridge provider's accountSender)");
|
|
122
135
|
const sender = { name: "Drawbridge", ...from };
|
|
136
|
+
const custom = Object.fromEntries(
|
|
137
|
+
Object.entries(args || {}).filter(([, value]) => value !== void 0 && value !== null && value !== "").map(([key, value]) => [key, String(value)])
|
|
138
|
+
);
|
|
123
139
|
await sendWithRetry(() => sendgridRequest({
|
|
124
140
|
apiKey,
|
|
125
141
|
...request2 && { request: request2 },
|
|
@@ -137,6 +153,7 @@ var sendgrid = {
|
|
|
137
153
|
}
|
|
138
154
|
],
|
|
139
155
|
from: sender,
|
|
156
|
+
...Object.keys(custom).length && { custom_args: custom },
|
|
140
157
|
...headers && { headers },
|
|
141
158
|
personalizations: [
|
|
142
159
|
{
|