@drawbridge/drawbridge-utils 0.0.107 → 0.0.109
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/connections/index.cjs +306 -41
- package/dist/connections/index.d.cts +485 -42
- package/dist/connections/index.d.ts +485 -42
- package/dist/connections/index.js +302 -40
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { refresh } from './oauth.cjs';
|
|
2
|
+
export { consentUrl, exchange, pkcePair } from './oauth.cjs';
|
|
3
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
3
4
|
|
|
4
5
|
// THE HOOK VOCABULARY. Closed, and every connection answers all of it.
|
|
5
6
|
//
|
|
@@ -48,14 +49,49 @@ const HOOKS = Object.freeze({
|
|
|
48
49
|
]),
|
|
49
50
|
|
|
50
51
|
// Receiving from the vendor.
|
|
52
|
+
//
|
|
53
|
+
// Split across TWO SERVICES, and the split is the point. `verify` and `event`
|
|
54
|
+
// and `receive` run in drawbridge-webhooks against the vendor's open
|
|
55
|
+
// connection, where the budget is whatever that vendor's timeout is —
|
|
56
|
+
// Shopify's is about five seconds, and missing it means they retry and the
|
|
57
|
+
// delivery is buffered twice. `process` runs in drawbridge-sync off the
|
|
58
|
+
// buffer, where it can take as long as it needs and retry without the vendor
|
|
59
|
+
// ever knowing.
|
|
60
|
+
//
|
|
61
|
+
// One hook spanning that seam would hide it, and the thing it hides is the
|
|
62
|
+
// one most likely to bite: slow work written on the receiving side turns
|
|
63
|
+
// into duplicate deliveries.
|
|
51
64
|
inbound : Object.freeze([
|
|
52
|
-
// Prove the request came from the vendor
|
|
53
|
-
//
|
|
65
|
+
// Prove the request came from the vendor, AND return the payload it
|
|
66
|
+
// carries. One hook rather than two because for some vendors they are
|
|
67
|
+
// genuinely one operation: a JWT-bodied webhook (Kinde) cannot be decoded
|
|
68
|
+
// into anything trustworthy without verifying it first, and a decode that
|
|
69
|
+
// runs before the signature check is exactly the bug this shape prevents.
|
|
70
|
+
//
|
|
71
|
+
// So the raw bytes stop here. Everything downstream receives the payload
|
|
72
|
+
// this returned, which means nothing downstream can act on unverified
|
|
73
|
+
// data even by mistake.
|
|
74
|
+
//
|
|
75
|
+
// It is also where a request gets refused for any other reason — an event
|
|
76
|
+
// outside the allowlist, a connection in the wrong state. Anything that
|
|
77
|
+
// can reject belongs here, so the route holds no rules of its own.
|
|
54
78
|
'verify',
|
|
55
|
-
// Name the event, from wherever this vendor puts it.
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
'
|
|
79
|
+
// Name the event, from wherever this vendor puts it. A header for
|
|
80
|
+
// Shopify, the route itself for Twilio, a claim in the payload for a
|
|
81
|
+
// JWT-bodied vendor.
|
|
82
|
+
'event',
|
|
83
|
+
// What to buffer and what to answer RIGHT NOW, while the vendor waits.
|
|
84
|
+
// Thin by obligation. Most vendors only shape a buffer doc here; one that
|
|
85
|
+
// must reply with content (Twilio answers HELP inline with TwiML, which
|
|
86
|
+
// carriers require) returns that too.
|
|
87
|
+
'receive',
|
|
88
|
+
// Do the work the event implies. Runs in drawbridge-sync, off the buffer.
|
|
89
|
+
//
|
|
90
|
+
// DECLARED here, IMPLEMENTED there — the same split as the step handlers,
|
|
91
|
+
// and for the same reason: this half needs controllers, queues and vendor
|
|
92
|
+
// SDKs, and putting those behind a published package makes every consumer
|
|
93
|
+
// carry them. The declaration is what proves the implementation exists.
|
|
94
|
+
'process'
|
|
59
95
|
]),
|
|
60
96
|
|
|
61
97
|
// Vendor data a campaign draws on. Named for what every store platform has,
|
|
@@ -155,6 +191,113 @@ const INPUTS = Object.freeze([
|
|
|
155
191
|
'url'
|
|
156
192
|
]);
|
|
157
193
|
|
|
194
|
+
// A LIVE ACCESS TOKEN, for any vendor that issues an expiring one.
|
|
195
|
+
//
|
|
196
|
+
// Written once here rather than per vendor, because every OAuth connection ends
|
|
197
|
+
// up needing it and the failure when it is missing is the same everywhere: a
|
|
198
|
+
// call that worked in testing and returns 401 an hour later.
|
|
199
|
+
//
|
|
200
|
+
// REFRESH BEFORE USE, not on a schedule. Correctness cannot depend on a job
|
|
201
|
+
// having run — a worker that was down, a queue that was drained, a connection
|
|
202
|
+
// nobody touched for a week all leave a scheduled refresh behind, and the next
|
|
203
|
+
// call fails. Doing it at the point of use also does work proportional to what
|
|
204
|
+
// is actually used rather than to how many connections exist.
|
|
205
|
+
//
|
|
206
|
+
// The scheduled probe (auth.probe, dispatched from sync) is a SEPARATE concern
|
|
207
|
+
// and both are needed: this one keeps calls working, that one keeps the grant
|
|
208
|
+
// alive against a vendor's idle window and reports health honestly.
|
|
209
|
+
|
|
210
|
+
// How long before real expiry a token counts as stale. Tokens are minted with a
|
|
211
|
+
// lifetime measured in the vendor's clock and spent against ours, and a request
|
|
212
|
+
// takes time to arrive — a token expiring in ten seconds is already too late for
|
|
213
|
+
// a call that has to travel.
|
|
214
|
+
const SKEW_SECONDS = 120;
|
|
215
|
+
|
|
216
|
+
const isStale = ( settings, now = Date.now() ) => {
|
|
217
|
+
|
|
218
|
+
// No expiry recorded means the vendor issues tokens that do not expire
|
|
219
|
+
// (Mailchimp) or a grant stored before expiry was tracked. Either way there
|
|
220
|
+
// is nothing to refresh toward, and treating unknown as stale would spend a
|
|
221
|
+
// refresh token on every single call.
|
|
222
|
+
if( ! settings?.expiresAt ) return false;
|
|
223
|
+
|
|
224
|
+
return new Date( settings.expiresAt ).getTime() - ( SKEW_SECONDS * 1000 ) <= now;
|
|
225
|
+
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// Compute what to store from a mint. Exported because the OAuth callback stores
|
|
229
|
+
// the same shape at connect time, and two places deriving `expiresAt` from
|
|
230
|
+
// `expiresIn` separately is how they come to disagree.
|
|
231
|
+
const tokenSettings = ({ existing = {}, now = Date.now(), tokens }) => ({
|
|
232
|
+
accessToken : tokens.accessToken,
|
|
233
|
+
// A vendor that does not rotate its refresh token returns none on a refresh
|
|
234
|
+
// (Klaviyo). Keeping the existing one is what makes the NEXT refresh work —
|
|
235
|
+
// dropping it invalidates the grant one call later, nowhere near the cause.
|
|
236
|
+
...( ( tokens.refreshToken || existing.refreshToken ) && {
|
|
237
|
+
refreshToken : tokens.refreshToken || existing.refreshToken
|
|
238
|
+
}),
|
|
239
|
+
// Absent when the vendor issues non-expiring tokens, and absent is meaningful
|
|
240
|
+
// — isStale reads it as "nothing to refresh toward".
|
|
241
|
+
...( tokens.expiresIn && {
|
|
242
|
+
expiresAt : new Date( now + ( tokens.expiresIn * 1000 ) ).toISOString()
|
|
243
|
+
}),
|
|
244
|
+
...( tokens.scope && { scope : tokens.scope })
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Returns a token that is good right now, refreshing and persisting first if the
|
|
248
|
+
// stored one is spent.
|
|
249
|
+
//
|
|
250
|
+
// `save` is injected because this package cannot reach a database — the same
|
|
251
|
+
// reason `fetcher` is. It receives the settings to store and is responsible for
|
|
252
|
+
// encrypting them; forgetting to persist means a refresh on every call, which
|
|
253
|
+
// works but burns the vendor's rate limit and eventually trips it.
|
|
254
|
+
// `force` mints even when the stored token still looks good. That is what
|
|
255
|
+
// auth.probe needs: a probe that short-circuits on an unexpired token never
|
|
256
|
+
// reaches the vendor, so it reports healthy on a grant revoked an hour ago.
|
|
257
|
+
const accessToken = async ({
|
|
258
|
+
clientId,
|
|
259
|
+
clientSecret,
|
|
260
|
+
fetcher,
|
|
261
|
+
force = false,
|
|
262
|
+
manifest,
|
|
263
|
+
now = Date.now(),
|
|
264
|
+
save,
|
|
265
|
+
settings
|
|
266
|
+
} = {}) => {
|
|
267
|
+
|
|
268
|
+
if( ! settings?.accessToken && ! settings?.refreshToken ){
|
|
269
|
+
|
|
270
|
+
throw new Error( 'This connection holds no credential, so there is no token to use' );
|
|
271
|
+
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if( ! force && ! isStale( settings, now ) ) return settings.accessToken;
|
|
275
|
+
|
|
276
|
+
if( ! settings.refreshToken ){
|
|
277
|
+
|
|
278
|
+
// Expired with no way to mint another. Said plainly rather than returning
|
|
279
|
+
// a token the vendor will refuse — the merchant has to reconnect and the
|
|
280
|
+
// error should say so.
|
|
281
|
+
throw new Error( 'This connection has expired and cannot be renewed automatically. Reconnect it.' );
|
|
282
|
+
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const minted = await refresh({
|
|
286
|
+
clientId,
|
|
287
|
+
clientSecret,
|
|
288
|
+
descriptor : manifest.auth.oauth,
|
|
289
|
+
...( fetcher && { fetcher }),
|
|
290
|
+
refreshToken : settings.refreshToken
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const next = tokenSettings({ existing : settings, now, tokens : minted });
|
|
294
|
+
|
|
295
|
+
if( save ) await save( next );
|
|
296
|
+
|
|
297
|
+
return next.accessToken;
|
|
298
|
+
|
|
299
|
+
};
|
|
300
|
+
|
|
158
301
|
// Klaviyo, exported from the brand kit and left as authored — the fills are the
|
|
159
302
|
// vendor's own mark, not a recolour.
|
|
160
303
|
//
|
|
@@ -166,6 +309,38 @@ var icon$3 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
|
|
|
166
309
|
<path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
|
|
167
310
|
</svg>`;
|
|
168
311
|
|
|
312
|
+
// Klaviyo pins its API by DATE. A request without this header is refused, and
|
|
313
|
+
// one with an old date keeps the response shape that date shipped with — which
|
|
314
|
+
// is the point: bumping it is a deliberate act with a changelog to read, not
|
|
315
|
+
// something that drifts under us.
|
|
316
|
+
const REVISION = '2026-07-15';
|
|
317
|
+
|
|
318
|
+
const api = async ( path, { fetcher = fetch, token } ) => {
|
|
319
|
+
|
|
320
|
+
const response = await fetcher( 'https://a.klaviyo.com/api' + path, {
|
|
321
|
+
headers : {
|
|
322
|
+
// Bearer, not Klaviyo-API-Key — that header is for private keys, and
|
|
323
|
+
// sending it with an OAuth token fails in a way that reads like a bad
|
|
324
|
+
// token rather than a bad scheme.
|
|
325
|
+
authorization : 'Bearer ' + token,
|
|
326
|
+
revision : REVISION
|
|
327
|
+
},
|
|
328
|
+
signal : AbortSignal.timeout( 15000 )
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
if( ! response.ok ){
|
|
332
|
+
|
|
333
|
+
throw Object.assign(
|
|
334
|
+
new Error( 'Klaviyo refused the request (' + response.status + ')' ),
|
|
335
|
+
{ status : response.status }
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return response.json();
|
|
341
|
+
|
|
342
|
+
};
|
|
343
|
+
|
|
169
344
|
// Klaviyo — contact and event sync, over OAuth.
|
|
170
345
|
//
|
|
171
346
|
// THE FIRST VENDOR ADDED AS A MANIFEST AND NOTHING ELSE. No form component, no
|
|
@@ -202,6 +377,11 @@ var klaviyo = {
|
|
|
202
377
|
// it is a fact about someone else's records rather than a string this
|
|
203
378
|
// code computes. Deriving one from a provider key produced
|
|
204
379
|
// redirect_uri_mismatch on a connection nobody had touched.
|
|
380
|
+
// Klaviyo drops a refresh token after 90 days of NON-USE. The vendor
|
|
381
|
+
// never mentions this at runtime — you discover it when a refresh fails
|
|
382
|
+
// on a connection nobody touched — so it is declared, and it is why
|
|
383
|
+
// auth.probe has to run on a schedule rather than only before a call.
|
|
384
|
+
idleExpiry : 90 * 24 * 60 * 60,
|
|
205
385
|
redirect : '/api/connection/klaviyo/callback',
|
|
206
386
|
// Space separated. accounts:read is required by Klaviyo on every app
|
|
207
387
|
// and must stay in the list; the rest are what a contact sync needs.
|
|
@@ -233,7 +413,92 @@ var klaviyo = {
|
|
|
233
413
|
label : 'Klaviyo account'
|
|
234
414
|
}
|
|
235
415
|
],
|
|
416
|
+
// The three auth hooks, all pure HTTP against Klaviyo — which is why they
|
|
417
|
+
// live here rather than in sync. A vendor's own protocol belongs beside the
|
|
418
|
+
// vendor.
|
|
419
|
+
hooks : {
|
|
420
|
+
// Turn a fresh grant into settings worth showing. Without this the card
|
|
421
|
+
// renders an empty "Klaviyo account" field, because the merchant is never
|
|
422
|
+
// asked which account they connected — the consent already decided it and
|
|
423
|
+
// asking again would be a question we can answer ourselves.
|
|
424
|
+
'auth.connect' : async ({ fetcher, tokens }) => {
|
|
425
|
+
|
|
426
|
+
const body = await api( '/accounts', { fetcher, token : tokens.accessToken });
|
|
427
|
+
|
|
428
|
+
const account = body?.data?.[ 0 ];
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
account : account?.attributes?.contact_information?.organization_name || account?.id || null,
|
|
432
|
+
accountId : account?.id || null
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
},
|
|
436
|
+
// Revoke at KLAVIYO, not just locally. Forgetting our copy leaves the
|
|
437
|
+
// grant live in the merchant's account, so a disconnect that looks
|
|
438
|
+
// complete here still shows Drawbridge with access over there.
|
|
439
|
+
//
|
|
440
|
+
// Basic auth with our client, exactly like the token exchange — the token
|
|
441
|
+
// being revoked is the subject, not the credential.
|
|
442
|
+
'auth.disconnect' : async ({ clientId, clientSecret, fetcher = fetch, settings }) => {
|
|
443
|
+
|
|
444
|
+
const token = settings?.refreshToken || settings?.accessToken;
|
|
445
|
+
|
|
446
|
+
if( ! token ) return { revoked : false };
|
|
447
|
+
|
|
448
|
+
const response = await fetcher( 'https://a.klaviyo.com/oauth/revoke', {
|
|
449
|
+
body : new URLSearchParams({
|
|
450
|
+
token,
|
|
451
|
+
token_type_hint : settings?.refreshToken ? 'refresh_token' : 'access_token'
|
|
452
|
+
}),
|
|
453
|
+
headers : {
|
|
454
|
+
authorization : 'Basic ' + Buffer.from( clientId + ':' + clientSecret ).toString( 'base64' ),
|
|
455
|
+
'content-type' : 'application/x-www-form-urlencoded'
|
|
456
|
+
},
|
|
457
|
+
method : 'POST',
|
|
458
|
+
signal : AbortSignal.timeout( 15000 )
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// A grant already revoked at the vendor answers non-2xx, and that is
|
|
462
|
+
// the outcome we wanted — surfacing it as a failure would leave a
|
|
463
|
+
// merchant unable to finish disconnecting.
|
|
464
|
+
return { revoked : response.ok };
|
|
465
|
+
|
|
466
|
+
},
|
|
467
|
+
// THE MINT IS THE PROBE. Asking "is this token still good" by inspecting
|
|
468
|
+
// what we stored answers the wrong question — a grant revoked inside
|
|
469
|
+
// Klaviyo still looks perfect in our database. Spending the refresh token
|
|
470
|
+
// is the only thing that asks Klaviyo.
|
|
471
|
+
//
|
|
472
|
+
// It also keeps the grant warm: Klaviyo expires a refresh token after 90
|
|
473
|
+
// days of NON-USE, so a connection nobody touches dies silently without
|
|
474
|
+
// this running.
|
|
475
|
+
'auth.probe' : async ({ clientId, clientSecret, fetcher, manifest, settings }) => {
|
|
476
|
+
|
|
477
|
+
const token = await accessToken({
|
|
478
|
+
clientId,
|
|
479
|
+
clientSecret,
|
|
480
|
+
fetcher,
|
|
481
|
+
// Mint even if the stored token still looks good — a probe that
|
|
482
|
+
// short-circuits never reaches Klaviyo and reports healthy on a
|
|
483
|
+
// grant revoked an hour ago.
|
|
484
|
+
force : true,
|
|
485
|
+
manifest,
|
|
486
|
+
settings
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
return { ok : Boolean( token ) };
|
|
490
|
+
|
|
491
|
+
}
|
|
492
|
+
},
|
|
236
493
|
icon: icon$3,
|
|
494
|
+
// A grant with no list chosen is authenticated and useless. The list cannot
|
|
495
|
+
// be part of the consent flow — enumerating lists needs the token the consent
|
|
496
|
+
// returns — so it is always a second step, and the card must say so rather
|
|
497
|
+
// than showing Active over nothing.
|
|
498
|
+
incomplete : ( data ) => ( data?.settings?.list
|
|
499
|
+
? null
|
|
500
|
+
: 'Choose which Klaviyo list your contacts should sync into.'
|
|
501
|
+
),
|
|
237
502
|
label : 'klaviyo',
|
|
238
503
|
requires : [
|
|
239
504
|
'KLAVIYO_OAUTH_CLIENT_ID',
|
|
@@ -261,13 +526,24 @@ var klaviyo = {
|
|
|
261
526
|
'catalog.prices' : false,
|
|
262
527
|
'catalog.products' : false,
|
|
263
528
|
'catalog.promotions' : false,
|
|
264
|
-
'inbound.
|
|
265
|
-
'inbound.
|
|
529
|
+
'inbound.event' : false,
|
|
530
|
+
'inbound.process' : false,
|
|
531
|
+
'inbound.receive' : false,
|
|
266
532
|
'inbound.verify' : false,
|
|
267
533
|
'lifecycle.cleanup' : false,
|
|
268
534
|
'lifecycle.register' : false,
|
|
269
535
|
'lifecycle.rehydrate' : false
|
|
270
536
|
},
|
|
537
|
+
tasks : () => [
|
|
538
|
+
// Mailchimp carries the same warning, deliberately worded the same way. A
|
|
539
|
+
// merchant who connects either one and is told nothing reasonably assumes
|
|
540
|
+
// contacts are flowing, and finds out weeks later that they are not.
|
|
541
|
+
{
|
|
542
|
+
message : 'Contact syncing to Klaviyo lists has not shipped yet. Connecting stores your authorization so it is ready, but nothing is being sent to Klaviyo right now.',
|
|
543
|
+
title : 'List sync not available yet',
|
|
544
|
+
type : 'warning'
|
|
545
|
+
}
|
|
546
|
+
],
|
|
271
547
|
title : 'Klaviyo'
|
|
272
548
|
};
|
|
273
549
|
|
|
@@ -329,6 +605,15 @@ var mailchimp = {
|
|
|
329
605
|
}
|
|
330
606
|
],
|
|
331
607
|
icon: icon$2,
|
|
608
|
+
// A key with no audience chosen is authenticated and inert. Mailchimp also
|
|
609
|
+
// needs its merge fields created on that audience before any Drawbridge total
|
|
610
|
+
// can be written to a member — unlike Klaviyo, its custom fields are not
|
|
611
|
+
// schemaless — so the audience must be picked before lifecycle.register has
|
|
612
|
+
// anything to register against.
|
|
613
|
+
incomplete : ( data ) => ( data?.settings?.audience
|
|
614
|
+
? null
|
|
615
|
+
: 'Choose which Mailchimp audience your contacts should sync into.'
|
|
616
|
+
),
|
|
332
617
|
label : 'mailchimp',
|
|
333
618
|
// Uniform surface, honest answers. A key is stored and can be removed; nothing
|
|
334
619
|
// else is built yet, because audience sync has not shipped. Every false here
|
|
@@ -342,8 +627,9 @@ var mailchimp = {
|
|
|
342
627
|
'catalog.prices' : false,
|
|
343
628
|
'catalog.products' : false,
|
|
344
629
|
'catalog.promotions' : false,
|
|
345
|
-
'inbound.
|
|
346
|
-
'inbound.
|
|
630
|
+
'inbound.event' : false,
|
|
631
|
+
'inbound.process' : false,
|
|
632
|
+
'inbound.receive' : false,
|
|
347
633
|
'inbound.verify' : false,
|
|
348
634
|
'lifecycle.cleanup' : false,
|
|
349
635
|
'lifecycle.register' : false,
|
|
@@ -382,6 +668,96 @@ var icon$1 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
|
|
|
382
668
|
<path d="M258.993 193.019L249.103 230.052C249.103 230.052 238.079 225.023 225 225.85C205.833 227.059 205.628 239.171 205.824 242.21C206.865 258.756 250.376 262.381 252.821 301.171C254.745 331.688 236.656 352.574 210.592 354.21C179.313 356.19 162.089 337.711 162.089 337.711L168.716 309.472C168.716 309.472 186.052 322.569 199.921 321.686C208.993 321.119 212.228 313.738 211.903 308.514C210.536 286.921 175.102 288.185 172.862 252.695C170.985 222.811 190.57 192.554 233.803 189.821C250.46 188.762 258.993 193.028 258.993 193.028" fill="white"/>
|
|
383
669
|
</svg>`;
|
|
384
670
|
|
|
671
|
+
// THE SHARED HALF OF inbound.verify.
|
|
672
|
+
//
|
|
673
|
+
// A vendor whose scheme is "hash the raw body with a shared secret and compare,
|
|
674
|
+
// constant-time, against a header" declares that shape as `inbound.signature`
|
|
675
|
+
// data on its manifest (algorithm, encoding, the env var naming the secret) and
|
|
676
|
+
// wires this straight in as its hook — Shopify does exactly that.
|
|
677
|
+
//
|
|
678
|
+
// A vendor whose scheme is not that shape — Twilio signs the REGISTERED URL
|
|
679
|
+
// rather than the body, a JWT-bodied vendor verifies against a JWKS — writes its
|
|
680
|
+
// own inbound.verify instead. That is why verify is a hook and not config: this
|
|
681
|
+
// file covers the common case, not the contract.
|
|
682
|
+
const verifySignature = ({ body, descriptor, headers }) => {
|
|
683
|
+
|
|
684
|
+
const provided = headers[ descriptor.headers.signature ];
|
|
685
|
+
|
|
686
|
+
if( ! provided ){
|
|
687
|
+
|
|
688
|
+
throw Object.assign( new Error( 'Missing webhook signature' ), { status : 401 });
|
|
689
|
+
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const digest = createHmac( descriptor.signature.algorithm, process.env[ descriptor.signature.secret ] )
|
|
693
|
+
.update( body )
|
|
694
|
+
.digest( descriptor.signature.encoding );
|
|
695
|
+
|
|
696
|
+
// Buffers of different lengths crash timingSafeEqual rather than compare
|
|
697
|
+
// false — decided here, before the length itself becomes a timing signal.
|
|
698
|
+
const digestBuffer = Buffer.from( digest, descriptor.signature.encoding );
|
|
699
|
+
const providedBuffer = Buffer.from( provided, descriptor.signature.encoding );
|
|
700
|
+
|
|
701
|
+
if(
|
|
702
|
+
digestBuffer.length !== providedBuffer.length ||
|
|
703
|
+
! timingSafeEqual( digestBuffer, providedBuffer )
|
|
704
|
+
){
|
|
705
|
+
|
|
706
|
+
throw Object.assign( new Error( 'Invalid webhook signature' ), { status : 401 });
|
|
707
|
+
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// The proof and the parse happen together on purpose. Nothing downstream of
|
|
711
|
+
// inbound.verify ever sees the raw bytes, which is what makes acting on
|
|
712
|
+
// unverified data impossible rather than merely discouraged.
|
|
713
|
+
return JSON.parse( body.toString() );
|
|
714
|
+
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
// THE SHARED HALF OF inbound.event, for a vendor that puts it in a header.
|
|
718
|
+
const readEventHeader = ({ descriptor, headers }) => headers[ descriptor.headers.event ];
|
|
719
|
+
|
|
720
|
+
// WHERE THIS VENDOR PUTS THINGS ON AN INBOUND REQUEST. Header names are facts
|
|
721
|
+
// about someone else's product, so they belong beside the rest of the vendor
|
|
722
|
+
// rather than as string literals in a route — which is where they were, and is
|
|
723
|
+
// why a second inbound vendor meant a second route file.
|
|
724
|
+
//
|
|
725
|
+
// Pulled out to a const (rather than written inline under `inbound:` below) so
|
|
726
|
+
// the hooks further down can reference the SAME object the manifest publishes,
|
|
727
|
+
// instead of a second copy that could drift from it.
|
|
728
|
+
//
|
|
729
|
+
// `signature` describes an HMAC scheme the shared verifier can run: hash the
|
|
730
|
+
// raw body with the named secret and compare, constant-time, against the
|
|
731
|
+
// header. Vendors whose scheme is not that shape declare no signature block and
|
|
732
|
+
// implement inbound.verify themselves — a JWT-bodied vendor (Kinde) verifies
|
|
733
|
+
// against a JWKS and returns the decoded claims, and Twilio signs the
|
|
734
|
+
// registered URL rather than the body. That is why verify is a hook and not
|
|
735
|
+
// config.
|
|
736
|
+
const inbound = {
|
|
737
|
+
headers : {
|
|
738
|
+
event : 'x-shopify-topic',
|
|
739
|
+
id : 'x-shopify-webhook-id',
|
|
740
|
+
shop : 'x-shopify-shop-domain',
|
|
741
|
+
signature : 'x-shopify-hmac-sha256'
|
|
742
|
+
},
|
|
743
|
+
signature : {
|
|
744
|
+
algorithm : 'sha256',
|
|
745
|
+
encoding : 'base64',
|
|
746
|
+
secret : 'SHOPIFY_API_SECRET'
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
// Shopify's own GDPR deadline, not ours — these three are the only topics
|
|
751
|
+
// registered under `compliance_topics` in the app toml, and Shopify expects an
|
|
752
|
+
// answer even for a shop that has already uninstalled. Anything else on this
|
|
753
|
+
// channel is refused here rather than buffered, the same way a bad signature is:
|
|
754
|
+
// it is a request that should never have arrived.
|
|
755
|
+
const COMPLIANCE_TOPICS = new Set([
|
|
756
|
+
'customers/data_request',
|
|
757
|
+
'customers/redact',
|
|
758
|
+
'shop/redact'
|
|
759
|
+
]);
|
|
760
|
+
|
|
385
761
|
// Shopify — installed from the App Store, never connected with keys.
|
|
386
762
|
var shopify = {
|
|
387
763
|
// INSTALL, not oauth. The grant is an OAuth grant, but the merchant never
|
|
@@ -427,30 +803,43 @@ var shopify = {
|
|
|
427
803
|
}
|
|
428
804
|
],
|
|
429
805
|
group : 'ecommerce',
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
806
|
+
// The install is the whole configuration — Shopify hands back the shop and
|
|
807
|
+
// there is nothing further to choose. `shop` absent means the install did not
|
|
808
|
+
// finish, which is a credential problem rather than a setup one, so the
|
|
809
|
+
// stored status already says so.
|
|
810
|
+
incomplete : () => null,
|
|
811
|
+
// verify and event lean entirely on the shared HMAC helper — Shopify's scheme
|
|
812
|
+
// is exactly the shape it covers, so there is nothing vendor-specific to
|
|
813
|
+
// write for either. receive is the one hook that genuinely differs by
|
|
814
|
+
// channel: /events buffers whatever arrives with the shop domain stamped on;
|
|
815
|
+
// /compliance enforces the topic allowlist above, because answering one late
|
|
816
|
+
// is a legal deadline rather than a retry.
|
|
817
|
+
hooks : {
|
|
818
|
+
'inbound.event' : ( args ) => readEventHeader({ ...args, descriptor : inbound }),
|
|
819
|
+
'inbound.receive' : ({ channel, event, headers, payload }) => {
|
|
820
|
+
|
|
821
|
+
if( channel === 'compliance' && ! COMPLIANCE_TOPICS.has( event ) ){
|
|
822
|
+
|
|
823
|
+
throw Object.assign( new Error( 'Unrecognized compliance topic: ' + event ), { status : 401 });
|
|
824
|
+
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return {
|
|
828
|
+
// Compliance payloads already carry shop_domain in the body — Shopify's
|
|
829
|
+
// own GDPR shape. The app-level event stream does not; that domain
|
|
830
|
+
// lives only in the header, so it is stamped on here rather than left
|
|
831
|
+
// for drawbridge-sync to reach into headers nobody hands it.
|
|
832
|
+
data : channel === 'compliance'
|
|
833
|
+
? payload
|
|
834
|
+
: { ...payload, shop_domain : headers[ inbound.headers.shop ] || null },
|
|
835
|
+
provider : { id : headers[ inbound.headers.id ] || null }
|
|
836
|
+
};
|
|
837
|
+
|
|
446
838
|
},
|
|
447
|
-
|
|
448
|
-
algorithm : 'sha256',
|
|
449
|
-
encoding : 'base64',
|
|
450
|
-
secret : 'SHOPIFY_API_SECRET'
|
|
451
|
-
}
|
|
839
|
+
'inbound.verify' : ( args ) => verifySignature({ ...args, descriptor : inbound })
|
|
452
840
|
},
|
|
453
841
|
icon: icon$1,
|
|
842
|
+
inbound,
|
|
454
843
|
label : 'shopify',
|
|
455
844
|
// A pre-launch integration: it only surfaces once the App Store listing
|
|
456
845
|
// exists and the app is fully configured. Requiring all four means it can
|
|
@@ -479,8 +868,9 @@ var shopify = {
|
|
|
479
868
|
'catalog.prices' : false,
|
|
480
869
|
'catalog.products' : true,
|
|
481
870
|
'catalog.promotions' : true,
|
|
482
|
-
'inbound.
|
|
483
|
-
'inbound.
|
|
871
|
+
'inbound.event' : true,
|
|
872
|
+
'inbound.process' : true,
|
|
873
|
+
'inbound.receive' : true,
|
|
484
874
|
'inbound.verify' : true,
|
|
485
875
|
'lifecycle.cleanup' : true,
|
|
486
876
|
'lifecycle.register' : true,
|
|
@@ -631,6 +1021,9 @@ var webhook = {
|
|
|
631
1021
|
// It is the one card that reads wrong — our logo among vendor logos — and it
|
|
632
1022
|
// wants a mark of its own when there is one.
|
|
633
1023
|
icon,
|
|
1024
|
+
// The destination url is supplied per step, not per connection, so there is
|
|
1025
|
+
// nothing to finish here — generating the secret IS connecting.
|
|
1026
|
+
incomplete : () => null,
|
|
634
1027
|
label : 'webhook',
|
|
635
1028
|
// Gated on the encryption secret: without it the signing secret could not be
|
|
636
1029
|
// stored safely, so the connection must not be offered at all.
|
|
@@ -647,8 +1040,9 @@ var webhook = {
|
|
|
647
1040
|
'catalog.prices' : false,
|
|
648
1041
|
'catalog.products' : false,
|
|
649
1042
|
'catalog.promotions' : false,
|
|
650
|
-
'inbound.
|
|
651
|
-
'inbound.
|
|
1043
|
+
'inbound.event' : false,
|
|
1044
|
+
'inbound.process' : false,
|
|
1045
|
+
'inbound.receive' : false,
|
|
652
1046
|
'inbound.verify' : false,
|
|
653
1047
|
'lifecycle.cleanup' : false,
|
|
654
1048
|
'lifecycle.register' : false,
|
|
@@ -830,14 +1224,35 @@ const build = ( manifest ) => {
|
|
|
830
1224
|
|
|
831
1225
|
}
|
|
832
1226
|
|
|
1227
|
+
// THE REDIRECT MUST BE THE ONE PATH THE DASHBOARD SERVES.
|
|
1228
|
+
//
|
|
1229
|
+
// There is exactly one callback route — app/api/connection/[slug]/callback
|
|
1230
|
+
// in drawbridge-app-web — so this path is a fact about our own routing, not
|
|
1231
|
+
// a free choice. It stays declared rather than derived because it is also a
|
|
1232
|
+
// record of what is registered in the vendor's console, and reading it in
|
|
1233
|
+
// the manifest is how somebody checks the two agree.
|
|
1234
|
+
//
|
|
1235
|
+
// Declared-but-wrong is the dangerous state: it passes review, gets
|
|
1236
|
+
// registered at the vendor, and fails AFTER the merchant has consented —
|
|
1237
|
+
// they hit a dashboard 404 having already granted access. Cheaper to refuse
|
|
1238
|
+
// the manifest at import.
|
|
1239
|
+
if( manifest.auth.oauth.redirect !== '/api/connection/' + manifest.slug + '/callback' ){
|
|
1240
|
+
|
|
1241
|
+
throw new Error(
|
|
1242
|
+
manifest.slug + ' declares auth.oauth.redirect ' + manifest.auth.oauth.redirect
|
|
1243
|
+
+ ' but the only callback route is /api/connection/' + manifest.slug + '/callback'
|
|
1244
|
+
);
|
|
1245
|
+
|
|
1246
|
+
}
|
|
1247
|
+
|
|
833
1248
|
}
|
|
834
1249
|
|
|
835
1250
|
// A vendor that receives from the outside must say where it puts the event
|
|
836
1251
|
// name. Without it the receiver has nothing to dispatch on, and the failure
|
|
837
1252
|
// is a request accepted and dropped rather than an error.
|
|
838
|
-
if( manifest.supports?.[ 'inbound.
|
|
1253
|
+
if( manifest.supports?.[ 'inbound.event' ] && ! manifest.inbound?.headers?.event ){
|
|
839
1254
|
|
|
840
|
-
throw new Error( manifest.slug + ' supports inbound.
|
|
1255
|
+
throw new Error( manifest.slug + ' supports inbound.event but declares no inbound.headers.event' );
|
|
841
1256
|
|
|
842
1257
|
}
|
|
843
1258
|
|
|
@@ -847,6 +1262,27 @@ const build = ( manifest ) => {
|
|
|
847
1262
|
|
|
848
1263
|
}
|
|
849
1264
|
|
|
1265
|
+
// IS THE CREDENTIAL ENOUGH? For most vendors yes, and they answer with a bare
|
|
1266
|
+
// `() => null`. But a credential is not always a working connection: a
|
|
1267
|
+
// Klaviyo grant with no list chosen is authenticated and useless, and the
|
|
1268
|
+
// merchant sees an Active card doing nothing — the exact silence this
|
|
1269
|
+
// package exists to remove.
|
|
1270
|
+
//
|
|
1271
|
+
// Returns the REASON it is not usable yet, in the merchant's words, or null
|
|
1272
|
+
// when it is. One function rather than a boolean plus a message, because two
|
|
1273
|
+
// declarations of the same fact drift: the status the card shows and the
|
|
1274
|
+
// sentence explaining it come from the same call or they eventually
|
|
1275
|
+
// disagree.
|
|
1276
|
+
//
|
|
1277
|
+
// Required on every manifest for the same reason `supports` is: a missing
|
|
1278
|
+
// answer is indistinguishable from a vendor written before the question
|
|
1279
|
+
// existed.
|
|
1280
|
+
if( typeof manifest?.incomplete !== 'function' ){
|
|
1281
|
+
|
|
1282
|
+
throw new Error( manifest.slug + ' must declare incomplete( data ) — return null when the connection is usable, or the reason it is not' );
|
|
1283
|
+
|
|
1284
|
+
}
|
|
1285
|
+
|
|
850
1286
|
// How to get the credential, in the merchant's words. A connection page that
|
|
851
1287
|
// cannot say where to find an API key sends somebody to a vendor's docs
|
|
852
1288
|
// written for a different integration.
|
|
@@ -1090,7 +1526,11 @@ const runHook = async ( slug, name, args = {} ) => {
|
|
|
1090
1526
|
|
|
1091
1527
|
} catch ( error ) {
|
|
1092
1528
|
|
|
1093
|
-
|
|
1529
|
+
// A hook throws to REJECT, not just to report failure — inbound.verify
|
|
1530
|
+
// on a forged signature, inbound.receive on a topic outside the vendor's
|
|
1531
|
+
// declared set. The status rides along so a caller can answer 401 rather
|
|
1532
|
+
// than always folding a hook's own rejection into a 500.
|
|
1533
|
+
return { error : error?.message || 'failed', outcome : OUTCOMES.failed, status : error?.status || null };
|
|
1094
1534
|
|
|
1095
1535
|
}
|
|
1096
1536
|
|
|
@@ -1169,6 +1609,9 @@ const publicConnectionKeys = Object.freeze([
|
|
|
1169
1609
|
'group',
|
|
1170
1610
|
'id',
|
|
1171
1611
|
'image',
|
|
1612
|
+
// The reason a connected vendor still is not usable — a Klaviyo grant with no
|
|
1613
|
+
// list chosen. Public because the card that shows Pending has to say why.
|
|
1614
|
+
'incomplete',
|
|
1172
1615
|
'label',
|
|
1173
1616
|
'setup',
|
|
1174
1617
|
'settings',
|
|
@@ -1216,7 +1659,7 @@ const resolveConnection = ( item, data ) => {
|
|
|
1216
1659
|
|
|
1217
1660
|
return Object.fromEntries(
|
|
1218
1661
|
Object.entries( item )
|
|
1219
|
-
.filter( ( [ key ] ) => ! [ 'auth', 'enabled', 'fields', 'inbound', 'requires', 'steps', 'supports' ].includes( key ) )
|
|
1662
|
+
.filter( ( [ key ] ) => ! [ 'auth', 'enabled', 'fields', 'hooks', 'inbound', 'requires', 'steps', 'supports' ].includes( key ) )
|
|
1220
1663
|
.map( ( [ key, value ] ) => [
|
|
1221
1664
|
key,
|
|
1222
1665
|
( typeof value === 'function' ? value( data ) : value )
|
|
@@ -1225,4 +1668,4 @@ const resolveConnection = ( item, data ) => {
|
|
|
1225
1668
|
|
|
1226
1669
|
};
|
|
1227
1670
|
|
|
1228
|
-
export { AUTH_TYPES, CATEGORIES, HOOKS, HOOK_NAMES, INPUTS, OAUTH_FIELDS, OUTCOMES, availableConnections, build, connectFields, connectionSteps, connections, hookSupport, mergeSettings, projectConnection, publicConnectionKeys, publicSettingsBySlug, redactSettings, resolveConnection, runHook, scopesMessage, stepQueues };
|
|
1671
|
+
export { AUTH_TYPES, CATEGORIES, HOOKS, HOOK_NAMES, INPUTS, OAUTH_FIELDS, OUTCOMES, accessToken, availableConnections, build, connectFields, connectionSteps, connections, hookSupport, isStale, mergeSettings, projectConnection, publicConnectionKeys, publicSettingsBySlug, redactSettings, refresh, resolveConnection, runHook, scopesMessage, stepQueues, tokenSettings };
|