@drawbridge/drawbridge-utils 0.0.108 → 0.0.110

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
@@ -221,7 +361,17 @@ var klaviyo = {
221
361
  // Google product wants it.
222
362
  auth : {
223
363
  oauth : {
224
- authorize : 'https://a.klaviyo.com/oauth/authorize',
364
+ // TWO DIFFERENT HOSTS, and swapping them fails in opposite directions.
365
+ //
366
+ // authorize is a page a HUMAN loads, and it lives on www. Pointing it at
367
+ // a.klaviyo.com -- their API host -- sends the merchant somewhere that
368
+ // never renders a consent screen, so the journey stalls with no error
369
+ // anybody can see.
370
+ //
371
+ // token is a server call and must stay on a.klaviyo.com: Klaviyo began
372
+ // blocking OAuth token traffic through www on 2025-03-31, so the mirror
373
+ // image of this mistake breaks the exchange instead of the consent.
374
+ authorize : 'https://www.klaviyo.com/oauth/authorize',
225
375
  // NAMES the env vars holding OUR application's client. One identity,
226
376
  // every merchant — the token is the merchant's and arrives from their
227
377
  // own consent, which is what stops one organization reading another's
@@ -237,6 +387,11 @@ var klaviyo = {
237
387
  // it is a fact about someone else's records rather than a string this
238
388
  // code computes. Deriving one from a provider key produced
239
389
  // redirect_uri_mismatch on a connection nobody had touched.
390
+ // Klaviyo drops a refresh token after 90 days of NON-USE. The vendor
391
+ // never mentions this at runtime — you discover it when a refresh fails
392
+ // on a connection nobody touched — so it is declared, and it is why
393
+ // auth.probe has to run on a schedule rather than only before a call.
394
+ idleExpiry : 90 * 24 * 60 * 60,
240
395
  redirect : '/api/connection/klaviyo/callback',
241
396
  // Space separated. accounts:read is required by Klaviyo on every app
242
397
  // and must stay in the list; the rest are what a contact sync needs.
@@ -268,7 +423,92 @@ var klaviyo = {
268
423
  label : 'Klaviyo account'
269
424
  }
270
425
  ],
426
+ // The three auth hooks, all pure HTTP against Klaviyo — which is why they
427
+ // live here rather than in sync. A vendor's own protocol belongs beside the
428
+ // vendor.
429
+ hooks : {
430
+ // Turn a fresh grant into settings worth showing. Without this the card
431
+ // renders an empty "Klaviyo account" field, because the merchant is never
432
+ // asked which account they connected — the consent already decided it and
433
+ // asking again would be a question we can answer ourselves.
434
+ 'auth.connect' : async ({ fetcher, tokens }) => {
435
+
436
+ const body = await api( '/accounts', { fetcher, token : tokens.accessToken });
437
+
438
+ const account = body?.data?.[ 0 ];
439
+
440
+ return {
441
+ account : account?.attributes?.contact_information?.organization_name || account?.id || null,
442
+ accountId : account?.id || null
443
+ };
444
+
445
+ },
446
+ // Revoke at KLAVIYO, not just locally. Forgetting our copy leaves the
447
+ // grant live in the merchant's account, so a disconnect that looks
448
+ // complete here still shows Drawbridge with access over there.
449
+ //
450
+ // Basic auth with our client, exactly like the token exchange — the token
451
+ // being revoked is the subject, not the credential.
452
+ 'auth.disconnect' : async ({ clientId, clientSecret, fetcher = fetch, settings }) => {
453
+
454
+ const token = settings?.refreshToken || settings?.accessToken;
455
+
456
+ if( ! token ) return { revoked : false };
457
+
458
+ const response = await fetcher( 'https://a.klaviyo.com/oauth/revoke', {
459
+ body : new URLSearchParams({
460
+ token,
461
+ token_type_hint : settings?.refreshToken ? 'refresh_token' : 'access_token'
462
+ }),
463
+ headers : {
464
+ authorization : 'Basic ' + Buffer.from( clientId + ':' + clientSecret ).toString( 'base64' ),
465
+ 'content-type' : 'application/x-www-form-urlencoded'
466
+ },
467
+ method : 'POST',
468
+ signal : AbortSignal.timeout( 15000 )
469
+ });
470
+
471
+ // A grant already revoked at the vendor answers non-2xx, and that is
472
+ // the outcome we wanted — surfacing it as a failure would leave a
473
+ // merchant unable to finish disconnecting.
474
+ return { revoked : response.ok };
475
+
476
+ },
477
+ // THE MINT IS THE PROBE. Asking "is this token still good" by inspecting
478
+ // what we stored answers the wrong question — a grant revoked inside
479
+ // Klaviyo still looks perfect in our database. Spending the refresh token
480
+ // is the only thing that asks Klaviyo.
481
+ //
482
+ // It also keeps the grant warm: Klaviyo expires a refresh token after 90
483
+ // days of NON-USE, so a connection nobody touches dies silently without
484
+ // this running.
485
+ 'auth.probe' : async ({ clientId, clientSecret, fetcher, manifest, settings }) => {
486
+
487
+ const token = await accessToken({
488
+ clientId,
489
+ clientSecret,
490
+ fetcher,
491
+ // Mint even if the stored token still looks good — a probe that
492
+ // short-circuits never reaches Klaviyo and reports healthy on a
493
+ // grant revoked an hour ago.
494
+ force : true,
495
+ manifest,
496
+ settings
497
+ });
498
+
499
+ return { ok : Boolean( token ) };
500
+
501
+ }
502
+ },
271
503
  icon: icon$3,
504
+ // A grant with no list chosen is authenticated and useless. The list cannot
505
+ // be part of the consent flow — enumerating lists needs the token the consent
506
+ // returns — so it is always a second step, and the card must say so rather
507
+ // than showing Active over nothing.
508
+ incomplete : ( data ) => ( data?.settings?.list
509
+ ? null
510
+ : 'Choose which Klaviyo list your contacts should sync into.'
511
+ ),
272
512
  label : 'klaviyo',
273
513
  requires : [
274
514
  'KLAVIYO_OAUTH_CLIENT_ID',
@@ -304,6 +544,16 @@ var klaviyo = {
304
544
  'lifecycle.register' : false,
305
545
  'lifecycle.rehydrate' : false
306
546
  },
547
+ tasks : () => [
548
+ // Mailchimp carries the same warning, deliberately worded the same way. A
549
+ // merchant who connects either one and is told nothing reasonably assumes
550
+ // contacts are flowing, and finds out weeks later that they are not.
551
+ {
552
+ 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.',
553
+ title : 'List sync not available yet',
554
+ type : 'warning'
555
+ }
556
+ ],
307
557
  title : 'Klaviyo'
308
558
  };
309
559
 
@@ -365,6 +615,15 @@ var mailchimp = {
365
615
  }
366
616
  ],
367
617
  icon: icon$2,
618
+ // A key with no audience chosen is authenticated and inert. Mailchimp also
619
+ // needs its merge fields created on that audience before any Drawbridge total
620
+ // can be written to a member — unlike Klaviyo, its custom fields are not
621
+ // schemaless — so the audience must be picked before lifecycle.register has
622
+ // anything to register against.
623
+ incomplete : ( data ) => ( data?.settings?.audience
624
+ ? null
625
+ : 'Choose which Mailchimp audience your contacts should sync into.'
626
+ ),
368
627
  label : 'mailchimp',
369
628
  // Uniform surface, honest answers. A key is stored and can be removed; nothing
370
629
  // else is built yet, because audience sync has not shipped. Every false here
@@ -501,7 +760,7 @@ const inbound = {
501
760
  // Shopify's own GDPR deadline, not ours — these three are the only topics
502
761
  // registered under `compliance_topics` in the app toml, and Shopify expects an
503
762
  // 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:
763
+ // channel is refused here rather than buffered, the same way a bad signature is:
505
764
  // it is a request that should never have arrived.
506
765
  const COMPLIANCE_TOPICS = new Set([
507
766
  'customers/data_request',
@@ -554,17 +813,22 @@ var shopify = {
554
813
  }
555
814
  ],
556
815
  group : 'ecommerce',
816
+ // The install is the whole configuration — Shopify hands back the shop and
817
+ // there is nothing further to choose. `shop` absent means the install did not
818
+ // finish, which is a credential problem rather than a setup one, so the
819
+ // stored status already says so.
820
+ incomplete : () => null,
557
821
  // verify and event lean entirely on the shared HMAC helper — Shopify's scheme
558
822
  // is exactly the shape it covers, so there is nothing vendor-specific to
559
823
  // write for either. receive is the one hook that genuinely differs by
560
- // action: /events buffers whatever arrives with the shop domain stamped on;
824
+ // channel: /events buffers whatever arrives with the shop domain stamped on;
561
825
  // /compliance enforces the topic allowlist above, because answering one late
562
826
  // is a legal deadline rather than a retry.
563
827
  hooks : {
564
828
  'inbound.event' : ( args ) => readEventHeader({ ...args, descriptor : inbound }),
565
- 'inbound.receive' : ({ action, event, headers, payload }) => {
829
+ 'inbound.receive' : ({ channel, event, headers, payload }) => {
566
830
 
567
- if( action === 'compliance' && ! COMPLIANCE_TOPICS.has( event ) ){
831
+ if( channel === 'compliance' && ! COMPLIANCE_TOPICS.has( event ) ){
568
832
 
569
833
  throw Object.assign( new Error( 'Unrecognized compliance topic: ' + event ), { status : 401 });
570
834
 
@@ -575,7 +839,7 @@ var shopify = {
575
839
  // own GDPR shape. The app-level event stream does not; that domain
576
840
  // lives only in the header, so it is stamped on here rather than left
577
841
  // for drawbridge-sync to reach into headers nobody hands it.
578
- data : action === 'compliance'
842
+ data : channel === 'compliance'
579
843
  ? payload
580
844
  : { ...payload, shop_domain : headers[ inbound.headers.shop ] || null },
581
845
  provider : { id : headers[ inbound.headers.id ] || null }
@@ -767,6 +1031,9 @@ var webhook = {
767
1031
  // It is the one card that reads wrong — our logo among vendor logos — and it
768
1032
  // wants a mark of its own when there is one.
769
1033
  icon,
1034
+ // The destination url is supplied per step, not per connection, so there is
1035
+ // nothing to finish here — generating the secret IS connecting.
1036
+ incomplete : () => null,
770
1037
  label : 'webhook',
771
1038
  // Gated on the encryption secret: without it the signing secret could not be
772
1039
  // stored safely, so the connection must not be offered at all.
@@ -967,6 +1234,27 @@ const build = ( manifest ) => {
967
1234
 
968
1235
  }
969
1236
 
1237
+ // THE REDIRECT MUST BE THE ONE PATH THE DASHBOARD SERVES.
1238
+ //
1239
+ // There is exactly one callback route — app/api/connection/[slug]/callback
1240
+ // in drawbridge-app-web — so this path is a fact about our own routing, not
1241
+ // a free choice. It stays declared rather than derived because it is also a
1242
+ // record of what is registered in the vendor's console, and reading it in
1243
+ // the manifest is how somebody checks the two agree.
1244
+ //
1245
+ // Declared-but-wrong is the dangerous state: it passes review, gets
1246
+ // registered at the vendor, and fails AFTER the merchant has consented —
1247
+ // they hit a dashboard 404 having already granted access. Cheaper to refuse
1248
+ // the manifest at import.
1249
+ if( manifest.auth.oauth.redirect !== '/api/connection/' + manifest.slug + '/callback' ){
1250
+
1251
+ throw new Error(
1252
+ manifest.slug + ' declares auth.oauth.redirect ' + manifest.auth.oauth.redirect
1253
+ + ' but the only callback route is /api/connection/' + manifest.slug + '/callback'
1254
+ );
1255
+
1256
+ }
1257
+
970
1258
  }
971
1259
 
972
1260
  // A vendor that receives from the outside must say where it puts the event
@@ -984,6 +1272,27 @@ const build = ( manifest ) => {
984
1272
 
985
1273
  }
986
1274
 
1275
+ // IS THE CREDENTIAL ENOUGH? For most vendors yes, and they answer with a bare
1276
+ // `() => null`. But a credential is not always a working connection: a
1277
+ // Klaviyo grant with no list chosen is authenticated and useless, and the
1278
+ // merchant sees an Active card doing nothing — the exact silence this
1279
+ // package exists to remove.
1280
+ //
1281
+ // Returns the REASON it is not usable yet, in the merchant's words, or null
1282
+ // when it is. One function rather than a boolean plus a message, because two
1283
+ // declarations of the same fact drift: the status the card shows and the
1284
+ // sentence explaining it come from the same call or they eventually
1285
+ // disagree.
1286
+ //
1287
+ // Required on every manifest for the same reason `supports` is: a missing
1288
+ // answer is indistinguishable from a vendor written before the question
1289
+ // existed.
1290
+ if( typeof manifest?.incomplete !== 'function' ){
1291
+
1292
+ throw new Error( manifest.slug + ' must declare incomplete( data ) — return null when the connection is usable, or the reason it is not' );
1293
+
1294
+ }
1295
+
987
1296
  // How to get the credential, in the merchant's words. A connection page that
988
1297
  // cannot say where to find an API key sends somebody to a vendor's docs
989
1298
  // written for a different integration.
@@ -1310,6 +1619,9 @@ const publicConnectionKeys = Object.freeze([
1310
1619
  'group',
1311
1620
  'id',
1312
1621
  'image',
1622
+ // The reason a connected vendor still is not usable — a Klaviyo grant with no
1623
+ // list chosen. Public because the card that shows Pending has to say why.
1624
+ 'incomplete',
1313
1625
  'label',
1314
1626
  'setup',
1315
1627
  'settings',
@@ -1366,4 +1678,4 @@ const resolveConnection = ( item, data ) => {
1366
1678
 
1367
1679
  };
1368
1680
 
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 };
1681
+ 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 };