@drawbridge/drawbridge-utils 0.0.156 → 0.0.158
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/axios.cjs +5 -0
- package/dist/axios.d.cts +14 -0
- package/dist/axios.d.ts +14 -0
- package/dist/axios.js +5 -0
- package/dist/connections/index.cjs +263 -39
- package/dist/connections/index.d.cts +466 -82
- package/dist/connections/index.d.ts +466 -82
- package/dist/connections/index.js +262 -38
- package/dist/providers.cjs +263 -39
- package/dist/providers.js +262 -38
- package/dist/safe-http.cjs +9 -2
- package/dist/safe-http.d.cts +9 -2
- package/dist/safe-http.d.ts +9 -2
- package/dist/safe-http.js +9 -2
- package/dist/sendgrid.cjs +18 -1
- package/dist/sendgrid.d.cts +24 -1
- package/dist/sendgrid.d.ts +24 -1
- package/dist/sendgrid.js +18 -1
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
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
|
+
import crypto, { createHmac, timingSafeEqual, createVerify, createHash, randomUUID } from 'node:crypto';
|
|
5
5
|
import { request } from '../http.cjs';
|
|
6
6
|
import { channels } from '../pricing.cjs';
|
|
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
|
-
//
|
|
2079
|
-
//
|
|
2080
|
-
|
|
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,17 @@ 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
|
+
if( context?.topic === 'email.events' ) return summariseEvents( context?.data );
|
|
2467
|
+
if( context?.topic === 'sms.status' ) return statusCallback( context?.data );
|
|
2468
|
+
|
|
2089
2469
|
const keyword = String( context?.data?.Body || '' ).trim().toUpperCase();
|
|
2090
2470
|
const address = toE164( context?.data?.From );
|
|
2091
2471
|
|
|
@@ -2141,9 +2521,14 @@ var drawbridge = {
|
|
|
2141
2521
|
|
|
2142
2522
|
},
|
|
2143
2523
|
|
|
2144
|
-
|
|
2524
|
+
// A SendGrid batch carries many sg_message_ids and no single identity, so
|
|
2525
|
+
// it names no provider id. That means no dedupe key for a redelivery —
|
|
2526
|
+
// acceptable because SendGrid only retries a non-2xx, and the cost of a
|
|
2527
|
+
// duplicate here is a second buffer row rather than a repeated side
|
|
2528
|
+
// effect. Revisit if this ever writes suppressions.
|
|
2529
|
+
receive : ({ channel, payload }) => ({
|
|
2145
2530
|
data : payload,
|
|
2146
|
-
provider : { id : payload?.MessageSid || null }
|
|
2531
|
+
provider : { id : channel === 'email' ? null : payload?.MessageSid || null }
|
|
2147
2532
|
}),
|
|
2148
2533
|
|
|
2149
2534
|
// TWILIO'S SCHEME, from docs.twilio.com/usage/security: take the full
|
|
@@ -2153,7 +2538,12 @@ var drawbridge = {
|
|
|
2153
2538
|
// X-Twilio-Signature. It signs the URL rather than the raw body, which
|
|
2154
2539
|
// is exactly why the shared body-HMAC verifier cannot cover it and this
|
|
2155
2540
|
// hook exists.
|
|
2156
|
-
verify : ({ body, headers, secret, url }) => {
|
|
2541
|
+
verify : ({ body, channel, headers, secret, url }) => {
|
|
2542
|
+
|
|
2543
|
+
// SendGrid signs timestamp + raw body with ECDSA, against a public
|
|
2544
|
+
// key. Nothing in Twilio's scheme below applies, which is why the
|
|
2545
|
+
// channel decides here rather than the manifest declaring one shape.
|
|
2546
|
+
if( channel === 'email' ) return verifyEcdsa({ body, descriptor : sendgridInbound, headers, secret });
|
|
2157
2547
|
|
|
2158
2548
|
// OUR misconfiguration, never a forgery — a missing credential must
|
|
2159
2549
|
// not be answered 401.
|
|
@@ -2446,15 +2836,30 @@ var drawbridge = {
|
|
|
2446
2836
|
icon: icon$3,
|
|
2447
2837
|
|
|
2448
2838
|
// WHERE TWILIO PUTS THINGS on an inbound request, and WHICH credential
|
|
2449
|
-
// verifies
|
|
2450
|
-
//
|
|
2839
|
+
// verifies each channel. The route resolves the NAME to the stored value and
|
|
2840
|
+
// hands it to verify; this manifest never reads a store.
|
|
2841
|
+
//
|
|
2842
|
+
// `signature.channels` because this is the one manifest that receives from two
|
|
2843
|
+
// different vendors — Twilio on `sms`, SendGrid on `email` — which need
|
|
2844
|
+
// different credentials and share no scheme. A manifest whose channels all
|
|
2845
|
+
// verify the same way keeps declaring `signature` flat, and the route falls
|
|
2846
|
+
// back to it; Shopify's two channels do exactly that.
|
|
2451
2847
|
inbound : {
|
|
2452
2848
|
headers : {
|
|
2453
2849
|
id : 'i-twilio-idempotency-token',
|
|
2454
2850
|
signature : 'x-twilio-signature'
|
|
2455
2851
|
},
|
|
2456
2852
|
signature : {
|
|
2457
|
-
|
|
2853
|
+
channels : {
|
|
2854
|
+
email : sendgridInbound.signature,
|
|
2855
|
+
sms : { secret : 'TWILIO_AUTH_TOKEN' },
|
|
2856
|
+
// Outbound delivery receipts, which Twilio signs exactly as it signs
|
|
2857
|
+
// an inbound message — same scheme, same credential, different url.
|
|
2858
|
+
// Separate channel because the payload is a MessageStatus rather than
|
|
2859
|
+
// a message, and conflating them would put a STOP keyword check on a
|
|
2860
|
+
// delivery receipt.
|
|
2861
|
+
'sms-status' : { secret : 'TWILIO_AUTH_TOKEN' }
|
|
2862
|
+
}
|
|
2458
2863
|
}
|
|
2459
2864
|
},
|
|
2460
2865
|
// PRIVATE: never in the catalog, always available to the builder.
|
|
@@ -2486,6 +2891,13 @@ var drawbridge = {
|
|
|
2486
2891
|
// Optional: SENDGRID_SEND_FROM_ADDRESS is not boot-required in sync
|
|
2487
2892
|
// either. Unset, it degrades to the account sender rather than
|
|
2488
2893
|
// refusing to start.
|
|
2894
|
+
// NOT redacted, because it is a PUBLIC key. Marking it secret would
|
|
2895
|
+
// be theatre, and it would stop an operator reading back the value
|
|
2896
|
+
// they need to compare against the SendGrid console.
|
|
2897
|
+
//
|
|
2898
|
+
// Optional: absent, the event webhook answers 500 on verify rather
|
|
2899
|
+
// than accepting unverified deliveries, and nothing else notices.
|
|
2900
|
+
{ 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
2901
|
{ 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
2902
|
],
|
|
2491
2903
|
icon : sendgridIcon,
|
|
@@ -3956,71 +4368,6 @@ var icon = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmln
|
|
|
3956
4368
|
<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
4369
|
</svg>`;
|
|
3958
4370
|
|
|
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
4371
|
// ── ORDER ATTRIBUTION, the vendor's own arithmetic ───────────────────────────
|
|
4025
4372
|
//
|
|
4026
4373
|
// Orders are attributed via `_drwbrdg_*` line-item properties injected at
|
|
@@ -5872,6 +6219,39 @@ var shopify = {
|
|
|
5872
6219
|
// Webhooks — the only connection with no third party behind it. Connecting
|
|
5873
6220
|
// generates a signing secret rather than asking for a credential, which is why
|
|
5874
6221
|
// its one field declares no `input`.
|
|
6222
|
+
|
|
6223
|
+
// A receiver owes us a status, not a document. Whatever it returns is buffered
|
|
6224
|
+
// whole in the worker and written to the run record, so it is bounded.
|
|
6225
|
+
const RESPONSE_LIMIT = 64 * 1024;
|
|
6226
|
+
|
|
6227
|
+
// STRIPE-SHAPED: `t=<unix seconds>,v1=<hex>[,v1=<hex>]`, over `<t>.<raw body>`.
|
|
6228
|
+
// The timestamp is INSIDE the signed string, so a captured delivery stops
|
|
6229
|
+
// verifying once the receiver's tolerance passes — signing the body alone made a
|
|
6230
|
+
// replay valid forever. A second v1 carries the previous secret while a
|
|
6231
|
+
// regeneration's grace window is open (the api stamps `previous.until`), so a
|
|
6232
|
+
// receiver still on the old value keeps verifying mid-rotation instead of
|
|
6233
|
+
// failing every delivery between the button press and their config change.
|
|
6234
|
+
const signature = ( { body, settings } ) => {
|
|
6235
|
+
|
|
6236
|
+
const timestamp = Math.floor( Date.now() / 1000 );
|
|
6237
|
+
|
|
6238
|
+
const payload = timestamp + '.' + JSON.stringify( body );
|
|
6239
|
+
|
|
6240
|
+
const previous = settings.previous?.secret && new Date( settings.previous.until ) > new Date()
|
|
6241
|
+
? [ settings.previous.secret ]
|
|
6242
|
+
: [];
|
|
6243
|
+
|
|
6244
|
+
return [
|
|
6245
|
+
't=' + timestamp,
|
|
6246
|
+
...[ settings.secret, ...previous ].map( ( secret ) => 'v1=' + crypto
|
|
6247
|
+
.createHmac( 'sha256', secret )
|
|
6248
|
+
.update( payload )
|
|
6249
|
+
.digest( 'hex' )
|
|
6250
|
+
)
|
|
6251
|
+
].join( ',' );
|
|
6252
|
+
|
|
6253
|
+
};
|
|
6254
|
+
|
|
5875
6255
|
var webhook = {
|
|
5876
6256
|
// Connecting GENERATES the secret rather than storing one the merchant typed,
|
|
5877
6257
|
// so the buttons say what actually happens.
|
|
@@ -5902,7 +6282,9 @@ var webhook = {
|
|
|
5902
6282
|
'Press Connect. Drawbridge generates a signing secret and shows it here.',
|
|
5903
6283
|
'Copy the secret into your own endpoint.',
|
|
5904
6284
|
'Add a Send webhook step to a workflow and put your endpoint URL on it. The URL belongs to the step rather than the connection, so one connection can serve several endpoints.',
|
|
5905
|
-
'On each request, compute HMAC-SHA256 of
|
|
6285
|
+
'On each request, read the X-Drawbridge-Signature header — `t=<unix seconds>,v1=<hex>` — and compute HMAC-SHA256 of `<t>.<raw request body>` with the secret. Use the raw bytes, not a re-serialised parse.',
|
|
6286
|
+
'Compare your result against each v1 value with a constant-time comparison, and reject any request whose timestamp is more than five minutes old.',
|
|
6287
|
+
'After you regenerate the secret, the previous one keeps signing for 24 hours (as a second v1), so update your endpoint within that window.'
|
|
5906
6288
|
]
|
|
5907
6289
|
},
|
|
5908
6290
|
// Nothing to be exclusive with — there is no second webhook vendor, and a
|
|
@@ -5975,6 +6357,11 @@ var webhook = {
|
|
|
5975
6357
|
|
|
5976
6358
|
if( ! url ) return { message : 'Outgoing webhook URL is not configured for this step.', request, response : { skipped : true }, skipped : true };
|
|
5977
6359
|
|
|
6360
|
+
// HTTPS ONLY. The body is the lead — email, phone, address — and a
|
|
6361
|
+
// signature authenticates it without hiding it. The api refuses one on
|
|
6362
|
+
// save; this catches a document written any other way.
|
|
6363
|
+
if( ! /^https:\/\//i.test( url ) ) return { message : 'Outgoing webhook URL must use https.', request, response : { skipped : true }, skipped : true };
|
|
6364
|
+
|
|
5978
6365
|
// The LEAD, when there is one, rather than the accumulated context. A
|
|
5979
6366
|
// receiver wants the entrant's record, not our internal step state —
|
|
5980
6367
|
// and the shell already resolved it, because every hook that names a
|
|
@@ -5990,14 +6377,11 @@ var webhook = {
|
|
|
5990
6377
|
// tells the merchant to check it.
|
|
5991
6378
|
if( settings?.secret ){
|
|
5992
6379
|
|
|
5993
|
-
outgoing[ 'X-Drawbridge-Signature' ] =
|
|
5994
|
-
.createHmac( 'sha256', settings.secret )
|
|
5995
|
-
.update( JSON.stringify( body ) )
|
|
5996
|
-
.digest( 'hex' );
|
|
6380
|
+
outgoing[ 'X-Drawbridge-Signature' ] = signature({ body, settings });
|
|
5997
6381
|
|
|
5998
6382
|
}
|
|
5999
6383
|
|
|
6000
|
-
const response = await send({ body, headers : outgoing, method, url });
|
|
6384
|
+
const response = await send({ body, headers : outgoing, maxContentLength : RESPONSE_LIMIT, method, url });
|
|
6001
6385
|
|
|
6002
6386
|
return { message : 'Webhook POSTed to ' + url + '.', request, response : response || { delivered : true } };
|
|
6003
6387
|
|
|
@@ -6052,7 +6436,7 @@ var webhook = {
|
|
|
6052
6436
|
: settings?.secret
|
|
6053
6437
|
? [
|
|
6054
6438
|
{
|
|
6055
|
-
message : 'Compute HMAC-SHA256( secret, body ) and compare against the X-Drawbridge-Signature header
|
|
6439
|
+
message : 'Compute HMAC-SHA256( secret, t + "." + raw body ) and constant-time compare it against a v1 in the X-Drawbridge-Signature header; reject timestamps older than five minutes.',
|
|
6056
6440
|
title : 'Requests must be verified',
|
|
6057
6441
|
type : 'warning'
|
|
6058
6442
|
}
|