@drawbridge/drawbridge-utils 0.0.125 → 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 +118 -20
- package/dist/connections/index.d.cts +148 -9
- package/dist/connections/index.d.ts +148 -9
- package/dist/connections/index.js +112 -14
- package/dist/providers.cjs +115 -17
- package/dist/providers.js +109 -11
- 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");
|
|
@@ -909,6 +909,9 @@ var attentive_default2 = {
|
|
|
909
909
|
title: "Attentive"
|
|
910
910
|
};
|
|
911
911
|
|
|
912
|
+
// lib/connections/providers/drawbridge.js
|
|
913
|
+
var import_node_crypto2 = require("crypto");
|
|
914
|
+
|
|
912
915
|
// lib/http.js
|
|
913
916
|
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
914
917
|
var request = async ({
|
|
@@ -1735,6 +1738,8 @@ var channels = {
|
|
|
1735
1738
|
};
|
|
1736
1739
|
|
|
1737
1740
|
// lib/connections/providers/drawbridge.js
|
|
1741
|
+
var STOP_KEYWORDS = ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"];
|
|
1742
|
+
var START_KEYWORDS = ["START", "UNSTOP", "YES"];
|
|
1738
1743
|
var interpolate = (template, data2) => {
|
|
1739
1744
|
if (!template) return template;
|
|
1740
1745
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => (data2 == null ? void 0 : data2[key]) != null ? String(data2[key]) : "{{" + key + "}}");
|
|
@@ -1962,7 +1967,88 @@ var drawbridge_default2 = {
|
|
|
1962
1967
|
};
|
|
1963
1968
|
}
|
|
1964
1969
|
},
|
|
1965
|
-
|
|
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
|
+
},
|
|
1966
2052
|
lifecycle: false,
|
|
1967
2053
|
resources: {
|
|
1968
2054
|
audiences: false,
|
|
@@ -2141,6 +2227,18 @@ var drawbridge_default2 = {
|
|
|
2141
2227
|
webhook: false
|
|
2142
2228
|
},
|
|
2143
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
|
+
},
|
|
2144
2242
|
// PRIVATE: never in the catalog, always available to the builder.
|
|
2145
2243
|
private: true,
|
|
2146
2244
|
// THE PLATFORM'S OWN SENDING CREDENTIALS — SendGrid, Twilio, and the internal
|
|
@@ -2773,7 +2871,7 @@ var klaviyo_default2 = {
|
|
|
2773
2871
|
};
|
|
2774
2872
|
|
|
2775
2873
|
// lib/connections/providers/mailchimp.js
|
|
2776
|
-
var
|
|
2874
|
+
var import_node_crypto3 = require("crypto");
|
|
2777
2875
|
|
|
2778
2876
|
// lib/connections/icons/mailchimp.js
|
|
2779
2877
|
var mailchimp_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
@@ -2805,7 +2903,7 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
|
|
|
2805
2903
|
}
|
|
2806
2904
|
return response.json();
|
|
2807
2905
|
};
|
|
2808
|
-
var subscriberHash = (email) => (0,
|
|
2906
|
+
var subscriberHash = (email) => (0, import_node_crypto3.createHash)("md5").update(String(email).trim().toLowerCase()).digest("hex");
|
|
2809
2907
|
var mailchimp_default2 = {
|
|
2810
2908
|
// OAUTH 2, authorization code. Every url below is quoted from
|
|
2811
2909
|
// mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ rather
|
|
@@ -3092,7 +3190,7 @@ var mailchimp_default2 = {
|
|
|
3092
3190
|
};
|
|
3093
3191
|
|
|
3094
3192
|
// lib/connections/providers/shopify.js
|
|
3095
|
-
var
|
|
3193
|
+
var import_node_crypto5 = require("crypto");
|
|
3096
3194
|
var import_nanoid2 = require("nanoid");
|
|
3097
3195
|
|
|
3098
3196
|
// lib/connections/icons/shopify.js
|
|
@@ -3103,7 +3201,7 @@ var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
|
|
|
3103
3201
|
</svg>`;
|
|
3104
3202
|
|
|
3105
3203
|
// lib/connections/inbound.js
|
|
3106
|
-
var
|
|
3204
|
+
var import_node_crypto4 = require("crypto");
|
|
3107
3205
|
var verifySignature = ({ body, descriptor, headers, secret }) => {
|
|
3108
3206
|
if (!secret) {
|
|
3109
3207
|
throw Object.assign(new Error("Missing webhook secret: " + descriptor.signature.secret), { status: 500 });
|
|
@@ -3112,10 +3210,10 @@ var verifySignature = ({ body, descriptor, headers, secret }) => {
|
|
|
3112
3210
|
if (!provided) {
|
|
3113
3211
|
throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
|
|
3114
3212
|
}
|
|
3115
|
-
const digest = (0,
|
|
3213
|
+
const digest = (0, import_node_crypto4.createHmac)(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
|
|
3116
3214
|
const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
|
|
3117
3215
|
const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
|
|
3118
|
-
if (digestBuffer.length !== providedBuffer.length || !(0,
|
|
3216
|
+
if (digestBuffer.length !== providedBuffer.length || !(0, import_node_crypto4.timingSafeEqual)(digestBuffer, providedBuffer)) {
|
|
3119
3217
|
throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
|
|
3120
3218
|
}
|
|
3121
3219
|
return JSON.parse(body.toString());
|
|
@@ -3854,7 +3952,7 @@ var shopify_default2 = {
|
|
|
3854
3952
|
event: "shopify.register.webhooks"
|
|
3855
3953
|
},
|
|
3856
3954
|
name: "register",
|
|
3857
|
-
options: { jobId: "connection.update.register." + workflow.connection + "." + (0,
|
|
3955
|
+
options: { jobId: "connection.update.register." + workflow.connection + "." + (0, import_node_crypto5.randomUUID)() },
|
|
3858
3956
|
queue: "connection"
|
|
3859
3957
|
}],
|
|
3860
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.",
|
|
@@ -4141,7 +4239,7 @@ var shopify_default2 = {
|
|
|
4141
4239
|
};
|
|
4142
4240
|
|
|
4143
4241
|
// lib/connections/providers/webhook.js
|
|
4144
|
-
var
|
|
4242
|
+
var import_node_crypto6 = __toESM(require("crypto"), 1);
|
|
4145
4243
|
|
|
4146
4244
|
// lib/safe-http.js
|
|
4147
4245
|
var import_dns2 = __toESM(require("dns"), 1);
|
|
@@ -4373,7 +4471,7 @@ var webhook_default = {
|
|
|
4373
4471
|
request2.body = body;
|
|
4374
4472
|
const outgoing = { ...headers };
|
|
4375
4473
|
if (settings == null ? void 0 : settings.secret) {
|
|
4376
|
-
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");
|
|
4377
4475
|
}
|
|
4378
4476
|
const response = await send2({ body, headers: outgoing, method, url });
|
|
4379
4477
|
return { message: "Webhook POSTed to " + url + ".", request: request2, response: response || { delivered: true } };
|
|
@@ -4447,7 +4545,7 @@ var leaves = (node, path = []) => Object.entries(node || {}).flatMap(
|
|
|
4447
4545
|
([key, value]) => typeof value === "function" ? [[[...path, key].join("."), value]] : value && typeof value === "object" ? leaves(value, [...path, key]) : []
|
|
4448
4546
|
);
|
|
4449
4547
|
var build = (manifest) => {
|
|
4450
|
-
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;
|
|
4451
4549
|
if (!(manifest == null ? void 0 : manifest.slug)) throw new Error("A connection needs a slug");
|
|
4452
4550
|
if (!(manifest == null ? void 0 : manifest.title)) throw new Error(manifest.slug + " needs a title");
|
|
4453
4551
|
if (!(manifest == null ? void 0 : manifest.private) && !(manifest == null ? void 0 : manifest.feature)) throw new Error(manifest.slug + " needs a plan feature key");
|
|
@@ -4526,16 +4624,16 @@ var build = (manifest) => {
|
|
|
4526
4624
|
);
|
|
4527
4625
|
}
|
|
4528
4626
|
}
|
|
4529
|
-
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)) {
|
|
4530
4628
|
throw new Error(manifest.slug + " implements inbound.event but declares no inbound.headers.event");
|
|
4531
4629
|
}
|
|
4532
|
-
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)) {
|
|
4533
4631
|
throw new Error(manifest.slug + " implements inbound.verify but declares no inbound.headers.signature");
|
|
4534
4632
|
}
|
|
4535
4633
|
if (typeof (manifest == null ? void 0 : manifest.status) !== "function") {
|
|
4536
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");
|
|
4537
4635
|
}
|
|
4538
|
-
if (!Array.isArray((
|
|
4636
|
+
if (!Array.isArray((_q = manifest == null ? void 0 : manifest.content) == null ? void 0 : _q.guide) || !manifest.content.guide.length) {
|
|
4539
4637
|
throw new Error(manifest.slug + " needs content.guide \u2014 an array of steps for its page");
|
|
4540
4638
|
}
|
|
4541
4639
|
if (typeof (manifest == null ? void 0 : manifest.status) !== "function") {
|
|
@@ -4547,8 +4645,8 @@ var build = (manifest) => {
|
|
|
4547
4645
|
}
|
|
4548
4646
|
for (const [domain, verbs] of Object.entries(HOOKS)) {
|
|
4549
4647
|
for (const verb of verbs) {
|
|
4550
|
-
const hook = (
|
|
4551
|
-
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;
|
|
4552
4650
|
if (hook !== false && !implemented({ [domain]: { [verb]: hook } }, domain + "." + verb)) {
|
|
4553
4651
|
throw new Error(manifest.slug + " must answer hooks." + domain + "." + verb + " \u2014 false, a function, or {} if another repo implements it");
|
|
4554
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
|
|
|
@@ -1577,6 +1579,14 @@ var icon$3 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
|
|
|
1577
1579
|
</defs>
|
|
1578
1580
|
</svg>`;
|
|
1579
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
|
+
|
|
1580
1590
|
// Drawbridge itself — the PRIVATE connection.
|
|
1581
1591
|
//
|
|
1582
1592
|
// Nobody connects this. There is no credential, no consent, and no card on the
|
|
@@ -1931,7 +1941,113 @@ var drawbridge = {
|
|
|
1931
1941
|
}
|
|
1932
1942
|
|
|
1933
1943
|
},
|
|
1934
|
-
|
|
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
|
+
},
|
|
1935
2051
|
lifecycle : false,
|
|
1936
2052
|
resources : {
|
|
1937
2053
|
audiences : false,
|
|
@@ -2200,6 +2316,19 @@ var drawbridge = {
|
|
|
2200
2316
|
webhook : false
|
|
2201
2317
|
},
|
|
2202
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
|
+
},
|
|
2203
2332
|
// PRIVATE: never in the catalog, always available to the builder.
|
|
2204
2333
|
private : true,
|
|
2205
2334
|
// THE PLATFORM'S OWN SENDING CREDENTIALS — SendGrid, Twilio, and the internal
|
|
@@ -5332,7 +5461,17 @@ const build = ( manifest ) => {
|
|
|
5332
5461
|
// A vendor that receives from the outside must say where it puts the event
|
|
5333
5462
|
// name. Without it the receiver has nothing to dispatch on, and the failure
|
|
5334
5463
|
// is a request accepted and dropped rather than an error.
|
|
5335
|
-
|
|
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 ){
|
|
5336
5475
|
|
|
5337
5476
|
throw new Error( manifest.slug + ' implements inbound.event but declares no inbound.headers.event' );
|
|
5338
5477
|
|