@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.
- package/dist/connections/index.cjs +207 -7
- package/dist/connections/index.d.cts +320 -8
- package/dist/connections/index.d.ts +320 -8
- package/dist/connections/index.js +203 -6
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import { refresh } from './oauth.js';
|
|
2
|
+
export { consentUrl, exchange, pkcePair } from './oauth.js';
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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' : ({
|
|
829
|
+
'inbound.receive' : ({ channel, event, headers, payload }) => {
|
|
566
830
|
|
|
567
|
-
if(
|
|
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 :
|
|
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 };
|