@drawbridge/drawbridge-utils 0.0.124 → 0.0.126
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/connections/index.cjs +138 -25
- package/dist/connections/index.d.cts +171 -15
- package/dist/connections/index.d.ts +171 -15
- package/dist/connections/index.js +132 -19
- package/dist/providers.cjs +135 -22
- package/dist/providers.js +129 -16
- package/package.json +1 -1
|
@@ -251,7 +251,7 @@ var HOOKS = Object.freeze({
|
|
|
251
251
|
])
|
|
252
252
|
});
|
|
253
253
|
var HOOK_EFFECTS = Object.freeze(["enqueues", "events", "writes"]);
|
|
254
|
-
var WRITE_OPERATIONS = Object.freeze(["create", "update"]);
|
|
254
|
+
var WRITE_OPERATIONS = Object.freeze(["create", "delete", "update"]);
|
|
255
255
|
var HOOK_PROPS = Object.freeze([
|
|
256
256
|
"channel",
|
|
257
257
|
"clientId",
|
|
@@ -311,9 +311,9 @@ var effectsOf = (answer) => {
|
|
|
311
311
|
if (!WRITE_OPERATIONS.includes(write == null ? void 0 : write.operation)) {
|
|
312
312
|
throw new Error("A described write on " + write.collection + " needs an operation \u2014 one of " + WRITE_OPERATIONS.join(", "));
|
|
313
313
|
}
|
|
314
|
-
if (!(write == null ? void 0 : write.data)) throw new Error("A described write on " + write.collection + " carries no data");
|
|
315
|
-
if (
|
|
316
|
-
throw new Error("A described
|
|
314
|
+
if (write.operation !== "delete" && !(write == null ? void 0 : write.data)) throw new Error("A described write on " + write.collection + " carries no data");
|
|
315
|
+
if (["delete", "update"].includes(write.operation) && !write.query) {
|
|
316
|
+
throw new Error("A described " + write.operation + " on " + write.collection + " has no query \u2014 that is every document in it");
|
|
317
317
|
}
|
|
318
318
|
if (write.operation === "create" && write.query) {
|
|
319
319
|
throw new Error("A described create on " + write.collection + " carries a query \u2014 create does not filter");
|
|
@@ -893,9 +893,13 @@ var attentive_default2 = {
|
|
|
893
893
|
}
|
|
894
894
|
},
|
|
895
895
|
// WHY, in the merchant's words, and what to do about it.
|
|
896
|
+
// ONLY FOR A LIVE GRANT. A disconnected or errored connection's next step
|
|
897
|
+
// is reconnecting — prompting "choose a segment" there asks the merchant to
|
|
898
|
+
// configure a grant that no longer exists. `pending` is exactly this task's
|
|
899
|
+
// moment: the grant is good and the segment is the missing half.
|
|
896
900
|
tasks: (data2) => {
|
|
897
901
|
var _a;
|
|
898
|
-
return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.segment) ? [] : [
|
|
902
|
+
return !["active", "pending"].includes(data2 == null ? void 0 : data2.status) || ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.segment) ? [] : [
|
|
899
903
|
{
|
|
900
904
|
message: "Choose which Attentive segment your contacts should sync into. Until you do, nothing is being synced.",
|
|
901
905
|
title: "Choose a segment"
|
|
@@ -905,6 +909,9 @@ var attentive_default2 = {
|
|
|
905
909
|
title: "Attentive"
|
|
906
910
|
};
|
|
907
911
|
|
|
912
|
+
// lib/connections/providers/drawbridge.js
|
|
913
|
+
var import_node_crypto2 = require("crypto");
|
|
914
|
+
|
|
908
915
|
// lib/http.js
|
|
909
916
|
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
910
917
|
var request = async ({
|
|
@@ -1731,6 +1738,8 @@ var channels = {
|
|
|
1731
1738
|
};
|
|
1732
1739
|
|
|
1733
1740
|
// lib/connections/providers/drawbridge.js
|
|
1741
|
+
var STOP_KEYWORDS = ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"];
|
|
1742
|
+
var START_KEYWORDS = ["START", "UNSTOP", "YES"];
|
|
1734
1743
|
var interpolate = (template, data2) => {
|
|
1735
1744
|
if (!template) return template;
|
|
1736
1745
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => (data2 == null ? void 0 : data2[key]) != null ? String(data2[key]) : "{{" + key + "}}");
|
|
@@ -1958,7 +1967,88 @@ var drawbridge_default2 = {
|
|
|
1958
1967
|
};
|
|
1959
1968
|
}
|
|
1960
1969
|
},
|
|
1961
|
-
|
|
1970
|
+
// INBOUND SMS to the platform number. Twilio's webhook, verified and
|
|
1971
|
+
// processed HERE — the manifest owns its own inbound, the same as every
|
|
1972
|
+
// other vendor, and the bespoke webhooks route + the handler that lived in
|
|
1973
|
+
// sync's buffer table are gone.
|
|
1974
|
+
inbound: {
|
|
1975
|
+
// EVERY POST ON THIS CHANNEL IS AN INBOUND MESSAGE. Twilio sends no
|
|
1976
|
+
// topic header — the registered url IS the topic.
|
|
1977
|
+
event: () => "message.inbound",
|
|
1978
|
+
// STOP AND START, AS DESCRIBED WRITES. A withdrawn number is withdrawn
|
|
1979
|
+
// for everyone — organization : null is the platform floor canSend()
|
|
1980
|
+
// reads on every send path. $setOnInsert + upsert so webhook replays
|
|
1981
|
+
// and repeat STOPs collapse onto the unique { channel, address,
|
|
1982
|
+
// organization } index instead of erroring.
|
|
1983
|
+
process: ({ context }) => {
|
|
1984
|
+
var _a, _b;
|
|
1985
|
+
const keyword = String(((_a = context == null ? void 0 : context.data) == null ? void 0 : _a.Body) || "").trim().toUpperCase();
|
|
1986
|
+
const address = toE164((_b = context == null ? void 0 : context.data) == null ? void 0 : _b.From);
|
|
1987
|
+
if (!address) return { message: "Inbound SMS carried no usable sender number.", skipped: true };
|
|
1988
|
+
if (STOP_KEYWORDS.includes(keyword)) {
|
|
1989
|
+
return {
|
|
1990
|
+
message: "STOP recorded \u2014 " + address + " is suppressed on SMS everywhere.",
|
|
1991
|
+
request: { keyword },
|
|
1992
|
+
writes: [{
|
|
1993
|
+
collection: "suppression",
|
|
1994
|
+
data: {
|
|
1995
|
+
$setOnInsert: {
|
|
1996
|
+
address,
|
|
1997
|
+
channel: "sms",
|
|
1998
|
+
organization: null,
|
|
1999
|
+
reason: keyword,
|
|
2000
|
+
source: "stop"
|
|
2001
|
+
}
|
|
2002
|
+
},
|
|
2003
|
+
operation: "update",
|
|
2004
|
+
options: { upsert: true },
|
|
2005
|
+
query: {
|
|
2006
|
+
address,
|
|
2007
|
+
channel: "sms",
|
|
2008
|
+
organization: null
|
|
2009
|
+
}
|
|
2010
|
+
}]
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
if (START_KEYWORDS.includes(keyword)) {
|
|
2014
|
+
return {
|
|
2015
|
+
message: "START recorded \u2014 " + address + " can receive SMS again.",
|
|
2016
|
+
request: { keyword },
|
|
2017
|
+
writes: [{
|
|
2018
|
+
collection: "suppression",
|
|
2019
|
+
operation: "delete",
|
|
2020
|
+
query: {
|
|
2021
|
+
address,
|
|
2022
|
+
channel: "sms",
|
|
2023
|
+
organization: null
|
|
2024
|
+
}
|
|
2025
|
+
}]
|
|
2026
|
+
};
|
|
2027
|
+
}
|
|
2028
|
+
return { message: "A real reply, not a keyword \u2014 nothing to record.", skipped: true };
|
|
2029
|
+
},
|
|
2030
|
+
receive: ({ payload }) => ({
|
|
2031
|
+
data: payload,
|
|
2032
|
+
provider: { id: (payload == null ? void 0 : payload.MessageSid) || null }
|
|
2033
|
+
}),
|
|
2034
|
+
// TWILIO'S SCHEME, from docs.twilio.com/usage/security: take the full
|
|
2035
|
+
// registered url, sort the POST parameters alphabetically (Unix-style,
|
|
2036
|
+
// case-sensitive), append each name and value with no delimiters, sign
|
|
2037
|
+
// with HMAC-SHA1 keyed by the AuthToken, base64 — compared against
|
|
2038
|
+
// X-Twilio-Signature. It signs the URL rather than the raw body, which
|
|
2039
|
+
// is exactly why the shared body-HMAC verifier cannot cover it and this
|
|
2040
|
+
// hook exists.
|
|
2041
|
+
verify: ({ body, headers, secret, url }) => {
|
|
2042
|
+
if (!secret) throw Object.assign(new Error("Missing webhook secret: TWILIO_AUTH_TOKEN"), { status: 500 });
|
|
2043
|
+
const params = new URLSearchParams(String(body || ""));
|
|
2044
|
+
const signed = url + [...params.keys()].sort().map((key) => key + params.get(key)).join("");
|
|
2045
|
+
const expected = (0, import_node_crypto2.createHmac)("sha1", secret).update(signed).digest("base64");
|
|
2046
|
+
const provided = String((headers == null ? void 0 : headers["x-twilio-signature"]) || "");
|
|
2047
|
+
const matches = expected.length === provided.length && (0, import_node_crypto2.timingSafeEqual)(Buffer.from(expected), Buffer.from(provided));
|
|
2048
|
+
if (!matches) throw Object.assign(new Error("Invalid Twilio signature"), { status: 401 });
|
|
2049
|
+
return Object.fromEntries(params);
|
|
2050
|
+
}
|
|
2051
|
+
},
|
|
1962
2052
|
lifecycle: false,
|
|
1963
2053
|
resources: {
|
|
1964
2054
|
audiences: false,
|
|
@@ -2137,6 +2227,18 @@ var drawbridge_default2 = {
|
|
|
2137
2227
|
webhook: false
|
|
2138
2228
|
},
|
|
2139
2229
|
icon: drawbridge_default,
|
|
2230
|
+
// WHERE TWILIO PUTS THINGS on an inbound request, and WHICH credential
|
|
2231
|
+
// verifies it. `secret` names the drawbridge provider's smsToken — the route
|
|
2232
|
+
// resolves the name to the stored value and hands it to verify.
|
|
2233
|
+
inbound: {
|
|
2234
|
+
headers: {
|
|
2235
|
+
id: "i-twilio-idempotency-token",
|
|
2236
|
+
signature: "x-twilio-signature"
|
|
2237
|
+
},
|
|
2238
|
+
signature: {
|
|
2239
|
+
secret: "TWILIO_AUTH_TOKEN"
|
|
2240
|
+
}
|
|
2241
|
+
},
|
|
2140
2242
|
// PRIVATE: never in the catalog, always available to the builder.
|
|
2141
2243
|
private: true,
|
|
2142
2244
|
// THE PLATFORM'S OWN SENDING CREDENTIALS — SendGrid, Twilio, and the internal
|
|
@@ -2752,9 +2854,13 @@ var klaviyo_default2 = {
|
|
|
2752
2854
|
}
|
|
2753
2855
|
},
|
|
2754
2856
|
// WHY, in the merchant's words, and what to do about it.
|
|
2857
|
+
// ONLY FOR A LIVE GRANT. A disconnected or errored connection's next step
|
|
2858
|
+
// is reconnecting — prompting "choose a list" there asks the merchant to
|
|
2859
|
+
// configure a grant that no longer exists. `pending` is exactly this task's
|
|
2860
|
+
// moment: the grant is good and the list is the missing half.
|
|
2755
2861
|
tasks: (data2) => {
|
|
2756
2862
|
var _a;
|
|
2757
|
-
return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? [] : [
|
|
2863
|
+
return !["active", "pending"].includes(data2 == null ? void 0 : data2.status) || ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? [] : [
|
|
2758
2864
|
{
|
|
2759
2865
|
message: "Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.",
|
|
2760
2866
|
title: "Choose a list"
|
|
@@ -2765,7 +2871,7 @@ var klaviyo_default2 = {
|
|
|
2765
2871
|
};
|
|
2766
2872
|
|
|
2767
2873
|
// lib/connections/providers/mailchimp.js
|
|
2768
|
-
var
|
|
2874
|
+
var import_node_crypto3 = require("crypto");
|
|
2769
2875
|
|
|
2770
2876
|
// lib/connections/icons/mailchimp.js
|
|
2771
2877
|
var mailchimp_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
@@ -2797,7 +2903,7 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
|
|
|
2797
2903
|
}
|
|
2798
2904
|
return response.json();
|
|
2799
2905
|
};
|
|
2800
|
-
var subscriberHash = (email) => (0,
|
|
2906
|
+
var subscriberHash = (email) => (0, import_node_crypto3.createHash)("md5").update(String(email).trim().toLowerCase()).digest("hex");
|
|
2801
2907
|
var mailchimp_default2 = {
|
|
2802
2908
|
// OAUTH 2, authorization code. Every url below is quoted from
|
|
2803
2909
|
// mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ rather
|
|
@@ -2839,7 +2945,7 @@ var mailchimp_default2 = {
|
|
|
2839
2945
|
content: {
|
|
2840
2946
|
confirm: "Disconnecting removes Drawbridge's stored Mailchimp access. You can also remove Drawbridge from the Authorized Apps page in your Mailchimp account. Your contacts stay in both Drawbridge and Mailchimp \u2014 neither list is deleted.",
|
|
2841
2947
|
description: [
|
|
2842
|
-
"Drawbridge no longer sends email through Mailchimp. Notification email now sends from Drawbridge itself, and verifying a domain under
|
|
2948
|
+
"Drawbridge no longer sends email through Mailchimp. Notification email now sends from Drawbridge itself, and verifying a domain under Messaging in your organization settings puts your own brand in the from line.",
|
|
2843
2949
|
"Connecting Mailchimp lets Drawbridge sync the contacts your campaigns collect into a Mailchimp audience, so the people who enter a giveaway can be marketed to alongside the rest of your list.",
|
|
2844
2950
|
"Anyone who has opted out in Drawbridge is synced as unsubscribed rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.",
|
|
2845
2951
|
"Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before."
|
|
@@ -3067,9 +3173,13 @@ var mailchimp_default2 = {
|
|
|
3067
3173
|
}
|
|
3068
3174
|
},
|
|
3069
3175
|
// WHY, in the merchant's words, and what to do about it.
|
|
3176
|
+
// ONLY FOR A LIVE GRANT. A disconnected or errored connection's next step
|
|
3177
|
+
// is reconnecting — prompting "choose a audience" there asks the merchant to
|
|
3178
|
+
// configure a grant that no longer exists. `pending` is exactly this task's
|
|
3179
|
+
// moment: the grant is good and the audience is the missing half.
|
|
3070
3180
|
tasks: (data2) => {
|
|
3071
3181
|
var _a;
|
|
3072
|
-
return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.audience) ? [] : [
|
|
3182
|
+
return !["active", "pending"].includes(data2 == null ? void 0 : data2.status) || ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.audience) ? [] : [
|
|
3073
3183
|
{
|
|
3074
3184
|
message: "Choose which Mailchimp audience your contacts should sync into. Until you do, nothing is being synced.",
|
|
3075
3185
|
title: "Choose an audience"
|
|
@@ -3080,7 +3190,7 @@ var mailchimp_default2 = {
|
|
|
3080
3190
|
};
|
|
3081
3191
|
|
|
3082
3192
|
// lib/connections/providers/shopify.js
|
|
3083
|
-
var
|
|
3193
|
+
var import_node_crypto5 = require("crypto");
|
|
3084
3194
|
var import_nanoid2 = require("nanoid");
|
|
3085
3195
|
|
|
3086
3196
|
// lib/connections/icons/shopify.js
|
|
@@ -3091,7 +3201,7 @@ var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
|
|
|
3091
3201
|
</svg>`;
|
|
3092
3202
|
|
|
3093
3203
|
// lib/connections/inbound.js
|
|
3094
|
-
var
|
|
3204
|
+
var import_node_crypto4 = require("crypto");
|
|
3095
3205
|
var verifySignature = ({ body, descriptor, headers, secret }) => {
|
|
3096
3206
|
if (!secret) {
|
|
3097
3207
|
throw Object.assign(new Error("Missing webhook secret: " + descriptor.signature.secret), { status: 500 });
|
|
@@ -3100,10 +3210,10 @@ var verifySignature = ({ body, descriptor, headers, secret }) => {
|
|
|
3100
3210
|
if (!provided) {
|
|
3101
3211
|
throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
|
|
3102
3212
|
}
|
|
3103
|
-
const digest = (0,
|
|
3213
|
+
const digest = (0, import_node_crypto4.createHmac)(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
|
|
3104
3214
|
const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
|
|
3105
3215
|
const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
|
|
3106
|
-
if (digestBuffer.length !== providedBuffer.length || !(0,
|
|
3216
|
+
if (digestBuffer.length !== providedBuffer.length || !(0, import_node_crypto4.timingSafeEqual)(digestBuffer, providedBuffer)) {
|
|
3107
3217
|
throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
|
|
3108
3218
|
}
|
|
3109
3219
|
return JSON.parse(body.toString());
|
|
@@ -3842,7 +3952,7 @@ var shopify_default2 = {
|
|
|
3842
3952
|
event: "shopify.register.webhooks"
|
|
3843
3953
|
},
|
|
3844
3954
|
name: "register",
|
|
3845
|
-
options: { jobId: "connection.update.register." + workflow.connection + "." + (0,
|
|
3955
|
+
options: { jobId: "connection.update.register." + workflow.connection + "." + (0, import_node_crypto5.randomUUID)() },
|
|
3846
3956
|
queue: "connection"
|
|
3847
3957
|
}],
|
|
3848
3958
|
message: (scopesMissing == null ? void 0 : scopesMissing.length) ? "Health check: ping ok, webhooks reconciled \u2014 connection errored, granted scopes are missing: " + scopesMissing.join(", ") + "." : refreshTokenRotated ? "Health check passed \u2014 refresh token rotated, ping ok, webhooks reconciled." : "Health check passed \u2014 ping ok, webhooks reconciled.",
|
|
@@ -4129,7 +4239,7 @@ var shopify_default2 = {
|
|
|
4129
4239
|
};
|
|
4130
4240
|
|
|
4131
4241
|
// lib/connections/providers/webhook.js
|
|
4132
|
-
var
|
|
4242
|
+
var import_node_crypto6 = __toESM(require("crypto"), 1);
|
|
4133
4243
|
|
|
4134
4244
|
// lib/safe-http.js
|
|
4135
4245
|
var import_dns2 = __toESM(require("dns"), 1);
|
|
@@ -4361,7 +4471,7 @@ var webhook_default = {
|
|
|
4361
4471
|
request2.body = body;
|
|
4362
4472
|
const outgoing = { ...headers };
|
|
4363
4473
|
if (settings == null ? void 0 : settings.secret) {
|
|
4364
|
-
outgoing["X-Drawbridge-Signature"] = "sha256=" +
|
|
4474
|
+
outgoing["X-Drawbridge-Signature"] = "sha256=" + import_node_crypto6.default.createHmac("sha256", settings.secret).update(JSON.stringify(body)).digest("hex");
|
|
4365
4475
|
}
|
|
4366
4476
|
const response = await send2({ body, headers: outgoing, method, url });
|
|
4367
4477
|
return { message: "Webhook POSTed to " + url + ".", request: request2, response: response || { delivered: true } };
|
|
@@ -4407,7 +4517,10 @@ var webhook_default = {
|
|
|
4407
4517
|
// pressing Connect will do; afterwards it states the verification the
|
|
4408
4518
|
// merchant's own endpoint has to perform, because a signed payload nobody
|
|
4409
4519
|
// checks is an unsigned payload.
|
|
4410
|
-
|
|
4520
|
+
// The connect prompt only where connecting is not already the card's whole
|
|
4521
|
+
// story: a disconnected or errored document renders a Connect action itself,
|
|
4522
|
+
// and a task repeating it talks over the button.
|
|
4523
|
+
tasks: ({ settings, status } = {}) => ["disconnected", "error"].includes(status) ? [] : (settings == null ? void 0 : settings.secret) ? [
|
|
4411
4524
|
{
|
|
4412
4525
|
message: "Compute HMAC-SHA256( secret, body ) and compare against the X-Drawbridge-Signature header to confirm each payload.",
|
|
4413
4526
|
title: "Requests must be verified",
|
|
@@ -4432,7 +4545,7 @@ var leaves = (node, path = []) => Object.entries(node || {}).flatMap(
|
|
|
4432
4545
|
([key, value]) => typeof value === "function" ? [[[...path, key].join("."), value]] : value && typeof value === "object" ? leaves(value, [...path, key]) : []
|
|
4433
4546
|
);
|
|
4434
4547
|
var build = (manifest) => {
|
|
4435
|
-
var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q, _r;
|
|
4548
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q, _r, _s, _t;
|
|
4436
4549
|
if (!(manifest == null ? void 0 : manifest.slug)) throw new Error("A connection needs a slug");
|
|
4437
4550
|
if (!(manifest == null ? void 0 : manifest.title)) throw new Error(manifest.slug + " needs a title");
|
|
4438
4551
|
if (!(manifest == null ? void 0 : manifest.private) && !(manifest == null ? void 0 : manifest.feature)) throw new Error(manifest.slug + " needs a plan feature key");
|
|
@@ -4511,16 +4624,16 @@ var build = (manifest) => {
|
|
|
4511
4624
|
);
|
|
4512
4625
|
}
|
|
4513
4626
|
}
|
|
4514
|
-
if (implemented(manifest.hooks, "inbound.event") &&
|
|
4627
|
+
if (implemented(manifest.hooks, "inbound.event") && typeof ((_l = (_k = manifest.hooks) == null ? void 0 : _k.inbound) == null ? void 0 : _l.event) !== "function" && !((_n = (_m = manifest.inbound) == null ? void 0 : _m.headers) == null ? void 0 : _n.event)) {
|
|
4515
4628
|
throw new Error(manifest.slug + " implements inbound.event but declares no inbound.headers.event");
|
|
4516
4629
|
}
|
|
4517
|
-
if (implemented(manifest.hooks, "inbound.verify") && !((
|
|
4630
|
+
if (implemented(manifest.hooks, "inbound.verify") && !((_p = (_o = manifest.inbound) == null ? void 0 : _o.headers) == null ? void 0 : _p.signature)) {
|
|
4518
4631
|
throw new Error(manifest.slug + " implements inbound.verify but declares no inbound.headers.signature");
|
|
4519
4632
|
}
|
|
4520
4633
|
if (typeof (manifest == null ? void 0 : manifest.status) !== "function") {
|
|
4521
4634
|
throw new Error(manifest.slug + " must declare status( data ) \u2014 return null to accept the connection's own status, or { message, status } to override it");
|
|
4522
4635
|
}
|
|
4523
|
-
if (!Array.isArray((
|
|
4636
|
+
if (!Array.isArray((_q = manifest == null ? void 0 : manifest.content) == null ? void 0 : _q.guide) || !manifest.content.guide.length) {
|
|
4524
4637
|
throw new Error(manifest.slug + " needs content.guide \u2014 an array of steps for its page");
|
|
4525
4638
|
}
|
|
4526
4639
|
if (typeof (manifest == null ? void 0 : manifest.status) !== "function") {
|
|
@@ -4532,8 +4645,8 @@ var build = (manifest) => {
|
|
|
4532
4645
|
}
|
|
4533
4646
|
for (const [domain, verbs] of Object.entries(HOOKS)) {
|
|
4534
4647
|
for (const verb of verbs) {
|
|
4535
|
-
const hook = (
|
|
4536
|
-
if (((
|
|
4648
|
+
const hook = (_s = (_r = manifest.hooks) == null ? void 0 : _r[domain]) == null ? void 0 : _s[verb];
|
|
4649
|
+
if (((_t = manifest.hooks) == null ? void 0 : _t[domain]) === false) continue;
|
|
4537
4650
|
if (hook !== false && !implemented({ [domain]: { [verb]: hook } }, domain + "." + verb)) {
|
|
4538
4651
|
throw new Error(manifest.slug + " must answer hooks." + domain + "." + verb + " \u2014 false, a function, or {} if another repo implements it");
|
|
4539
4652
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { authToken } from './oauth.cjs';
|
|
2
2
|
export { consentUrl, pkcePair } from './oauth.cjs';
|
|
3
3
|
import { toE164, detectCountry } from '../phone.cjs';
|
|
4
|
+
import crypto, { createHmac, timingSafeEqual, createHash, randomUUID } from 'node:crypto';
|
|
4
5
|
import { request } from '../http.cjs';
|
|
5
6
|
import { channels } from '../pricing.cjs';
|
|
6
|
-
import crypto, { createHash, createHmac, timingSafeEqual, randomUUID } from 'node:crypto';
|
|
7
7
|
import { customAlphabet } from 'nanoid';
|
|
8
8
|
import { toCanonicalEmail } from '../email.cjs';
|
|
9
9
|
import { conversionRate } from '../plans.cjs';
|
|
@@ -282,7 +282,7 @@ const HOOK_EFFECTS = Object.freeze([ 'enqueues', 'events', 'writes' ]);
|
|
|
282
282
|
// The controller methods a described write may become. Only the two that a hook
|
|
283
283
|
// has ever needed — a deletion is a cascade, and cascades are owned by
|
|
284
284
|
// drawbridge-sync's streams rather than by a step.
|
|
285
|
-
const WRITE_OPERATIONS = Object.freeze([ 'create', 'update' ]);
|
|
285
|
+
const WRITE_OPERATIONS = Object.freeze([ 'create', 'delete', 'update' ]);
|
|
286
286
|
|
|
287
287
|
// READ A HOOK'S EFFECTS, or refuse them.
|
|
288
288
|
//
|
|
@@ -414,13 +414,15 @@ const effectsOf = ( answer ) => {
|
|
|
414
414
|
|
|
415
415
|
}
|
|
416
416
|
|
|
417
|
-
|
|
417
|
+
// A delete says WHICH, not WHAT — its query is its whole statement.
|
|
418
|
+
if( write.operation !== 'delete' && ! write?.data ) throw new Error( 'A described write on ' + write.collection + ' carries no data' );
|
|
418
419
|
|
|
419
|
-
// AN UPDATE WITH NO QUERY IS EVERY DOCUMENT IN THE COLLECTION.
|
|
420
|
-
// rather than survived, because the controller would happily
|
|
421
|
-
|
|
420
|
+
// AN UPDATE OR DELETE WITH NO QUERY IS EVERY DOCUMENT IN THE COLLECTION.
|
|
421
|
+
// Refused here rather than survived, because the controller would happily
|
|
422
|
+
// run it.
|
|
423
|
+
if( [ 'delete', 'update' ].includes( write.operation ) && ! write.query ){
|
|
422
424
|
|
|
423
|
-
throw new Error( 'A described
|
|
425
|
+
throw new Error( 'A described ' + write.operation + ' on ' + write.collection + ' has no query — that is every document in it' );
|
|
424
426
|
|
|
425
427
|
}
|
|
426
428
|
|
|
@@ -1233,7 +1235,11 @@ var attentive = {
|
|
|
1233
1235
|
|
|
1234
1236
|
},
|
|
1235
1237
|
// WHY, in the merchant's words, and what to do about it.
|
|
1236
|
-
|
|
1238
|
+
// ONLY FOR A LIVE GRANT. A disconnected or errored connection's next step
|
|
1239
|
+
// is reconnecting — prompting "choose a segment" there asks the merchant to
|
|
1240
|
+
// configure a grant that no longer exists. `pending` is exactly this task's
|
|
1241
|
+
// moment: the grant is good and the segment is the missing half.
|
|
1242
|
+
tasks : ( data ) => ( ! [ 'active', 'pending' ].includes( data?.status ) || data?.settings?.segment
|
|
1237
1243
|
? []
|
|
1238
1244
|
: [
|
|
1239
1245
|
{
|
|
@@ -1573,6 +1579,14 @@ var icon$3 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
|
|
|
1573
1579
|
</defs>
|
|
1574
1580
|
</svg>`;
|
|
1575
1581
|
|
|
1582
|
+
// TWILIO'S OWN KEYWORD LISTS. STOP-family withdraws consent for the number —
|
|
1583
|
+
// platform-wide, because every organization sends from the same platform number,
|
|
1584
|
+
// so a withdrawal cannot be scoped narrower than the number it was sent to.
|
|
1585
|
+
// START-family re-subscribes. Anything else is a real reply and none of our
|
|
1586
|
+
// business.
|
|
1587
|
+
const STOP_KEYWORDS = [ 'STOP', 'STOPALL', 'UNSUBSCRIBE', 'CANCEL', 'END', 'QUIT' ];
|
|
1588
|
+
const START_KEYWORDS = [ 'START', 'UNSTOP', 'YES' ];
|
|
1589
|
+
|
|
1576
1590
|
// Drawbridge itself — the PRIVATE connection.
|
|
1577
1591
|
//
|
|
1578
1592
|
// Nobody connects this. There is no credential, no consent, and no card on the
|
|
@@ -1927,7 +1941,113 @@ var drawbridge = {
|
|
|
1927
1941
|
}
|
|
1928
1942
|
|
|
1929
1943
|
},
|
|
1930
|
-
|
|
1944
|
+
// INBOUND SMS to the platform number. Twilio's webhook, verified and
|
|
1945
|
+
// processed HERE — the manifest owns its own inbound, the same as every
|
|
1946
|
+
// other vendor, and the bespoke webhooks route + the handler that lived in
|
|
1947
|
+
// sync's buffer table are gone.
|
|
1948
|
+
inbound : {
|
|
1949
|
+
|
|
1950
|
+
// EVERY POST ON THIS CHANNEL IS AN INBOUND MESSAGE. Twilio sends no
|
|
1951
|
+
// topic header — the registered url IS the topic.
|
|
1952
|
+
event : () => 'message.inbound',
|
|
1953
|
+
|
|
1954
|
+
// STOP AND START, AS DESCRIBED WRITES. A withdrawn number is withdrawn
|
|
1955
|
+
// for everyone — organization : null is the platform floor canSend()
|
|
1956
|
+
// reads on every send path. $setOnInsert + upsert so webhook replays
|
|
1957
|
+
// and repeat STOPs collapse onto the unique { channel, address,
|
|
1958
|
+
// organization } index instead of erroring.
|
|
1959
|
+
process : ({ context }) => {
|
|
1960
|
+
|
|
1961
|
+
const keyword = String( context?.data?.Body || '' ).trim().toUpperCase();
|
|
1962
|
+
const address = toE164( context?.data?.From );
|
|
1963
|
+
|
|
1964
|
+
if( ! address ) return { message : 'Inbound SMS carried no usable sender number.', skipped : true };
|
|
1965
|
+
|
|
1966
|
+
if( STOP_KEYWORDS.includes( keyword ) ){
|
|
1967
|
+
|
|
1968
|
+
return {
|
|
1969
|
+
message : 'STOP recorded — ' + address + ' is suppressed on SMS everywhere.',
|
|
1970
|
+
request : { keyword },
|
|
1971
|
+
writes : [ {
|
|
1972
|
+
collection : 'suppression',
|
|
1973
|
+
data : {
|
|
1974
|
+
$setOnInsert : {
|
|
1975
|
+
address,
|
|
1976
|
+
channel : 'sms',
|
|
1977
|
+
organization : null,
|
|
1978
|
+
reason : keyword,
|
|
1979
|
+
source : 'stop'
|
|
1980
|
+
}
|
|
1981
|
+
},
|
|
1982
|
+
operation : 'update',
|
|
1983
|
+
options : { upsert : true },
|
|
1984
|
+
query : {
|
|
1985
|
+
address,
|
|
1986
|
+
channel : 'sms',
|
|
1987
|
+
organization : null
|
|
1988
|
+
}
|
|
1989
|
+
} ]
|
|
1990
|
+
};
|
|
1991
|
+
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
if( START_KEYWORDS.includes( keyword ) ){
|
|
1995
|
+
|
|
1996
|
+
return {
|
|
1997
|
+
message : 'START recorded — ' + address + ' can receive SMS again.',
|
|
1998
|
+
request : { keyword },
|
|
1999
|
+
writes : [ {
|
|
2000
|
+
collection : 'suppression',
|
|
2001
|
+
operation : 'delete',
|
|
2002
|
+
query : {
|
|
2003
|
+
address,
|
|
2004
|
+
channel : 'sms',
|
|
2005
|
+
organization : null
|
|
2006
|
+
}
|
|
2007
|
+
} ]
|
|
2008
|
+
};
|
|
2009
|
+
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
return { message : 'A real reply, not a keyword — nothing to record.', skipped : true };
|
|
2013
|
+
|
|
2014
|
+
},
|
|
2015
|
+
|
|
2016
|
+
receive : ({ payload }) => ({
|
|
2017
|
+
data : payload,
|
|
2018
|
+
provider : { id : payload?.MessageSid || null }
|
|
2019
|
+
}),
|
|
2020
|
+
|
|
2021
|
+
// TWILIO'S SCHEME, from docs.twilio.com/usage/security: take the full
|
|
2022
|
+
// registered url, sort the POST parameters alphabetically (Unix-style,
|
|
2023
|
+
// case-sensitive), append each name and value with no delimiters, sign
|
|
2024
|
+
// with HMAC-SHA1 keyed by the AuthToken, base64 — compared against
|
|
2025
|
+
// X-Twilio-Signature. It signs the URL rather than the raw body, which
|
|
2026
|
+
// is exactly why the shared body-HMAC verifier cannot cover it and this
|
|
2027
|
+
// hook exists.
|
|
2028
|
+
verify : ({ body, headers, secret, url }) => {
|
|
2029
|
+
|
|
2030
|
+
// OUR misconfiguration, never a forgery — a missing credential must
|
|
2031
|
+
// not be answered 401.
|
|
2032
|
+
if( ! secret ) throw Object.assign( new Error( 'Missing webhook secret: TWILIO_AUTH_TOKEN' ), { status : 500 });
|
|
2033
|
+
|
|
2034
|
+
const params = new URLSearchParams( String( body || '' ) );
|
|
2035
|
+
|
|
2036
|
+
const signed = url + [ ...params.keys() ].sort().map( ( key ) => key + params.get( key ) ).join( '' );
|
|
2037
|
+
|
|
2038
|
+
const expected = createHmac( 'sha1', secret ).update( signed ).digest( 'base64' );
|
|
2039
|
+
const provided = String( headers?.[ 'x-twilio-signature' ] || '' );
|
|
2040
|
+
|
|
2041
|
+
const matches = expected.length === provided.length
|
|
2042
|
+
&& timingSafeEqual( Buffer.from( expected ), Buffer.from( provided ) );
|
|
2043
|
+
|
|
2044
|
+
if( ! matches ) throw Object.assign( new Error( 'Invalid Twilio signature' ), { status : 401 });
|
|
2045
|
+
|
|
2046
|
+
return Object.fromEntries( params );
|
|
2047
|
+
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
},
|
|
1931
2051
|
lifecycle : false,
|
|
1932
2052
|
resources : {
|
|
1933
2053
|
audiences : false,
|
|
@@ -2196,6 +2316,19 @@ var drawbridge = {
|
|
|
2196
2316
|
webhook : false
|
|
2197
2317
|
},
|
|
2198
2318
|
icon: icon$3,
|
|
2319
|
+
|
|
2320
|
+
// WHERE TWILIO PUTS THINGS on an inbound request, and WHICH credential
|
|
2321
|
+
// verifies it. `secret` names the drawbridge provider's smsToken — the route
|
|
2322
|
+
// resolves the name to the stored value and hands it to verify.
|
|
2323
|
+
inbound : {
|
|
2324
|
+
headers : {
|
|
2325
|
+
id : 'i-twilio-idempotency-token',
|
|
2326
|
+
signature : 'x-twilio-signature'
|
|
2327
|
+
},
|
|
2328
|
+
signature : {
|
|
2329
|
+
secret : 'TWILIO_AUTH_TOKEN'
|
|
2330
|
+
}
|
|
2331
|
+
},
|
|
2199
2332
|
// PRIVATE: never in the catalog, always available to the builder.
|
|
2200
2333
|
private : true,
|
|
2201
2334
|
// THE PLATFORM'S OWN SENDING CREDENTIALS — SendGrid, Twilio, and the internal
|
|
@@ -2959,7 +3092,11 @@ var klaviyo = {
|
|
|
2959
3092
|
|
|
2960
3093
|
},
|
|
2961
3094
|
// WHY, in the merchant's words, and what to do about it.
|
|
2962
|
-
|
|
3095
|
+
// ONLY FOR A LIVE GRANT. A disconnected or errored connection's next step
|
|
3096
|
+
// is reconnecting — prompting "choose a list" there asks the merchant to
|
|
3097
|
+
// configure a grant that no longer exists. `pending` is exactly this task's
|
|
3098
|
+
// moment: the grant is good and the list is the missing half.
|
|
3099
|
+
tasks : ( data ) => ( ! [ 'active', 'pending' ].includes( data?.status ) || data?.settings?.list
|
|
2963
3100
|
? []
|
|
2964
3101
|
: [
|
|
2965
3102
|
{
|
|
@@ -3089,7 +3226,7 @@ var mailchimp = {
|
|
|
3089
3226
|
content : {
|
|
3090
3227
|
confirm : 'Disconnecting removes Drawbridge\'s stored Mailchimp access. You can also remove Drawbridge from the Authorized Apps page in your Mailchimp account. Your contacts stay in both Drawbridge and Mailchimp — neither list is deleted.',
|
|
3091
3228
|
description : [
|
|
3092
|
-
'Drawbridge no longer sends email through Mailchimp. Notification email now sends from Drawbridge itself, and verifying a domain under
|
|
3229
|
+
'Drawbridge no longer sends email through Mailchimp. Notification email now sends from Drawbridge itself, and verifying a domain under Messaging in your organization settings puts your own brand in the from line.',
|
|
3093
3230
|
'Connecting Mailchimp lets Drawbridge sync the contacts your campaigns collect into a Mailchimp audience, so the people who enter a giveaway can be marketed to alongside the rest of your list.',
|
|
3094
3231
|
'Anyone who has opted out in Drawbridge is synced as unsubscribed rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.',
|
|
3095
3232
|
'Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before.'
|
|
@@ -3392,7 +3529,11 @@ var mailchimp = {
|
|
|
3392
3529
|
|
|
3393
3530
|
},
|
|
3394
3531
|
// WHY, in the merchant's words, and what to do about it.
|
|
3395
|
-
|
|
3532
|
+
// ONLY FOR A LIVE GRANT. A disconnected or errored connection's next step
|
|
3533
|
+
// is reconnecting — prompting "choose a audience" there asks the merchant to
|
|
3534
|
+
// configure a grant that no longer exists. `pending` is exactly this task's
|
|
3535
|
+
// moment: the grant is good and the audience is the missing half.
|
|
3536
|
+
tasks : ( data ) => ( ! [ 'active', 'pending' ].includes( data?.status ) || data?.settings?.audience
|
|
3396
3537
|
? []
|
|
3397
3538
|
: [
|
|
3398
3539
|
{
|
|
@@ -5003,7 +5144,12 @@ var webhook = {
|
|
|
5003
5144
|
// pressing Connect will do; afterwards it states the verification the
|
|
5004
5145
|
// merchant's own endpoint has to perform, because a signed payload nobody
|
|
5005
5146
|
// checks is an unsigned payload.
|
|
5006
|
-
|
|
5147
|
+
// The connect prompt only where connecting is not already the card's whole
|
|
5148
|
+
// story: a disconnected or errored document renders a Connect action itself,
|
|
5149
|
+
// and a task repeating it talks over the button.
|
|
5150
|
+
tasks : ( { settings, status } = {} ) => ( [ 'disconnected', 'error' ].includes( status )
|
|
5151
|
+
? []
|
|
5152
|
+
: settings?.secret
|
|
5007
5153
|
? [
|
|
5008
5154
|
{
|
|
5009
5155
|
message : 'Compute HMAC-SHA256( secret, body ) and compare against the X-Drawbridge-Signature header to confirm each payload.',
|
|
@@ -5017,7 +5163,7 @@ var webhook = {
|
|
|
5017
5163
|
title : 'Webhook signing'
|
|
5018
5164
|
}
|
|
5019
5165
|
]
|
|
5020
|
-
|
|
5166
|
+
),
|
|
5021
5167
|
title : 'Webhooks'
|
|
5022
5168
|
};
|
|
5023
5169
|
|
|
@@ -5315,7 +5461,17 @@ const build = ( manifest ) => {
|
|
|
5315
5461
|
// A vendor that receives from the outside must say where it puts the event
|
|
5316
5462
|
// name. Without it the receiver has nothing to dispatch on, and the failure
|
|
5317
5463
|
// is a request accepted and dropped rather than an error.
|
|
5318
|
-
|
|
5464
|
+
//
|
|
5465
|
+
// A vendor may derive the event from the CHANNEL instead — Twilio sends no
|
|
5466
|
+
// topic header, so drawbridge's inbound.event is a constant and the
|
|
5467
|
+
// registered url is the topic. What is required is that SOMETHING names the
|
|
5468
|
+
// event: a declared header, or an event hook that answers without one. The
|
|
5469
|
+
// build cannot see inside the function, so the hook's existence is the
|
|
5470
|
+
// declaration — and a hook that returns nothing still fails at the route,
|
|
5471
|
+
// where a null event is refused before it is buffered.
|
|
5472
|
+
if( implemented( manifest.hooks, 'inbound.event' )
|
|
5473
|
+
&& typeof manifest.hooks?.inbound?.event !== 'function'
|
|
5474
|
+
&& ! manifest.inbound?.headers?.event ){
|
|
5319
5475
|
|
|
5320
5476
|
throw new Error( manifest.slug + ' implements inbound.event but declares no inbound.headers.event' );
|
|
5321
5477
|
|