@drawbridge/drawbridge-utils 0.0.157 → 0.0.159

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.
@@ -1,7 +1,7 @@
1
1
  import { authToken } from './oauth.js';
2
2
  export { consentUrl, pkcePair } from './oauth.js';
3
3
  import { toE164, detectCountry } from '../phone.js';
4
- import crypto, { createHmac, timingSafeEqual, createHash, randomUUID } from 'node:crypto';
4
+ import crypto, { createHmac, timingSafeEqual, createVerify, createHash, randomUUID } from 'node:crypto';
5
5
  import { request } from '../http.js';
6
6
  import { channels } from '../pricing.js';
7
7
  import { customAlphabet } from 'nanoid';
@@ -1273,6 +1273,144 @@ var attentive = {
1273
1273
  title : 'Attentive'
1274
1274
  };
1275
1275
 
1276
+ // THE SHARED HALF OF inbound.verify.
1277
+ //
1278
+ // A vendor whose scheme is "hash the raw body with a shared secret and compare,
1279
+ // constant-time, against a header" declares that shape as `inbound.signature`
1280
+ // data on its manifest (algorithm, encoding, the NAME of the credential) and
1281
+ // wires this straight in as its hook — Shopify does exactly that.
1282
+ //
1283
+ // A vendor whose scheme is not that shape — Twilio signs the REGISTERED URL
1284
+ // rather than the body, a JWT-bodied vendor verifies against a JWKS — writes its
1285
+ // own inbound.verify instead. That is why verify is a hook and not config: this
1286
+ // file covers the common case, not the contract.
1287
+ const verifySignature = ({ body, descriptor, headers, secret }) => {
1288
+
1289
+ // THE MANIFEST STILL DECLARES THE NAME, the caller supplies the value.
1290
+ // descriptor.signature.secret is 'SHOPIFY_API_SECRET' and stays that way —
1291
+ // naming which credential a vendor needs is the manifest's job. Reading it is
1292
+ // not: the value lives encrypted in the `provider` collection, and a package
1293
+ // that reached into process.env for it would force every service to copy the
1294
+ // collection back into its environment at boot before this could work.
1295
+ //
1296
+ // A 500 rather than the 401 below, because a secret we never loaded is our
1297
+ // misconfiguration and must not be indistinguishable in the logs from the
1298
+ // forged request that gets the same door slammed on it.
1299
+ if( ! secret ){
1300
+
1301
+ throw Object.assign( new Error( 'Missing webhook secret: ' + descriptor.signature.secret ), { status : 500 });
1302
+
1303
+ }
1304
+
1305
+ const provided = headers[ descriptor.headers.signature ];
1306
+
1307
+ if( ! provided ){
1308
+
1309
+ throw Object.assign( new Error( 'Missing webhook signature' ), { status : 401 });
1310
+
1311
+ }
1312
+
1313
+ const digest = createHmac( descriptor.signature.algorithm, secret )
1314
+ .update( body )
1315
+ .digest( descriptor.signature.encoding );
1316
+
1317
+ // Buffers of different lengths crash timingSafeEqual rather than compare
1318
+ // false — decided here, before the length itself becomes a timing signal.
1319
+ const digestBuffer = Buffer.from( digest, descriptor.signature.encoding );
1320
+ const providedBuffer = Buffer.from( provided, descriptor.signature.encoding );
1321
+
1322
+ if(
1323
+ digestBuffer.length !== providedBuffer.length ||
1324
+ ! timingSafeEqual( digestBuffer, providedBuffer )
1325
+ ){
1326
+
1327
+ throw Object.assign( new Error( 'Invalid webhook signature' ), { status : 401 });
1328
+
1329
+ }
1330
+
1331
+ // The proof and the parse happen together on purpose. Nothing downstream of
1332
+ // inbound.verify ever sees the raw bytes, which is what makes acting on
1333
+ // unverified data impossible rather than merely discouraged.
1334
+ return JSON.parse( body.toString() );
1335
+
1336
+ };
1337
+
1338
+ // How far out of date a signed payload may be. SendGrid hands us a timestamp
1339
+ // precisely so a captured delivery cannot be replayed forever, so we spend it.
1340
+ // Shopify gets no equivalent check because Shopify sends no timestamp — the
1341
+ // difference is what each vendor offers, not a different standard.
1342
+ const REPLAY_TOLERANCE_SECONDS = 5 * 60;
1343
+
1344
+ // Wrap SendGrid's base64 DER (SPKI) verification key as PEM, which is the only
1345
+ // form node's verifier takes. The console shows it as one unbroken base64 line;
1346
+ // PEM wants it in 64-character rows.
1347
+ const asPem = ( key ) => [
1348
+ '-----BEGIN PUBLIC KEY-----',
1349
+ ...( key.match( /.{1,64}/g ) || [] ),
1350
+ '-----END PUBLIC KEY-----'
1351
+ ].join( '\n' );
1352
+
1353
+ // THE OTHER SHARED SCHEME: ECDSA over timestamp + raw body, against a PUBLIC
1354
+ // key rather than a shared secret. SendGrid signs this way, and the asymmetry
1355
+ // matters — `secret` here is the vendor's public verification key, so it proves
1356
+ // the sender without ever being able to impersonate them.
1357
+ //
1358
+ // The raw bytes are load-bearing. SendGrid's own documentation is explicit that
1359
+ // the payload must be verified "in its raw bytes form" because a JSON round-trip
1360
+ // can drop characters that were signed. That is why the route hands verify a
1361
+ // Buffer and parses only after the proof, exactly as the HMAC path does.
1362
+ const verifyEcdsa = ({ body, descriptor, headers, secret }) => {
1363
+
1364
+ // Same reasoning as verifySignature: a key we never loaded is our
1365
+ // misconfiguration, and a 500 keeps it out of the forged-request logs.
1366
+ if( ! secret ){
1367
+
1368
+ throw Object.assign( new Error( 'Missing webhook key: ' + descriptor.signature.secret ), { status : 500 });
1369
+
1370
+ }
1371
+
1372
+ const provided = headers[ descriptor.headers.signature ];
1373
+ const timestamp = headers[ descriptor.headers.timestamp ];
1374
+
1375
+ if( ! provided || ! timestamp ){
1376
+
1377
+ throw Object.assign( new Error( 'Missing webhook signature' ), { status : 401 });
1378
+
1379
+ }
1380
+
1381
+ const age = Math.abs( Math.floor( Date.now() / 1000 ) - Number( timestamp ) );
1382
+
1383
+ // NaN fails this too, which is the point — an unparseable timestamp is not a
1384
+ // fresh one.
1385
+ if( ! ( age <= REPLAY_TOLERANCE_SECONDS ) ){
1386
+
1387
+ throw Object.assign( new Error( 'Stale webhook signature' ), { status : 401 });
1388
+
1389
+ }
1390
+
1391
+ // CONCATENATED AS BYTES, not as strings. `timestamp + body.toString()` would
1392
+ // decode the body as utf8 first, and any byte sequence that is not valid utf8
1393
+ // comes back as a replacement character — signed bytes silently replaced by
1394
+ // different bytes, and a valid delivery fails verification.
1395
+ const payload = Buffer.concat([ Buffer.from( String( timestamp ), 'utf8' ), body ]);
1396
+
1397
+ const verified = createVerify( 'sha256' )
1398
+ .update( payload )
1399
+ .verify( asPem( secret ), Buffer.from( provided, 'base64' ) );
1400
+
1401
+ if( ! verified ){
1402
+
1403
+ throw Object.assign( new Error( 'Invalid webhook signature' ), { status : 401 });
1404
+
1405
+ }
1406
+
1407
+ return JSON.parse( body.toString() );
1408
+
1409
+ };
1410
+
1411
+ // THE SHARED HALF OF inbound.event, for a vendor that puts it in a header.
1412
+ const readEventHeader = ({ descriptor, headers }) => headers[ descriptor.headers.event ];
1413
+
1276
1414
  // DRAWBRIDGE'S OWN HUBSPOT PORTAL, kept in step with account signups so the
