@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.
@@ -1,5 +1,6 @@
1
- export { consentUrl, exchange, pkcePair, refresh } from './oauth.cjs';
2
- import 'node:crypto';
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. Signature schemes differ per
53
- // vendor, which is exactly why this is a hook and not one shared function.
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
- 'topic',
57
- // Do the work the event implies.
58
- 'handle'
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.handle' : false,
265
- 'inbound.topic' : false,
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.handle' : false,
346
- 'inbound.topic' : false,
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
- // WHERE THIS VENDOR PUTS THINGS ON AN INBOUND REQUEST. Header names are facts
431
- // about someone else's product, so they belong beside the rest of the vendor
432
- // rather than as string literals in a route which is where they were, and
433
- // is why a second inbound vendor meant a second route file.
434
- //
435
- // `signature` describes an HMAC scheme the shared verifier can run: hash the
436
- // raw body with the named secret and compare, constant-time, against the
437
- // header. Vendors whose scheme is not that shape Stripe signs a timestamped
438
- // payload declare no signature block and implement inbound.verify instead.
439
- // That is why verify is a hook and not config.
440
- inbound : {
441
- headers : {
442
- id : 'x-shopify-webhook-id',
443
- shop : 'x-shopify-shop-domain',
444
- signature : 'x-shopify-hmac-sha256',
445
- topic : 'x-shopify-topic'
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
- signature : {
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.handle' : true,
483
- 'inbound.topic' : true,
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.handle' : false,
651
- 'inbound.topic' : false,
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.topic' ] && ! manifest.inbound?.headers?.topic ){
1253
+ if( manifest.supports?.[ 'inbound.event' ] && ! manifest.inbound?.headers?.event ){
839
1254
 
840
- throw new Error( manifest.slug + ' supports inbound.topic but declares no inbound.headers.topic' );
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
- return { error : error?.message || 'failed', outcome : OUTCOMES.failed };
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 };