@drawbridge/drawbridge-utils 0.0.125 → 0.0.127

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.
@@ -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 (write.operation === "update" && !write.query) {
316
- throw new Error("A described update on " + write.collection + " has no query \u2014 that is every document in it");
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 ({
@@ -1101,14 +1104,14 @@ var contacts = {
1101
1104
 
1102
1105
  // lib/connections/icons/drawbridge.js
1103
1106
  var drawbridge_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
1104
- <rect width="500" height="500" fill="#BAEC5F"/>
1107
+ <rect width="500" height="500" fill="#0D1314"/>
1105
1108
  <g clip-path="url(#clip0_2115_2832)">
1106
- <path d="M140.224 127.586L174.803 188.73V311.176L140 372.32L176.084 392.031L216.111 321.753V178.278L176.341 108L140.224 127.586Z" fill="#0D1314"/>
1107
- <path d="M360.002 127.523L323.694 108.282L284.949 178.498V321.596L322.924 391.749L359.394 372.79L326.225 311.52V188.73L360.002 127.523Z" fill="#0D1314"/>
1109
+ <path d="M153.198 142.241L183.69 196.065V303.852L153 357.676L184.82 375.028L220.116 313.163V186.865L185.046 125L153.198 142.241Z" fill="#BAEC5F"/>
1110
+ <path d="M347.002 142.186L314.984 125.249L280.818 187.058V313.025L314.306 374.779L346.465 358.091L317.216 304.156V196.066L347.002 142.186Z" fill="#BAEC5F"/>
1108
1111
  </g>
1109
1112
  <defs>
1110
1113
  <clipPath id="clip0_2115_2832">
1111
- <rect width="220" height="284" fill="white" transform="translate(140 108)"/>
1114
+ <rect width="194" height="250" fill="white" transform="translate(153 125)"/>
1112
1115
  </clipPath>
1113
1116
  </defs>
1114
1117
  </svg>`;
@@ -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
- inbound: false,
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 import_node_crypto2 = require("crypto");
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, import_node_crypto2.createHash)("md5").update(String(email).trim().toLowerCase()).digest("hex");
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 import_node_crypto4 = require("crypto");
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 import_node_crypto3 = require("crypto");
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, import_node_crypto3.createHmac)(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
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, import_node_crypto3.timingSafeEqual)(digestBuffer, providedBuffer)) {
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, import_node_crypto4.randomUUID)() },
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 import_node_crypto5 = __toESM(require("crypto"), 1);
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=" + import_node_crypto5.default.createHmac("sha256", settings.secret).update(JSON.stringify(body)).digest("hex");
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") && !((_l = (_k = manifest.inbound) == null ? void 0 : _k.headers) == null ? void 0 : _l.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") && !((_n = (_m = manifest.inbound) == null ? void 0 : _m.headers) == null ? void 0 : _n.signature)) {
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((_o = manifest == null ? void 0 : manifest.content) == null ? void 0 : _o.guide) || !manifest.content.guide.length) {
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 = (_q = (_p = manifest.hooks) == null ? void 0 : _p[domain]) == null ? void 0 : _q[verb];
4551
- if (((_r = manifest.hooks) == null ? void 0 : _r[domain]) === false) continue;
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
- if( ! write?.data ) throw new Error( 'A described write on ' + write.collection + ' carries no data' );
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. Refused here
420
- // rather than survived, because the controller would happily run it.
421
- if( write.operation === 'update' && ! write.query ){
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 update on ' + write.collection + ' has no query — that is every document in it' );
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
 
@@ -1565,18 +1567,26 @@ const contacts = {
1565
1567
  // lib/ directly. A bare .svg import would need a bundler loader and force the
1566
1568
  // tests onto dist/, which is a worse trade than one line of wrapper.
1567
1569
  var icon$3 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
1568
- <rect width="500" height="500" fill="#BAEC5F"/>
1570
+ <rect width="500" height="500" fill="#0D1314"/>
1569
1571
  <g clip-path="url(#clip0_2115_2832)">
1570
- <path d="M140.224 127.586L174.803 188.73V311.176L140 372.32L176.084 392.031L216.111 321.753V178.278L176.341 108L140.224 127.586Z" fill="#0D1314"/>
1571
- <path d="M360.002 127.523L323.694 108.282L284.949 178.498V321.596L322.924 391.749L359.394 372.79L326.225 311.52V188.73L360.002 127.523Z" fill="#0D1314"/>
1572
+ <path d="M153.198 142.241L183.69 196.065V303.852L153 357.676L184.82 375.028L220.116 313.163V186.865L185.046 125L153.198 142.241Z" fill="#BAEC5F"/>
1573
+ <path d="M347.002 142.186L314.984 125.249L280.818 187.058V313.025L314.306 374.779L346.465 358.091L317.216 304.156V196.066L347.002 142.186Z" fill="#BAEC5F"/>
1572
1574
  </g>
1573
1575
  <defs>
1574
1576
  <clipPath id="clip0_2115_2832">
1575
- <rect width="220" height="284" fill="white" transform="translate(140 108)"/>
1577
+ <rect width="194" height="250" fill="white" transform="translate(153 125)"/>
1576
1578
  </clipPath>
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
- inbound : false,
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
- if( implemented( manifest.hooks, 'inbound.event' ) && ! manifest.inbound?.headers?.event ){
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