1277
1415
  // campaign a customer arrived on is on their contact record.
1278
1416
  //
@@ -1668,6 +1806,230 @@ var twilioIcon = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none
1668
1806
  const STOP_KEYWORDS = [ 'STOP', 'STOPALL', 'UNSUBSCRIBE', 'CANCEL', 'END', 'QUIT' ];
1669
1807
  const START_KEYWORDS = [ 'START', 'UNSTOP', 'YES' ];
1670
1808
 
1809
+ // WHERE SENDGRID PUTS THINGS on an event delivery. Its own descriptor because
1810
+ // the scheme has nothing in common with Twilio's: ECDSA over timestamp + raw
1811
+ // body against a public key, versus HMAC over the registered url.
1812
+ const sendgridInbound = {
1813
+ headers : {
1814
+ signature : 'x-twilio-email-event-webhook-signature',
1815
+ timestamp : 'x-twilio-email-event-webhook-timestamp'
1816
+ },
1817
+ signature : {
1818
+ secret : 'SENDGRID_EVENT_WEBHOOK_KEY'
1819
+ }
1820
+ };
1821
+
1822
+ // The events that mean a message did not arrive. Only these carry a reason worth
1823
+ // reading — a delivered event's `response` is a 250 nobody needs.
1824
+ const REFUSALS = new Set([ 'blocked', 'bounce', 'deferred', 'dropped', 'spamreport' ]);
1825
+
1826
+ // ONE VOCABULARY FOR EVERY CHANNEL, and the rank is what makes it safe to write.
1827
+ //
1828
+ // Delivery events are not ordered. SendGrid can deliver a `deferred` after a
1829
+ // `delivered`, retries duplicate, and an asynchronous bounce can arrive days
1830
+ // later. A plain $set of the newest event lets a stale one overwrite a final
1831
+ // one, so the rank rides IN the document and the write guards on it — the
1832
+ // comparison happens in the query, atomically, instead of in a read-then-write
1833
+ // that two workers can interleave.
1834
+ //
1835
+ // TERMINAL-BAD OUTRANKS DELIVERED on purpose. A bounce or a spam complaint
1836
+ // arriving after delivery is the more informative fact and exactly the
1837
+ // transition worth seeing; nothing else may move the status backwards.
1838
+ const DELIVERY = {
1839
+ queued : 10,
1840
+ sent : 20,
1841
+ deferred : 30,
1842
+ blocked : 40,
1843
+ delivered : 50,
1844
+ bounced : 60,
1845
+ complained : 70
1846
+ };
1847
+
1848
+ // SendGrid's event names, and the one case where the event name is not enough:
1849
+ // a `bounce` is permanent or temporary depending on `type`, same event either
1850
+ // way. Getting that wrong means treating a full mailbox like a dead address.
1851
+ const fromSendgrid = ( event ) => {
1852
+
1853
+ if( event?.event === 'bounce' ) return event.type === 'blocked' ? 'blocked' : 'bounced';
1854
+
1855
+ return ({
1856
+ blocked : 'blocked',
1857
+ deferred : 'deferred',
1858
+ delivered : 'delivered',
1859
+ dropped : 'bounced',
1860
+ processed : 'queued',
1861
+ spamreport : 'complained'
1862
+ })[ event?.event ] || null;
1863
+
1864
+ };
1865
+
1866
+ // Twilio's MessageStatus. `failed` never left Twilio, `undelivered` left and the
1867
+ // carrier refused it — both are dead ends for this address, so both land on
1868
+ // bounced rather than inventing a status nothing else can produce.
1869
+ const fromTwilio = ( status ) => ({
1870
+ delivered : 'delivered',
1871
+ failed : 'bounced',
1872
+ queued : 'queued',
1873
+ sending : 'sent',
1874
+ sent : 'sent',
1875
+ undelivered : 'bounced'
1876
+ })[ String( status || '' ).toLowerCase() ] || null;
1877
+
1878
+ // THE WRITE ITSELF, identical for both channels because only `status` had to be
1879
+ // normalised. `code` and `reason` stay the vendor's own words — 5.0.0 and "500
1880
+ // unknown recipient" from SendGrid, 30003 and "Unreachable destination handset"
1881
+ // from Twilio — normalised in position, never in content.
1882
+ //
1883
+ // Adding a third vendor adds a mapper and a slug. It changes neither this
1884
+ // function, nor the shape, nor anything that reads it.
1885
+ const deliveryWrite = ({ at, code, notification, permanent, provider, reason, status }) => {
1886
+
1887
+ const rank = DELIVERY[ status ];
1888
+
1889
+ if( ! notification || ! rank ) return null;
1890
+
1891
+ return {
1892
+ collection : 'notification',
1893
+ data : {
1894
+ $set : {
1895
+ 'meta.delivery' : {
1896
+ at : at || new Date(),
1897
+ ...( code !== undefined && code !== null && { code : String( code ) }),
1898
+ ...( permanent !== undefined && { permanent }),
1899
+ provider,
1900
+ rank,
1901
+ ...( reason && { reason : String( reason ) }),
1902
+ status
1903
+ }
1904
+ }
1905
+ },
1906
+ operation : 'update',
1907
+ query : {
1908
+ id : notification,
1909
+ // NEVER an upsert. A callback naming a notification we do not have is a
1910
+ // vendor talking about someone else's message, not a document to create.
1911
+ $or : [
1912
+ { 'meta.delivery.rank' : { $exists : false } },
1913
+ { 'meta.delivery.rank' : { $lt : rank } }
1914
+ ]
1915
+ }
1916
+ };
1917
+
1918
+ };
1919
+
1920
+ // SendGrid batches events, so this summarises the lot into one log line AND
1921
+ // describes a write per event that names a notification. The log line is what
1922
+ // makes a deliverability question answerable at a glance; the writes are what
1923
+ // make it answerable in the admin three weeks later.
1924
+ //
1925
+ // It still writes no suppression. Turning a bounce or a spam report into a
1926
+ // consent decision is a product call, not a side effect to smuggle in with the
1927
+ // plumbing.
1928
+ //
1929
+ // `custom_args` are absent on an asynchronous bounce (SendGrid documents this),
1930
+ // so some real bounces name no notification and can only be counted, never
1931
+ // attributed. That is why an empty meta.delivery must never render as delivered.
1932
+ const summariseEvents = ( events ) => {
1933
+
1934
+ const list = Array.isArray( events ) ? events : [];
1935
+
1936
+ if( ! list.length ) return { message : 'SendGrid delivered an empty event batch.', skipped : true };
1937
+
1938
+ const counts = {};
1939
+
1940
+ for( const { event } of list ){
1941
+
1942
+ const name = event || 'unknown';
1943
+
1944
+ counts[ name ] = ( counts[ name ] || 0 ) + 1;
1945
+
1946
+ }
1947
+
1948
+ const tally = Object.entries( counts ).map( ( [ name, count ] ) => count + ' ' + name ).join( ', ' );
1949
+
1950
+ // Capped: a batch can carry hundreds, and a log line that carries all of them
1951
+ // is one nobody reads.
1952
+ const refused = list
1953
+ .filter( ( { event } ) => REFUSALS.has( event ) )
1954
+ .slice( 0, 5 )
1955
+ .map( ( { email, reason, response, status } ) => email + ' — ' + ( reason || response || status || 'no reason given' ) );
1956
+
1957
+ const writes = list
1958
+ .map( ( event ) => deliveryWrite({
1959
+ at : event.timestamp ? new Date( event.timestamp * 1000 ) : undefined,
1960
+ code : event.status,
1961
+ // Read BOTH shapes. SendGrid echoes custom_args as siblings of `email`
1962
+ // and `event`, which is why it warns about colliding with reserved
1963
+ // names — but this is the one thing in the design that cannot be proved
1964
+ // until a real event arrives, and reading the nested form too costs a
1965
+ // token and removes the only silent failure left here.
1966
+ notification : event.notification || event.custom_args?.notification,
1967
+ // Only a bounce carries the distinction, and only a real one is
1968
+ // permanent — `blocked` is this attempt refused, not this address dead.
1969
+ ...( event.event === 'bounce' && { permanent : event.type !== 'blocked' }),
1970
+ provider : { slug : 'sendgrid', id : event.sg_message_id || null },
1971
+ reason : event.reason || event.response,
1972
+ status : fromSendgrid( event )
1973
+ }) )
1974
+ .filter( Boolean );
1975
+
1976
+ return {
1977
+ message : [ list.length + ' SendGrid event(s): ' + tally, ...refused ].join( ' | ' ),
1978
+ ...( writes.length ? { writes } : { skipped : true })
1979
+ };
1980
+
1981
+ };
1982
+
1983
+ // Twilio posts ONE status per callback, form-encoded, where SendGrid posts a JSON
1984
+ // array. Normalising here is what lets both channels land in the same field.
1985
+ //
1986
+ // The notification is found by MessageSid rather than by an echoed id: Twilio's
1987
+ // create call returns the sid synchronously, so the send stamps it and the
1988
+ // callback joins on it. No query-string trick, and nothing depending on whether
1989
+ // Twilio preserves custom params through its signature.
1990
+ const statusCallback = ( data ) => {
1991
+
1992
+ const status = fromTwilio( data?.MessageStatus || data?.SmsStatus );
1993
+
1994
+ if( ! status ) return { message : 'Twilio sent a status this does not map: ' + ( data?.MessageStatus || 'none' ), skipped : true };
1995
+
1996
+ const rank = DELIVERY[ status ];
1997
+
1998
+ const write = {
1999
+ collection : 'notification',
2000
+ data : {
2001
+ $set : {
2002
+ 'meta.delivery' : {
2003
+ at : new Date(),
2004
+ ...( data?.ErrorCode && { code : String( data.ErrorCode ) }),
2005
+ // `failed` never left Twilio and `undelivered` was refused by the
2006
+ // carrier. Both are dead ends for this handset, so neither is
2007
+ // reported as a temporary condition.
2008
+ ...( status === 'bounced' && { permanent : true }),
2009
+ provider : { slug : 'twilio', id : data?.MessageSid || null },
2010
+ rank,
2011
+ ...( data?.ErrorMessage && { reason : String( data.ErrorMessage ) }),
2012
+ status
2013
+ }
2014
+ }
2015
+ },
2016
+ operation : 'update',
2017
+ query : {
2018
+ 'meta.delivery.provider.id' : data?.MessageSid,
2019
+ $or : [
2020
+ { 'meta.delivery.rank' : { $exists : false } },
2021
+ { 'meta.delivery.rank' : { $lt : rank } }
2022
+ ]
2023
+ }
2024
+ };
2025
+
2026
+ return {
2027
+ message : 'Twilio ' + status + ' for ' + ( data?.MessageSid || 'an unnamed message' ) + ( data?.ErrorCode ? ' (' + data.ErrorCode + ')' : '' ),
2028
+ ...( data?.MessageSid ? { writes : [ write ] } : { skipped : true })
2029
+ };
2030
+
2031
+ };
2032
+
1671
2033
  // Drawbridge itself — the PRIVATE connection.
