@drawbridge/drawbridge-utils 0.0.108 → 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,4 +1,5 @@
1
- export { consentUrl, exchange, pkcePair, refresh } from './oauth.cjs';
1
+ import { refresh } from './oauth.cjs';
2
+ export { consentUrl, exchange, pkcePair } from './oauth.cjs';
2
3
  import { createHmac, timingSafeEqual } from 'node:crypto';
3
4
 
4
5
  // THE HOOK VOCABULARY. Closed, and every connection answers all of it.
@@ -190,6 +191,113 @@ const INPUTS = Object.freeze([
190
191
  'url'
191
192
  ]);
192
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
+
193
301
  // Klaviyo, exported from the brand kit and left as authored — the fills are the
194
302
  // vendor's own mark, not a recolour.
195
303
  //
@@ -201,6 +309,38 @@ var icon$3 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
201
309
  <path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
202
310
  </svg>`;
203
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
+
204
344
  // Klaviyo — contact and event sync, over OAuth.
205
345
  //
206
346
  // THE FIRST VENDOR ADDED AS A MANIFEST AND NOTHING ELSE. No form component, no
@@ -237,6 +377,11 @@ var klaviyo = {
237
377
  // it is a fact about someone else's records rather than a string this
238
378
  // code computes. Deriving one from a provider key produced
239
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,
240
385
  redirect : '/api/connection/klaviyo/callback',
241
386
  // Space separated. accounts:read is required by Klaviyo on every app
242
387
  // and must stay in the list; the rest are what a contact sync needs.
@@ -268,7 +413,92 @@ var klaviyo = {
268
413
  label : 'Klaviyo account'
269
414
  }
270
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
+ },
271
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
+ ),
272
502
  label : 'klaviyo',
273
503
  requires : [
274
504
  'KLAVIYO_OAUTH_CLIENT_ID',
@@ -304,6 +534,16 @@ var klaviyo = {
304
534
  'lifecycle.register' : false,
305
535
  'lifecycle.rehydrate' : false
306
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
+ ],
307
547
  title : 'Klaviyo'
308
548
  };
309
549
 
@@ -365,6 +605,15 @@ var mailchimp = {
365
605
  }
366
606
  ],
367
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
+ ),
368
617
  label : 'mailchimp',
369
618
  // Uniform surface, honest answers. A key is stored and can be removed; nothing
370
619
  // else is built yet, because audience sync has not shipped. Every false here
@@ -501,7 +750,7 @@ const inbound = {
501
750
  // Shopify's own GDPR deadline, not ours — these three are the only topics
502
751
  // registered under `compliance_topics` in the app toml, and Shopify expects an
503
752
  // answer even for a shop that has already uninstalled. Anything else on this
504
- // action is refused here rather than buffered, the same way a bad signature is:
753
+ // channel is refused here rather than buffered, the same way a bad signature is:
505
754
  // it is a request that should never have arrived.
506
755
  const COMPLIANCE_TOPICS = new Set([
507
756
  'customers/data_request',
@@ -554,17 +803,22 @@ var shopify = {
554
803
  }
555
804
  ],
556
805
  group : 'ecommerce',
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,
557
811
  // verify and event lean entirely on the shared HMAC helper — Shopify's scheme
558
812
  // is exactly the shape it covers, so there is nothing vendor-specific to
559
813
  // write for either. receive is the one hook that genuinely differs by
560
- // action: /events buffers whatever arrives with the shop domain stamped on;
814
+ // channel: /events buffers whatever arrives with the shop domain stamped on;
561
815
  // /compliance enforces the topic allowlist above, because answering one late
562
816
  // is a legal deadline rather than a retry.
563
817
  hooks : {
564
818
  'inbound.event' : ( args ) => readEventHeader({ ...args, descriptor : inbound }),
565
- 'inbound.receive' : ({ action, event, headers, payload }) => {
819
+ 'inbound.receive' : ({ channel, event, headers, payload }) => {
566
820
 
567
- if( action === 'compliance' && ! COMPLIANCE_TOPICS.has( event ) ){
821
+ if( channel === 'compliance' && ! COMPLIANCE_TOPICS.has( event ) ){
568
822
 
569
823
  throw Object.assign( new Error( 'Unrecognized compliance topic: ' + event ), { status : 401 });
570
824
 
@@ -575,7 +829,7 @@ var shopify = {
575
829
  // own GDPR shape. The app-level event stream does not; that domain
576
830
  // lives only in the header, so it is stamped on here rather than left
577
831
  // for drawbridge-sync to reach into headers nobody hands it.
578
- data : action === 'compliance'
832
+ data : channel === 'compliance'
579
833
  ? payload
580
834
  : { ...payload, shop_domain : headers[ inbound.headers.shop ] || null },
581
835
  provider : { id : headers[ inbound.headers.id ] || null }
@@ -767,6 +1021,9 @@ var webhook = {
767
1021
  // It is the one card that reads wrong — our logo among vendor logos — and it
768
1022
  // wants a mark of its own when there is one.
769
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,
770
1027
  label : 'webhook',
771
1028
  // Gated on the encryption secret: without it the signing secret could not be
772
1029
  // stored safely, so the connection must not be offered at all.
@@ -967,6 +1224,27 @@ const build = ( manifest ) => {
967
1224
 
968
1225
  }
969
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
+
970
1248
  }
971
1249
 
972
1250
  // A vendor that receives from the outside must say where it puts the event
@@ -984,6 +1262,27 @@ const build = ( manifest ) => {
984
1262
 
985
1263
  }
986
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
+
987
1286
  // How to get the credential, in the merchant's words. A connection page that
988
1287
  // cannot say where to find an API key sends somebody to a vendor's docs
989
1288
  // written for a different integration.
@@ -1310,6 +1609,9 @@ const publicConnectionKeys = Object.freeze([
1310
1609
  'group',
1311
1610
  'id',
1312
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',
1313
1615
  'label',
1314
1616
  'setup',
1315
1617
  'settings',
@@ -1366,4 +1668,4 @@ const resolveConnection = ( item, data ) => {
1366
1668
 
1367
1669
  };
1368
1670
 
1369
- 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 };