1672
2034
  //
1673
2035
  // Nobody connects this. There is no credential, no consent, and no card on the
@@ -2075,9 +2437,16 @@ var drawbridge = {
2075
2437
  // sync's buffer table are gone.
2076
2438
  inbound : {
2077
2439
 
2078
- // EVERY POST ON THIS CHANNEL IS AN INBOUND MESSAGE. Twilio sends no
2079
- // topic header the registered url IS the topic.
2080
- event : () => 'message.inbound',
2440
+ // THE REGISTERED URL IS THE TOPIC. Neither vendor sends a topic header,
2441
+ // so the channel it arrived on names the event — `sms` is one inbound
2442
+ // message, `email` is a batch of SendGrid delivery events.
2443
+ //
2444
+ // Defaulted because the drain calls `event()` with nothing when it only
2445
+ // wants the SMS name.
2446
+ event : ({ channel } = {}) => ({
2447
+ email : 'email.events',
2448
+ 'sms-status' : 'sms.status'
2449
+ })[ channel ] || 'message.inbound',
2081
2450
 
2082
2451
  // STOP AND START, AS DESCRIBED WRITES. A withdrawn number is withdrawn
2083
2452
  // for everyone — organization : null is the platform floor canSend()
@@ -2086,6 +2455,19 @@ var drawbridge = {
2086
2455
  // organization } index instead of erroring.
2087
2456
  process : ({ context }) => {
2088
2457
 
2458
+ // BRANCHES ON TOPIC, NOT CHANNEL. The drain reads a buffer row, which
2459
+ // carries the type this manifest named at receive time and no memory
2460
+ // of the url it arrived on — `channel` simply is not in scope here.
2461
+ //
2462
+ // Not optional: the drain hands EVERY drawbridge buffer row to this
2463
+ // hook, so without the branch a SendGrid batch falls into the Twilio
2464
+ // path below, finds no `Body`, and logs itself as an inbound SMS that
2465
+ // carried no usable sender number.
2466
+ // `.events` because receive wraps the batch — schema/buffer.js types
2467
+ // `data` as an object and SendGrid posts an array.
2468
+ if( context?.topic === 'email.events' ) return summariseEvents( context?.data?.events );
2469
+ if( context?.topic === 'sms.status' ) return statusCallback( context?.data );
2470
+
2089
2471
  const keyword = String( context?.data?.Body || '' ).trim().toUpperCase();
2090
2472
  const address = toE164( context?.data?.From );
2091
2473
 
@@ -2141,9 +2523,24 @@ var drawbridge = {
2141
2523
 
2142
2524
  },
2143
2525
 
2144
- receive : ({ payload }) => ({
2145
- data : payload,
2146
- provider : { id : payload?.MessageSid || null }
2526
+ // A SendGrid batch carries many sg_message_ids and no single identity, so
2527
+ // it names no provider id. That means no dedupe key for a redelivery —
2528
+ // acceptable because SendGrid only retries a non-2xx, and the cost of a
2529
+ // duplicate here is a second buffer row rather than a repeated side
2530
+ // effect. Revisit if this ever writes suppressions.
2531
+ //
2532
+ // THE BATCH IS WRAPPED, and it has to be. SendGrid posts a JSON ARRAY
2533
+ // where every other vendor posts an object, and drawbridge-api's
2534
+ // schema/buffer.js declares `data : { bsonType : 'object' }` — which in
2535
+ // JSON Schema does NOT match an array. Writing the array raw fails
2536
+ // validation, answers 500, and puts SendGrid into a retry loop for a
2537
+ // webhook we would never accept.
2538
+ //
2539
+ // Wrapped here rather than widening the schema: `data` stays one shape
2540
+ // for every consumer, and the vendor that is odd carries the oddity.
2541
+ receive : ({ channel, payload }) => ({
2542
+ data : channel === 'email' ? { events : payload } : payload,
2543
+ provider : { id : channel === 'email' ? null : payload?.MessageSid || null }
2147
2544
  }),
2148
2545
 
2149
2546
  // TWILIO'S SCHEME, from docs.twilio.com/usage/security: take the full
@@ -2153,7 +2550,12 @@ var drawbridge = {
2153
2550
  // X-Twilio-Signature. It signs the URL rather than the raw body, which
2154
2551
  // is exactly why the shared body-HMAC verifier cannot cover it and this
2155
2552
  // hook exists.
2156
- verify : ({ body, headers, secret, url }) => {
2553
+ verify : ({ body, channel, headers, secret, url }) => {
2554
+
2555
+ // SendGrid signs timestamp + raw body with ECDSA, against a public
2556
+ // key. Nothing in Twilio's scheme below applies, which is why the
2557
+ // channel decides here rather than the manifest declaring one shape.
2558
+ if( channel === 'email' ) return verifyEcdsa({ body, descriptor : sendgridInbound, headers, secret });
2157
2559
 
2158
2560
  // OUR misconfiguration, never a forgery — a missing credential must
2159
2561
  // not be answered 401.
@@ -2446,15 +2848,30 @@ var drawbridge = {
2446
2848
  icon: icon$3,
2447
2849
 
2448
2850
  // WHERE TWILIO PUTS THINGS on an inbound request, and WHICH credential
2449
- // verifies it. `secret` names the drawbridge provider's smsToken the route
2450
- // resolves the name to the stored value and hands it to verify.
2851
+ // verifies each channel. The route resolves the NAME to the stored value and
2852
+ // hands it to verify; this manifest never reads a store.
2853
+ //
2854
+ // `signature.channels` because this is the one manifest that receives from two
2855
+ // different vendors — Twilio on `sms`, SendGrid on `email` — which need
2856
+ // different credentials and share no scheme. A manifest whose channels all
2857
+ // verify the same way keeps declaring `signature` flat, and the route falls
2858
+ // back to it; Shopify's two channels do exactly that.
2451
2859
  inbound : {
2452
2860
  headers : {
2453
2861
  id : 'i-twilio-idempotency-token',
2454
2862
  signature : 'x-twilio-signature'
2455
2863
  },
2456
2864
  signature : {
2457
- secret : 'TWILIO_AUTH_TOKEN'
2865
+ channels : {
2866
+ email : sendgridInbound.signature,
2867
+ sms : { secret : 'TWILIO_AUTH_TOKEN' },
2868
+ // Outbound delivery receipts, which Twilio signs exactly as it signs
2869
+ // an inbound message — same scheme, same credential, different url.
2870
+ // Separate channel because the payload is a MessageStatus rather than
2871
+ // a message, and conflating them would put a STOP keyword check on a
2872
+ // delivery receipt.
2873
+ 'sms-status' : { secret : 'TWILIO_AUTH_TOKEN' }
2874
+ }
2458
2875
  }
2459
2876
  },
2460
2877
  // PRIVATE: never in the catalog, always available to the builder.
@@ -2486,6 +2903,13 @@ var drawbridge = {
2486
2903
  // Optional: SENDGRID_SEND_FROM_ADDRESS is not boot-required in sync
2487
2904
  // either. Unset, it degrades to the account sender rather than
2488
2905
  // refusing to start.
2906
+ // NOT redacted, because it is a PUBLIC key. Marking it secret would
2907
+ // be theatre, and it would stop an operator reading back the value
2908
+ // they need to compare against the SendGrid console.
2909
+ //
2910
+ // Optional: absent, the event webhook answers 500 on verify rather
2911
+ // than accepting unverified deliveries, and nothing else notices.
2912
+ { input : 'text', key : 'eventKey', credential : 'SENDGRID_EVENT_WEBHOOK_KEY', label : 'Event webhook key', message : 'SendGrid, Settings, Mail Settings, Event Webhook — the verification key shown once signature verification is enabled. Public, not a secret.' },
2489
2913
  { 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.' }
2490
2914
  ],
2491
2915
  icon : sendgridIcon,
@@ -3956,71 +4380,6 @@ var icon = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmln
3956
4380
  <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"/>
3957
4381
  </svg>`;
3958
4382
 
3959
- // THE SHARED HALF OF inbound.verify.
3960
- //
3961
- // A vendor whose scheme is "hash the raw body with a shared secret and compare,
3962
- // constant-time, against a header" declares that shape as `inbound.signature`
3963
- // data on its manifest (algorithm, encoding, the NAME of the credential) and
3964
- // wires this straight in as its hook — Shopify does exactly that.
3965
- //
3966
- // A vendor whose scheme is not that shape — Twilio signs the REGISTERED URL
3967
- // rather than the body, a JWT-bodied vendor verifies against a JWKS — writes its
3968
- // own inbound.verify instead. That is why verify is a hook and not config: this
3969
- // file covers the common case, not the contract.
3970
- const verifySignature = ({ body, descriptor, headers, secret }) => {
3971
-
3972
- // THE MANIFEST STILL DECLARES THE NAME, the caller supplies the value.
3973
- // descriptor.signature.secret is 'SHOPIFY_API_SECRET' and stays that way —
3974
- // naming which credential a vendor needs is the manifest's job. Reading it is
3975
- // not: the value lives encrypted in the `provider` collection, and a package
3976
- // that reached into process.env for it would force every service to copy the
3977
- // collection back into its environment at boot before this could work.
3978
- //
3979
- // A 500 rather than the 401 below, because a secret we never loaded is our
3980
- // misconfiguration and must not be indistinguishable in the logs from the
3981
- // forged request that gets the same door slammed on it.
3982
- if( ! secret ){
3983
-
3984
- throw Object.assign( new Error( 'Missing webhook secret: ' + descriptor.signature.secret ), { status : 500 });
3985
-
3986
- }
3987
-
3988
- const provided = headers[ descriptor.headers.signature ];
3989
-
3990
- if( ! provided ){
3991
-
3992
- throw Object.assign( new Error( 'Missing webhook signature' ), { status : 401 });
3993
-
3994
- }
3995
-
3996
- const digest = createHmac( descriptor.signature.algorithm, secret )
3997
- .update( body )
3998
- .digest( descriptor.signature.encoding );
3999
-
4000
- // Buffers of different lengths crash timingSafeEqual rather than compare
4001
- // false — decided here, before the length itself becomes a timing signal.
4002
- const digestBuffer = Buffer.from( digest, descriptor.signature.encoding );
4003
- const providedBuffer = Buffer.from( provided, descriptor.signature.encoding );
4004
-
4005
- if(
4006
- digestBuffer.length !== providedBuffer.length ||
4007
- ! timingSafeEqual( digestBuffer, providedBuffer )
4008
- ){
4009
-
4010
- throw Object.assign( new Error( 'Invalid webhook signature' ), { status : 401 });
4011
-
4012
- }
4013
-
4014
- // The proof and the parse happen together on purpose. Nothing downstream of
4015
- // inbound.verify ever sees the raw bytes, which is what makes acting on
4016
- // unverified data impossible rather than merely discouraged.
4017
- return JSON.parse( body.toString() );
4018
-
4019
- };
4020
-
4021
- // THE SHARED HALF OF inbound.event, for a vendor that puts it in a header.
4022
- const readEventHeader = ({ descriptor, headers }) => headers[ descriptor.headers.event ];
4023
-
4024
4383
  // ── ORDER ATTRIBUTION, the vendor's own arithmetic ───────────────────────────
4025
4384
  //
4026
4385
  // Orders are attributed via `_drwbrdg_*` line-item properties injected at