@drawbridge/drawbridge-utils 0.0.134 → 0.0.135

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.
@@ -464,8 +464,14 @@ var authToken = async ({
464
464
  });
465
465
  const body = await response.json().catch(() => ({}));
466
466
  if (!response.ok) {
467
- throw new Error(
468
- renewing ? "The vendor refused the refresh token (" + response.status + ") \u2014 reconnect the connection" : "The vendor refused the exchange (" + response.status + ")" + ((body == null ? void 0 : body.error) ? ": " + body.error : "")
467
+ throw Object.assign(
468
+ new Error(
469
+ renewing ? "The vendor refused the refresh token (" + response.status + ") \u2014 reconnect the connection" : "The vendor refused the exchange (" + response.status + ")" + ((body == null ? void 0 : body.error) ? ": " + body.error : "")
470
+ ),
471
+ {
472
+ status: response.status,
473
+ ...renewing && { code: "grant_refused" }
474
+ }
469
475
  );
470
476
  }
471
477
  if (!renewing && !body.access_token) throw new Error("The vendor returned no access token");
@@ -521,7 +527,10 @@ var accessToken = async ({
521
527
  }
522
528
  if (!force && !isStale(settings, now)) return settings.accessToken;
523
529
  if (!settings.refreshToken) {
524
- throw new Error("This connection has expired and cannot be renewed automatically. Reconnect it.");
530
+ throw Object.assign(
531
+ new Error("This connection has expired and cannot be renewed automatically. Reconnect it."),
532
+ { code: "grant_refused" }
533
+ );
525
534
  }
526
535
  const minted = await manifest.hooks.auth.token({
527
536
  clientId,
@@ -2735,8 +2744,62 @@ var klaviyo_default2 = {
2735
2744
  sms: false,
2736
2745
  inbound: false,
2737
2746
  // Nothing to set up or tear down at the vendor: the grant is the whole
2738
- // integration, and revoking it is auth.disconnect's job.
2739
- lifecycle: false,
2747
+ // integration. What CAN rot is the grant itself, so health is the one
2748
+ // lifecycle hook Klaviyo carries.
2749
+ lifecycle: {
2750
+ // Nothing to set up, tear down, or re-register at the vendor — the
2751
+ // grant is the whole integration.
2752
+ cleanup: false,
2753
+ register: false,
2754
+ rehydrate: false,
2755
+ // KEEP THE GRANT PROVEN AND WARM. The shell mints a fresh access
2756
+ // token before every hook run — for Klaviyo that spends the refresh
2757
+ // token, which is the only question that reaches Klaviyo (see
2758
+ // auth.probe) — so this body running at all proves the grant still
2759
+ // rotates, and the daily run keeps it inside Klaviyo's 90-day idle
2760
+ // window. A dead grant never gets here: the shell's mint throws
2761
+ // `grant_refused` and the step runner errors the connection itself.
2762
+ //
2763
+ // The one call below proves the minted token is HONOURED — mint and
2764
+ // acceptance are different facts, and /accounts is already the call
2765
+ // the connect flow makes (auth.connect), so it needs no new scope.
2766
+ health: async ({ connection: connection2, token }, { fetcher, read } = {}) => {
2767
+ const request2 = { connectionId: connection2.id };
2768
+ try {
2769
+ await api2("/accounts", { fetcher, token });
2770
+ return {
2771
+ message: "Health check passed \u2014 token minted and accepted.",
2772
+ request: request2,
2773
+ response: { pingedAt: /* @__PURE__ */ new Date() }
2774
+ };
2775
+ } catch (error) {
2776
+ if ([401, 403].includes(error.status) && read) {
2777
+ const current = await read.get({ collection: "connection", query: { id: connection2.id } });
2778
+ if (current) {
2779
+ const others = (current.errors || []).filter((entry) => entry.source !== "oauth");
2780
+ error.writes = [{
2781
+ collection: "connection",
2782
+ data: {
2783
+ $set: {
2784
+ errors: [
2785
+ ...others,
2786
+ {
2787
+ message: "Klaviyo no longer accepts this connection. Reconnect Klaviyo to resume syncing.",
2788
+ source: "oauth"
2789
+ }
2790
+ ],
2791
+ status: "error"
2792
+ }
2793
+ },
2794
+ operation: "update",
2795
+ query: { id: connection2.id }
2796
+ }];
2797
+ }
2798
+ }
2799
+ throw error;
2800
+ }
2801
+ }
2802
+ },
2740
2803
  resources: {
2741
2804
  // The lists a merchant can sync into, for the picker on their
2742
2805
  // connection.
@@ -2812,6 +2875,19 @@ var klaviyo_default2 = {
2812
2875
  return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? data2.status : "pending";
2813
2876
  },
2814
2877
  steps: {
2878
+ connection: {
2879
+ // The daily grant check — same step type and workflow shape as
2880
+ // Shopify's, provisioned as a system workflow per connection.
2881
+ health: {
2882
+ check: () => ({
2883
+ description: "Keeps the Klaviyo grant working \u2014 spends the refresh token daily so a revoked or idle grant is reported instead of discovered by a failing sync.",
2884
+ hook: "lifecycle.health",
2885
+ key: "Klaviyo Connection Health",
2886
+ queue: "connection",
2887
+ system: true
2888
+ })
2889
+ }
2890
+ },
2815
2891
  contacts: {
2816
2892
  // A DECLARATION, not the work. It names the hook that does the work, and
2817
2893
  // says where that hook's values belong. Nested like the hooks, and the
@@ -741,8 +741,12 @@ const accessToken = async ({
741
741
 
742
742
  // Expired with no way to mint another. Said plainly rather than returning
743
743
  // a token the vendor will refuse — the merchant has to reconnect and the
744
- // error should say so.
745
- throw new Error( 'This connection has expired and cannot be renewed automatically. Reconnect it.' );
744
+ // error should say so. Same `code` as a refused refresh: both mean the
745
+ // grant is dead and only reconnecting revives it.
746
+ throw Object.assign(
747
+ new Error( 'This connection has expired and cannot be renewed automatically. Reconnect it.' ),
748
+ { code : 'grant_refused' }
749
+ );
746
750
 
747
751
  }
748
752
 
@@ -2941,8 +2945,84 @@ var klaviyo = {
2941
2945
  inbound : false,
2942
2946
 
2943
2947
  // Nothing to set up or tear down at the vendor: the grant is the whole
2944
- // integration, and revoking it is auth.disconnect's job.
2945
- lifecycle : false,
2948
+ // integration. What CAN rot is the grant itself, so health is the one
2949
+ // lifecycle hook Klaviyo carries.
2950
+ lifecycle : {
2951
+
2952
+ // Nothing to set up, tear down, or re-register at the vendor — the
2953
+ // grant is the whole integration.
2954
+ cleanup : false,
2955
+ register : false,
2956
+ rehydrate : false,
2957
+
2958
+ // KEEP THE GRANT PROVEN AND WARM. The shell mints a fresh access
2959
+ // token before every hook run — for Klaviyo that spends the refresh
2960
+ // token, which is the only question that reaches Klaviyo (see
2961
+ // auth.probe) — so this body running at all proves the grant still
2962
+ // rotates, and the daily run keeps it inside Klaviyo's 90-day idle
2963
+ // window. A dead grant never gets here: the shell's mint throws
2964
+ // `grant_refused` and the step runner errors the connection itself.
2965
+ //
2966
+ // The one call below proves the minted token is HONOURED — mint and
2967
+ // acceptance are different facts, and /accounts is already the call
2968
+ // the connect flow makes (auth.connect), so it needs no new scope.
2969
+ health : async ( { connection, token }, { fetcher, read } = {} ) => {
2970
+
2971
+ const request = { connectionId : connection.id };
2972
+
2973
+ try {
2974
+
2975
+ await api$1( '/accounts', { fetcher, token });
2976
+
2977
+ return {
2978
+ message : 'Health check passed — token minted and accepted.',
2979
+ request,
2980
+ response : { pingedAt : new Date() }
2981
+ };
2982
+
2983
+ } catch ( error ) {
2984
+
2985
+ // The token we JUST minted was refused — a revoked or
2986
+ // downgraded grant, never a transient. The write rides out
2987
+ // on the rejection (the shell performs a thrown error's
2988
+ // effects) because the step must still fail.
2989
+ if( [ 401, 403 ].includes( error.status ) && read ){
2990
+
2991
+ const current = await read.get({ collection : 'connection', query : { id : connection.id } });
2992
+
2993
+ if( current ){
2994
+
2995
+ const others = ( current.errors || [] ).filter( ( entry ) => entry.source !== 'oauth' );
2996
+
2997
+ error.writes = [ {
2998
+ collection : 'connection',
2999
+ data : {
3000
+ $set : {
3001
+ errors : [
3002
+ ...others,
3003
+ {
3004
+ message : 'Klaviyo no longer accepts this connection. Reconnect Klaviyo to resume syncing.',
3005
+ source : 'oauth'
3006
+ }
3007
+ ],
3008
+ status : 'error'
3009
+ }
3010
+ },
3011
+ operation : 'update',
3012
+ query : { id : connection.id }
3013
+ } ];
3014
+
3015
+ }
3016
+
3017
+ }
3018
+
3019
+ throw error;
3020
+
3021
+ }
3022
+
3023
+ }
3024
+
3025
+ },
2946
3026
 
2947
3027
  resources : {
2948
3028
 
@@ -3049,6 +3129,22 @@ var klaviyo = {
3049
3129
 
3050
3130
  steps : {
3051
3131
 
3132
+ connection : {
3133
+
3134
+ // The daily grant check — same step type and workflow shape as
3135
+ // Shopify's, provisioned as a system workflow per connection.
3136
+ health : {
3137
+ check : () => ({
3138
+ description : 'Keeps the Klaviyo grant working — spends the refresh token daily so a revoked or idle grant is reported instead of discovered by a failing sync.',
3139
+ hook : 'lifecycle.health',
3140
+ key : 'Klaviyo Connection Health',
3141
+ queue : 'connection',
3142
+ system : true
3143
+ })
3144
+ }
3145
+
3146
+ },
3147
+
3052
3148
  contacts : {
3053
3149
 
3054
3150
  // A DECLARATION, not the work. It names the hook that does the work, and
@@ -741,8 +741,12 @@ const accessToken = async ({
741
741
 
742
742
  // Expired with no way to mint another. Said plainly rather than returning
743
743
  // a token the vendor will refuse — the merchant has to reconnect and the
744
- // error should say so.
745
- throw new Error( 'This connection has expired and cannot be renewed automatically. Reconnect it.' );
744
+ // error should say so. Same `code` as a refused refresh: both mean the
745
+ // grant is dead and only reconnecting revives it.
746
+ throw Object.assign(
747
+ new Error( 'This connection has expired and cannot be renewed automatically. Reconnect it.' ),
748
+ { code : 'grant_refused' }
749
+ );
746
750
 
747
751
  }
748
752
 
@@ -2941,8 +2945,84 @@ var klaviyo = {
2941
2945
  inbound : false,
2942
2946
 
2943
2947
  // Nothing to set up or tear down at the vendor: the grant is the whole
2944
- // integration, and revoking it is auth.disconnect's job.
2945
- lifecycle : false,
2948
+ // integration. What CAN rot is the grant itself, so health is the one
2949
+ // lifecycle hook Klaviyo carries.
2950
+ lifecycle : {
2951
+
2952
+ // Nothing to set up, tear down, or re-register at the vendor — the
2953
+ // grant is the whole integration.
2954
+ cleanup : false,
2955
+ register : false,
2956
+ rehydrate : false,
2957
+
2958
+ // KEEP THE GRANT PROVEN AND WARM. The shell mints a fresh access
2959
+ // token before every hook run — for Klaviyo that spends the refresh
2960
+ // token, which is the only question that reaches Klaviyo (see
2961
+ // auth.probe) — so this body running at all proves the grant still
2962
+ // rotates, and the daily run keeps it inside Klaviyo's 90-day idle
2963
+ // window. A dead grant never gets here: the shell's mint throws
2964
+ // `grant_refused` and the step runner errors the connection itself.
2965
+ //
2966
+ // The one call below proves the minted token is HONOURED — mint and
2967
+ // acceptance are different facts, and /accounts is already the call
2968
+ // the connect flow makes (auth.connect), so it needs no new scope.
2969
+ health : async ( { connection, token }, { fetcher, read } = {} ) => {
2970
+
2971
+ const request = { connectionId : connection.id };
2972
+
2973
+ try {
2974
+
2975
+ await api$1( '/accounts', { fetcher, token });
2976
+
2977
+ return {
2978
+ message : 'Health check passed — token minted and accepted.',
2979
+ request,
2980
+ response : { pingedAt : new Date() }
2981
+ };
2982
+
2983
+ } catch ( error ) {
2984
+
2985
+ // The token we JUST minted was refused — a revoked or
2986
+ // downgraded grant, never a transient. The write rides out
2987
+ // on the rejection (the shell performs a thrown error's
2988
+ // effects) because the step must still fail.
2989
+ if( [ 401, 403 ].includes( error.status ) && read ){
2990
+
2991
+ const current = await read.get({ collection : 'connection', query : { id : connection.id } });
2992
+
2993
+ if( current ){
2994
+
2995
+ const others = ( current.errors || [] ).filter( ( entry ) => entry.source !== 'oauth' );
2996
+
2997
+ error.writes = [ {
2998
+ collection : 'connection',
2999
+ data : {
3000
+ $set : {
3001
+ errors : [
3002
+ ...others,
3003
+ {
3004
+ message : 'Klaviyo no longer accepts this connection. Reconnect Klaviyo to resume syncing.',
3005
+ source : 'oauth'
3006
+ }
3007
+ ],
3008
+ status : 'error'
3009
+ }
3010
+ },
3011
+ operation : 'update',
3012
+ query : { id : connection.id }
3013
+ } ];
3014
+
3015
+ }
3016
+
3017
+ }
3018
+
3019
+ throw error;
3020
+
3021
+ }
3022
+
3023
+ }
3024
+
3025
+ },
2946
3026
 
2947
3027
  resources : {
2948
3028
 
@@ -3049,6 +3129,22 @@ var klaviyo = {
3049
3129
 
3050
3130
  steps : {
3051
3131
 
3132
+ connection : {
3133
+
3134
+ // The daily grant check — same step type and workflow shape as
3135
+ // Shopify's, provisioned as a system workflow per connection.
3136
+ health : {
3137
+ check : () => ({
3138
+ description : 'Keeps the Klaviyo grant working — spends the refresh token daily so a revoked or idle grant is reported instead of discovered by a failing sync.',
3139
+ hook : 'lifecycle.health',
3140
+ key : 'Klaviyo Connection Health',
3141
+ queue : 'connection',
3142
+ system : true
3143
+ })
3144
+ }
3145
+
3146
+ },
3147
+
3052
3148
  contacts : {
3053
3149
 
3054
3150
  // A DECLARATION, not the work. It names the hook that does the work, and
@@ -390,8 +390,14 @@ var authToken = async ({
390
390
  });
391
391
  const body = await response.json().catch(() => ({}));
392
392
  if (!response.ok) {
393
- throw new Error(
394
- renewing ? "The vendor refused the refresh token (" + response.status + ") \u2014 reconnect the connection" : "The vendor refused the exchange (" + response.status + ")" + ((body == null ? void 0 : body.error) ? ": " + body.error : "")
393
+ throw Object.assign(
394
+ new Error(
395
+ renewing ? "The vendor refused the refresh token (" + response.status + ") \u2014 reconnect the connection" : "The vendor refused the exchange (" + response.status + ")" + ((body == null ? void 0 : body.error) ? ": " + body.error : "")
396
+ ),
397
+ {
398
+ status: response.status,
399
+ ...renewing && { code: "grant_refused" }
400
+ }
395
401
  );
396
402
  }
397
403
  if (!renewing && !body.access_token) throw new Error("The vendor returned no access token");
@@ -447,7 +453,10 @@ var accessToken = async ({
447
453
  }
448
454
  if (!force && !isStale(settings, now)) return settings.accessToken;
449
455
  if (!settings.refreshToken) {
450
- throw new Error("This connection has expired and cannot be renewed automatically. Reconnect it.");
456
+ throw Object.assign(
457
+ new Error("This connection has expired and cannot be renewed automatically. Reconnect it."),
458
+ { code: "grant_refused" }
459
+ );
451
460
  }
452
461
  const minted = await manifest.hooks.auth.token({
453
462
  clientId,
@@ -2661,8 +2670,62 @@ var klaviyo_default2 = {
2661
2670
  sms: false,
2662
2671
  inbound: false,
2663
2672
  // Nothing to set up or tear down at the vendor: the grant is the whole
2664
- // integration, and revoking it is auth.disconnect's job.
2665
- lifecycle: false,
2673
+ // integration. What CAN rot is the grant itself, so health is the one
2674
+ // lifecycle hook Klaviyo carries.
2675
+ lifecycle: {
2676
+ // Nothing to set up, tear down, or re-register at the vendor — the
2677
+ // grant is the whole integration.
2678
+ cleanup: false,
2679
+ register: false,
2680
+ rehydrate: false,
2681
+ // KEEP THE GRANT PROVEN AND WARM. The shell mints a fresh access
2682
+ // token before every hook run — for Klaviyo that spends the refresh
2683
+ // token, which is the only question that reaches Klaviyo (see
2684
+ // auth.probe) — so this body running at all proves the grant still
2685
+ // rotates, and the daily run keeps it inside Klaviyo's 90-day idle
2686
+ // window. A dead grant never gets here: the shell's mint throws
2687
+ // `grant_refused` and the step runner errors the connection itself.
2688
+ //
2689
+ // The one call below proves the minted token is HONOURED — mint and
2690
+ // acceptance are different facts, and /accounts is already the call
2691
+ // the connect flow makes (auth.connect), so it needs no new scope.
2692
+ health: async ({ connection: connection2, token }, { fetcher, read } = {}) => {
2693
+ const request2 = { connectionId: connection2.id };
2694
+ try {
2695
+ await api2("/accounts", { fetcher, token });
2696
+ return {
2697
+ message: "Health check passed \u2014 token minted and accepted.",
2698
+ request: request2,
2699
+ response: { pingedAt: /* @__PURE__ */ new Date() }
2700
+ };
2701
+ } catch (error) {
2702
+ if ([401, 403].includes(error.status) && read) {
2703
+ const current = await read.get({ collection: "connection", query: { id: connection2.id } });
2704
+ if (current) {
2705
+ const others = (current.errors || []).filter((entry) => entry.source !== "oauth");
2706
+ error.writes = [{
2707
+ collection: "connection",
2708
+ data: {
2709
+ $set: {
2710
+ errors: [
2711
+ ...others,
2712
+ {
2713
+ message: "Klaviyo no longer accepts this connection. Reconnect Klaviyo to resume syncing.",
2714
+ source: "oauth"
2715
+ }
2716
+ ],
2717
+ status: "error"
2718
+ }
2719
+ },
2720
+ operation: "update",
2721
+ query: { id: connection2.id }
2722
+ }];
2723
+ }
2724
+ }
2725
+ throw error;
2726
+ }
2727
+ }
2728
+ },
2666
2729
  resources: {
2667
2730
  // The lists a merchant can sync into, for the picker on their
2668
2731
  // connection.
@@ -2738,6 +2801,19 @@ var klaviyo_default2 = {
2738
2801
  return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? data2.status : "pending";
2739
2802
  },
2740
2803
  steps: {
2804
+ connection: {
2805
+ // The daily grant check — same step type and workflow shape as
2806
+ // Shopify's, provisioned as a system workflow per connection.
2807
+ health: {
2808
+ check: () => ({
2809
+ description: "Keeps the Klaviyo grant working \u2014 spends the refresh token daily so a revoked or idle grant is reported instead of discovered by a failing sync.",
2810
+ hook: "lifecycle.health",
2811
+ key: "Klaviyo Connection Health",
2812
+ queue: "connection",
2813
+ system: true
2814
+ })
2815
+ }
2816
+ },
2741
2817
  contacts: {
2742
2818
  // A DECLARATION, not the work. It names the hook that does the work, and
2743
2819
  // says where that hook's values belong. Nested like the hooks, and the
@@ -94,8 +94,14 @@ var authToken = async ({
94
94
  });
95
95
  const body = await response.json().catch(() => ({}));
96
96
  if (!response.ok) {
97
- throw new Error(
98
- renewing ? "The vendor refused the refresh token (" + response.status + ") \u2014 reconnect the connection" : "The vendor refused the exchange (" + response.status + ")" + ((body == null ? void 0 : body.error) ? ": " + body.error : "")
97
+ throw Object.assign(
98
+ new Error(
99
+ renewing ? "The vendor refused the refresh token (" + response.status + ") \u2014 reconnect the connection" : "The vendor refused the exchange (" + response.status + ")" + ((body == null ? void 0 : body.error) ? ": " + body.error : "")
100
+ ),
101
+ {
102
+ status: response.status,
103
+ ...renewing && { code: "grant_refused" }
104
+ }
99
105
  );
100
106
  }
101
107
  if (!renewing && !body.access_token) throw new Error("The vendor returned no access token");
@@ -173,9 +173,20 @@ const authToken = async ({
173
173
  // The two paths fail differently for the merchant: a refused exchange is
174
174
  // "try connecting again", a refused refresh is "reconnect this connection",
175
175
  // and collapsing them sends somebody to the wrong screen.
176
- throw new Error( renewing
177
- ? 'The vendor refused the refresh token (' + response.status + ') reconnect the connection'
178
- : 'The vendor refused the exchange (' + response.status + ')' + ( body?.error ? ': ' + body.error : '' )
176
+ //
177
+ // `code` marks the refused REFRESH machine-readably — the health-check
178
+ // shell errors the connection on it rather than retrying a dead grant
179
+ // into the log forever. Only the renewing branch: a refused exchange is
180
+ // a connect-flow failure with a person watching.
181
+ throw Object.assign(
182
+ new Error( renewing
183
+ ? 'The vendor refused the refresh token (' + response.status + ') — reconnect the connection'
184
+ : 'The vendor refused the exchange (' + response.status + ')' + ( body?.error ? ': ' + body.error : '' )
185
+ ),
186
+ {
187
+ status : response.status,
188
+ ...( renewing && { code : 'grant_refused' })
189
+ }
179
190
  );
180
191
 
181
192
  }
@@ -173,9 +173,20 @@ const authToken = async ({
173
173
  // The two paths fail differently for the merchant: a refused exchange is
174
174
  // "try connecting again", a refused refresh is "reconnect this connection",
175
175
  // and collapsing them sends somebody to the wrong screen.
176
- throw new Error( renewing
177
- ? 'The vendor refused the refresh token (' + response.status + ') reconnect the connection'
178
- : 'The vendor refused the exchange (' + response.status + ')' + ( body?.error ? ': ' + body.error : '' )
176
+ //
177
+ // `code` marks the refused REFRESH machine-readably — the health-check
178
+ // shell errors the connection on it rather than retrying a dead grant
179
+ // into the log forever. Only the renewing branch: a refused exchange is
180
+ // a connect-flow failure with a person watching.
181
+ throw Object.assign(
182
+ new Error( renewing
183
+ ? 'The vendor refused the refresh token (' + response.status + ') — reconnect the connection'
184
+ : 'The vendor refused the exchange (' + response.status + ')' + ( body?.error ? ': ' + body.error : '' )
185
+ ),
186
+ {
187
+ status : response.status,
188
+ ...( renewing && { code : 'grant_refused' })
189
+ }
179
190
  );
180
191
 
181
192
  }
@@ -69,8 +69,14 @@ var authToken = async ({
69
69
  });
70
70
  const body = await response.json().catch(() => ({}));
71
71
  if (!response.ok) {
72
- throw new Error(
73
- renewing ? "The vendor refused the refresh token (" + response.status + ") \u2014 reconnect the connection" : "The vendor refused the exchange (" + response.status + ")" + ((body == null ? void 0 : body.error) ? ": " + body.error : "")
72
+ throw Object.assign(
73
+ new Error(
74
+ renewing ? "The vendor refused the refresh token (" + response.status + ") \u2014 reconnect the connection" : "The vendor refused the exchange (" + response.status + ")" + ((body == null ? void 0 : body.error) ? ": " + body.error : "")
75
+ ),
76
+ {
77
+ status: response.status,
78
+ ...renewing && { code: "grant_refused" }
79
+ }
74
80
  );
75
81
  }
76
82
  if (!renewing && !body.access_token) throw new Error("The vendor returned no access token");
@@ -370,8 +370,14 @@ var authToken = async ({
370
370
  });
371
371
  const body = await response.json().catch(() => ({}));
372
372
  if (!response.ok) {
373
- throw new Error(
374
- renewing ? "The vendor refused the refresh token (" + response.status + ") \u2014 reconnect the connection" : "The vendor refused the exchange (" + response.status + ")" + ((body == null ? void 0 : body.error) ? ": " + body.error : "")
373
+ throw Object.assign(
374
+ new Error(
375
+ renewing ? "The vendor refused the refresh token (" + response.status + ") \u2014 reconnect the connection" : "The vendor refused the exchange (" + response.status + ")" + ((body == null ? void 0 : body.error) ? ": " + body.error : "")
376
+ ),
377
+ {
378
+ status: response.status,
379
+ ...renewing && { code: "grant_refused" }
380
+ }
375
381
  );
376
382
  }
377
383
  if (!renewing && !body.access_token) throw new Error("The vendor returned no access token");
@@ -427,7 +433,10 @@ var accessToken = async ({
427
433
  }
428
434
  if (!force && !isStale(settings, now)) return settings.accessToken;
429
435
  if (!settings.refreshToken) {
430
- throw new Error("This connection has expired and cannot be renewed automatically. Reconnect it.");
436
+ throw Object.assign(
437
+ new Error("This connection has expired and cannot be renewed automatically. Reconnect it."),
438
+ { code: "grant_refused" }
439
+ );
431
440
  }
432
441
  const minted = await manifest.hooks.auth.token({
433
442
  clientId,
@@ -2641,8 +2650,62 @@ var klaviyo_default2 = {
2641
2650
  sms: false,
2642
2651
  inbound: false,
2643
2652
  // Nothing to set up or tear down at the vendor: the grant is the whole
2644
- // integration, and revoking it is auth.disconnect's job.
2645
- lifecycle: false,
2653
+ // integration. What CAN rot is the grant itself, so health is the one
2654
+ // lifecycle hook Klaviyo carries.
2655
+ lifecycle: {
2656
+ // Nothing to set up, tear down, or re-register at the vendor — the
2657
+ // grant is the whole integration.
2658
+ cleanup: false,
2659
+ register: false,
2660
+ rehydrate: false,
2661
+ // KEEP THE GRANT PROVEN AND WARM. The shell mints a fresh access
2662
+ // token before every hook run — for Klaviyo that spends the refresh
2663
+ // token, which is the only question that reaches Klaviyo (see
2664
+ // auth.probe) — so this body running at all proves the grant still
2665
+ // rotates, and the daily run keeps it inside Klaviyo's 90-day idle
2666
+ // window. A dead grant never gets here: the shell's mint throws
2667
+ // `grant_refused` and the step runner errors the connection itself.
2668
+ //
2669
+ // The one call below proves the minted token is HONOURED — mint and
2670
+ // acceptance are different facts, and /accounts is already the call
2671
+ // the connect flow makes (auth.connect), so it needs no new scope.
2672
+ health: async ({ connection: connection2, token }, { fetcher, read } = {}) => {
2673
+ const request2 = { connectionId: connection2.id };
2674
+ try {
2675
+ await api2("/accounts", { fetcher, token });
2676
+ return {
2677
+ message: "Health check passed \u2014 token minted and accepted.",
2678
+ request: request2,
2679
+ response: { pingedAt: /* @__PURE__ */ new Date() }
2680
+ };
2681
+ } catch (error) {
2682
+ if ([401, 403].includes(error.status) && read) {
2683
+ const current = await read.get({ collection: "connection", query: { id: connection2.id } });
2684
+ if (current) {
2685
+ const others = (current.errors || []).filter((entry) => entry.source !== "oauth");
2686
+ error.writes = [{
2687
+ collection: "connection",
2688
+ data: {
2689
+ $set: {
2690
+ errors: [
2691
+ ...others,
2692
+ {
2693
+ message: "Klaviyo no longer accepts this connection. Reconnect Klaviyo to resume syncing.",
2694
+ source: "oauth"
2695
+ }
2696
+ ],
2697
+ status: "error"
2698
+ }
2699
+ },
2700
+ operation: "update",
2701
+ query: { id: connection2.id }
2702
+ }];
2703
+ }
2704
+ }
2705
+ throw error;
2706
+ }
2707
+ }
2708
+ },
2646
2709
  resources: {
2647
2710
  // The lists a merchant can sync into, for the picker on their
2648
2711
  // connection.
@@ -2718,6 +2781,19 @@ var klaviyo_default2 = {
2718
2781
  return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? data2.status : "pending";
2719
2782
  },
2720
2783
  steps: {
2784
+ connection: {
2785
+ // The daily grant check — same step type and workflow shape as
2786
+ // Shopify's, provisioned as a system workflow per connection.
2787
+ health: {
2788
+ check: () => ({
2789
+ description: "Keeps the Klaviyo grant working \u2014 spends the refresh token daily so a revoked or idle grant is reported instead of discovered by a failing sync.",
2790
+ hook: "lifecycle.health",
2791
+ key: "Klaviyo Connection Health",
2792
+ queue: "connection",
2793
+ system: true
2794
+ })
2795
+ }
2796
+ },
2721
2797
  contacts: {
2722
2798
  // A DECLARATION, not the work. It names the hook that does the work, and
2723
2799
  // says where that hook's values belong. Nested like the hooks, and the
package/dist/providers.js CHANGED
@@ -326,8 +326,14 @@ var authToken = async ({
326
326
  });
327
327
  const body = await response.json().catch(() => ({}));
328
328
  if (!response.ok) {
329
- throw new Error(
330
- renewing ? "The vendor refused the refresh token (" + response.status + ") \u2014 reconnect the connection" : "The vendor refused the exchange (" + response.status + ")" + ((body == null ? void 0 : body.error) ? ": " + body.error : "")
329
+ throw Object.assign(
330
+ new Error(
331
+ renewing ? "The vendor refused the refresh token (" + response.status + ") \u2014 reconnect the connection" : "The vendor refused the exchange (" + response.status + ")" + ((body == null ? void 0 : body.error) ? ": " + body.error : "")
332
+ ),
333
+ {
334
+ status: response.status,
335
+ ...renewing && { code: "grant_refused" }
336
+ }
331
337
  );
332
338
  }
333
339
  if (!renewing && !body.access_token) throw new Error("The vendor returned no access token");
@@ -383,7 +389,10 @@ var accessToken = async ({
383
389
  }
384
390
  if (!force && !isStale(settings, now)) return settings.accessToken;
385
391
  if (!settings.refreshToken) {
386
- throw new Error("This connection has expired and cannot be renewed automatically. Reconnect it.");
392
+ throw Object.assign(
393
+ new Error("This connection has expired and cannot be renewed automatically. Reconnect it."),
394
+ { code: "grant_refused" }
395
+ );
387
396
  }
388
397
  const minted = await manifest.hooks.auth.token({
389
398
  clientId,
@@ -2597,8 +2606,62 @@ var klaviyo_default2 = {
2597
2606
  sms: false,
2598
2607
  inbound: false,
2599
2608
  // Nothing to set up or tear down at the vendor: the grant is the whole
2600
- // integration, and revoking it is auth.disconnect's job.
2601
- lifecycle: false,
2609
+ // integration. What CAN rot is the grant itself, so health is the one
2610
+ // lifecycle hook Klaviyo carries.
2611
+ lifecycle: {
2612
+ // Nothing to set up, tear down, or re-register at the vendor — the
2613
+ // grant is the whole integration.
2614
+ cleanup: false,
2615
+ register: false,
2616
+ rehydrate: false,
2617
+ // KEEP THE GRANT PROVEN AND WARM. The shell mints a fresh access
2618
+ // token before every hook run — for Klaviyo that spends the refresh
2619
+ // token, which is the only question that reaches Klaviyo (see
2620
+ // auth.probe) — so this body running at all proves the grant still
2621
+ // rotates, and the daily run keeps it inside Klaviyo's 90-day idle
2622
+ // window. A dead grant never gets here: the shell's mint throws
2623
+ // `grant_refused` and the step runner errors the connection itself.
2624
+ //
2625
+ // The one call below proves the minted token is HONOURED — mint and
2626
+ // acceptance are different facts, and /accounts is already the call
2627
+ // the connect flow makes (auth.connect), so it needs no new scope.
2628
+ health: async ({ connection: connection2, token }, { fetcher, read } = {}) => {
2629
+ const request2 = { connectionId: connection2.id };
2630
+ try {
2631
+ await api2("/accounts", { fetcher, token });
2632
+ return {
2633
+ message: "Health check passed \u2014 token minted and accepted.",
2634
+ request: request2,
2635
+ response: { pingedAt: /* @__PURE__ */ new Date() }
2636
+ };
2637
+ } catch (error) {
2638
+ if ([401, 403].includes(error.status) && read) {
2639
+ const current = await read.get({ collection: "connection", query: { id: connection2.id } });
2640
+ if (current) {
2641
+ const others = (current.errors || []).filter((entry) => entry.source !== "oauth");
2642
+ error.writes = [{
2643
+ collection: "connection",
2644
+ data: {
2645
+ $set: {
2646
+ errors: [
2647
+ ...others,
2648
+ {
2649
+ message: "Klaviyo no longer accepts this connection. Reconnect Klaviyo to resume syncing.",
2650
+ source: "oauth"
2651
+ }
2652
+ ],
2653
+ status: "error"
2654
+ }
2655
+ },
2656
+ operation: "update",
2657
+ query: { id: connection2.id }
2658
+ }];
2659
+ }
2660
+ }
2661
+ throw error;
2662
+ }
2663
+ }
2664
+ },
2602
2665
  resources: {
2603
2666
  // The lists a merchant can sync into, for the picker on their
2604
2667
  // connection.
@@ -2674,6 +2737,19 @@ var klaviyo_default2 = {
2674
2737
  return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? data2.status : "pending";
2675
2738
  },
2676
2739
  steps: {
2740
+ connection: {
2741
+ // The daily grant check — same step type and workflow shape as
2742
+ // Shopify's, provisioned as a system workflow per connection.
2743
+ health: {
2744
+ check: () => ({
2745
+ description: "Keeps the Klaviyo grant working \u2014 spends the refresh token daily so a revoked or idle grant is reported instead of discovered by a failing sync.",
2746
+ hook: "lifecycle.health",
2747
+ key: "Klaviyo Connection Health",
2748
+ queue: "connection",
2749
+ system: true
2750
+ })
2751
+ }
2752
+ },
2677
2753
  contacts: {
2678
2754
  // A DECLARATION, not the work. It names the hook that does the work, and
2679
2755
  // says where that hook's values belong. Nested like the hooks, and the
package/dist/twilio.cjs CHANGED
@@ -174,6 +174,57 @@ var twilio = {
174
174
  sid: result == null ? void 0 : result.sid
175
175
  };
176
176
  },
177
+ // THE NUMBER AS TWILIO HOLDS IT NOW. Same IncomingPhoneNumber resource the
178
+ // purchase and release above use (api.twilio.com 2010-04-01, GET
179
+ // /IncomingPhoneNumbers/{sid}.json) — a 404 means the number is no longer
180
+ // on the account, which the health sweep must distinguish from a transient,
181
+ // so it is answered as { gone : true } rather than thrown.
182
+ numberConfig: async ({ accountSid, authToken, request: request2 = request, sid }) => {
183
+ if (!accountSid || !authToken) throw new Error("Twilio credentials missing \u2014 accountSid, authToken");
184
+ if (!sid) throw new Error("Twilio config read needs the IncomingPhoneNumber sid");
185
+ let result;
186
+ try {
187
+ result = await request2({
188
+ method: "GET",
189
+ url: "https://api.twilio.com/2010-04-01/Accounts/" + accountSid + "/IncomingPhoneNumbers/" + sid + ".json",
190
+ headers: {
191
+ "Authorization": "Basic " + Buffer.from(accountSid + ":" + authToken).toString("base64")
192
+ }
193
+ });
194
+ } catch (error) {
195
+ if ((error == null ? void 0 : error.status) === 404) return { gone: true };
196
+ throw error;
197
+ }
198
+ return {
199
+ gone: false,
200
+ number: result == null ? void 0 : result.phone_number,
201
+ smsMethod: result == null ? void 0 : result.sms_method,
202
+ smsUrl: result == null ? void 0 : result.sms_url,
203
+ status: result == null ? void 0 : result.status
204
+ };
205
+ },
206
+ // RE-POINT AN OWNED NUMBER'S INBOUND WEBHOOK — POST on the same resource,
207
+ // the exact fields purchase sets. The health sweep's repair arm.
208
+ updateNumber: async ({ accountSid, authToken, request: request2 = request, sid, smsUrl }) => {
209
+ if (!accountSid || !authToken) throw new Error("Twilio credentials missing \u2014 accountSid, authToken");
210
+ if (!sid) throw new Error("Twilio update needs the IncomingPhoneNumber sid");
211
+ if (!smsUrl) throw new Error("Twilio update needs the smsUrl to point the number at");
212
+ const result = await request2({
213
+ method: "POST",
214
+ type: "form",
215
+ url: "https://api.twilio.com/2010-04-01/Accounts/" + accountSid + "/IncomingPhoneNumbers/" + sid + ".json",
216
+ headers: {
217
+ "Authorization": "Basic " + Buffer.from(accountSid + ":" + authToken).toString("base64")
218
+ },
219
+ body: {
220
+ SmsUrl: smsUrl,
221
+ SmsMethod: "POST"
222
+ }
223
+ });
224
+ return {
225
+ smsUrl: result == null ? void 0 : result.sms_url
226
+ };
227
+ },
177
228
  releaseNumber: async ({ accountSid, authToken, request: request2 = request, sid }) => {
178
229
  if (!accountSid || !authToken) throw new Error("Twilio credentials missing \u2014 accountSid, authToken");
179
230
  if (!sid) throw new Error("Twilio release needs the IncomingPhoneNumber sid");
package/dist/twilio.d.cts CHANGED
@@ -159,6 +159,73 @@ const twilio = {
159
159
 
160
160
  },
161
161
 
162
+ // THE NUMBER AS TWILIO HOLDS IT NOW. Same IncomingPhoneNumber resource the
163
+ // purchase and release above use (api.twilio.com 2010-04-01, GET
164
+ // /IncomingPhoneNumbers/{sid}.json) — a 404 means the number is no longer
165
+ // on the account, which the health sweep must distinguish from a transient,
166
+ // so it is answered as { gone : true } rather than thrown.
167
+ numberConfig : async ({ accountSid, authToken, request: request$1 = request, sid }) => {
168
+
169
+ if( ! accountSid || ! authToken ) throw new Error( 'Twilio credentials missing — accountSid, authToken' );
170
+ if( ! sid ) throw new Error( 'Twilio config read needs the IncomingPhoneNumber sid' );
171
+
172
+ let result;
173
+
174
+ try {
175
+
176
+ result = await request$1({
177
+ method : 'GET',
178
+ url : 'https://api.twilio.com/2010-04-01/Accounts/' + accountSid + '/IncomingPhoneNumbers/' + sid + '.json',
179
+ headers : {
180
+ 'Authorization' : 'Basic ' + Buffer.from( accountSid + ':' + authToken ).toString( 'base64' )
181
+ }
182
+ });
183
+
184
+ } catch ( error ) {
185
+
186
+ if( error?.status === 404 ) return { gone : true };
187
+
188
+ throw error;
189
+
190
+ }
191
+
192
+ return {
193
+ gone : false,
194
+ number : result?.phone_number,
195
+ smsMethod : result?.sms_method,
196
+ smsUrl : result?.sms_url,
197
+ status : result?.status
198
+ };
199
+
200
+ },
201
+
202
+ // RE-POINT AN OWNED NUMBER'S INBOUND WEBHOOK — POST on the same resource,
203
+ // the exact fields purchase sets. The health sweep's repair arm.
204
+ updateNumber : async ({ accountSid, authToken, request: request$1 = request, sid, smsUrl }) => {
205
+
206
+ if( ! accountSid || ! authToken ) throw new Error( 'Twilio credentials missing — accountSid, authToken' );
207
+ if( ! sid ) throw new Error( 'Twilio update needs the IncomingPhoneNumber sid' );
208
+ if( ! smsUrl ) throw new Error( 'Twilio update needs the smsUrl to point the number at' );
209
+
210
+ const result = await request$1({
211
+ method : 'POST',
212
+ type : 'form',
213
+ url : 'https://api.twilio.com/2010-04-01/Accounts/' + accountSid + '/IncomingPhoneNumbers/' + sid + '.json',
214
+ headers : {
215
+ 'Authorization' : 'Basic ' + Buffer.from( accountSid + ':' + authToken ).toString( 'base64' )
216
+ },
217
+ body : {
218
+ SmsUrl : smsUrl,
219
+ SmsMethod : 'POST'
220
+ }
221
+ });
222
+
223
+ return {
224
+ smsUrl : result?.sms_url
225
+ };
226
+
227
+ },
228
+
162
229
  releaseNumber : async ({ accountSid, authToken, request: request$1 = request, sid }) => {
163
230
 
164
231
  if( ! accountSid || ! authToken ) throw new Error( 'Twilio credentials missing — accountSid, authToken' );
package/dist/twilio.d.ts CHANGED
@@ -159,6 +159,73 @@ const twilio = {
159
159
 
160
160
  },
161
161
 
162
+ // THE NUMBER AS TWILIO HOLDS IT NOW. Same IncomingPhoneNumber resource the
163
+ // purchase and release above use (api.twilio.com 2010-04-01, GET
164
+ // /IncomingPhoneNumbers/{sid}.json) — a 404 means the number is no longer
165
+ // on the account, which the health sweep must distinguish from a transient,
166
+ // so it is answered as { gone : true } rather than thrown.
167
+ numberConfig : async ({ accountSid, authToken, request: request$1 = request, sid }) => {
168
+
169
+ if( ! accountSid || ! authToken ) throw new Error( 'Twilio credentials missing — accountSid, authToken' );
170
+ if( ! sid ) throw new Error( 'Twilio config read needs the IncomingPhoneNumber sid' );
171
+
172
+ let result;
173
+
174
+ try {
175
+
176
+ result = await request$1({
177
+ method : 'GET',
178
+ url : 'https://api.twilio.com/2010-04-01/Accounts/' + accountSid + '/IncomingPhoneNumbers/' + sid + '.json',
179
+ headers : {
180
+ 'Authorization' : 'Basic ' + Buffer.from( accountSid + ':' + authToken ).toString( 'base64' )
181
+ }
182
+ });
183
+
184
+ } catch ( error ) {
185
+
186
+ if( error?.status === 404 ) return { gone : true };
187
+
188
+ throw error;
189
+
190
+ }
191
+
192
+ return {
193
+ gone : false,
194
+ number : result?.phone_number,
195
+ smsMethod : result?.sms_method,
196
+ smsUrl : result?.sms_url,
197
+ status : result?.status
198
+ };
199
+
200
+ },
201
+
202
+ // RE-POINT AN OWNED NUMBER'S INBOUND WEBHOOK — POST on the same resource,
203
+ // the exact fields purchase sets. The health sweep's repair arm.
204
+ updateNumber : async ({ accountSid, authToken, request: request$1 = request, sid, smsUrl }) => {
205
+
206
+ if( ! accountSid || ! authToken ) throw new Error( 'Twilio credentials missing — accountSid, authToken' );
207
+ if( ! sid ) throw new Error( 'Twilio update needs the IncomingPhoneNumber sid' );
208
+ if( ! smsUrl ) throw new Error( 'Twilio update needs the smsUrl to point the number at' );
209
+
210
+ const result = await request$1({
211
+ method : 'POST',
212
+ type : 'form',
213
+ url : 'https://api.twilio.com/2010-04-01/Accounts/' + accountSid + '/IncomingPhoneNumbers/' + sid + '.json',
214
+ headers : {
215
+ 'Authorization' : 'Basic ' + Buffer.from( accountSid + ':' + authToken ).toString( 'base64' )
216
+ },
217
+ body : {
218
+ SmsUrl : smsUrl,
219
+ SmsMethod : 'POST'
220
+ }
221
+ });
222
+
223
+ return {
224
+ smsUrl : result?.sms_url
225
+ };
226
+
227
+ },
228
+
162
229
  releaseNumber : async ({ accountSid, authToken, request: request$1 = request, sid }) => {
163
230
 
164
231
  if( ! accountSid || ! authToken ) throw new Error( 'Twilio credentials missing — accountSid, authToken' );
package/dist/twilio.js CHANGED
@@ -149,6 +149,57 @@ var twilio = {
149
149
  sid: result == null ? void 0 : result.sid
150
150
  };
151
151
  },
152
+ // THE NUMBER AS TWILIO HOLDS IT NOW. Same IncomingPhoneNumber resource the
153
+ // purchase and release above use (api.twilio.com 2010-04-01, GET
154
+ // /IncomingPhoneNumbers/{sid}.json) — a 404 means the number is no longer
155
+ // on the account, which the health sweep must distinguish from a transient,
156
+ // so it is answered as { gone : true } rather than thrown.
157
+ numberConfig: async ({ accountSid, authToken, request: request2 = request, sid }) => {
158
+ if (!accountSid || !authToken) throw new Error("Twilio credentials missing \u2014 accountSid, authToken");
159
+ if (!sid) throw new Error("Twilio config read needs the IncomingPhoneNumber sid");
160
+ let result;
161
+ try {
162
+ result = await request2({
163
+ method: "GET",
164
+ url: "https://api.twilio.com/2010-04-01/Accounts/" + accountSid + "/IncomingPhoneNumbers/" + sid + ".json",
165
+ headers: {
166
+ "Authorization": "Basic " + Buffer.from(accountSid + ":" + authToken).toString("base64")
167
+ }
168
+ });
169
+ } catch (error) {
170
+ if ((error == null ? void 0 : error.status) === 404) return { gone: true };
171
+ throw error;
172
+ }
173
+ return {
174
+ gone: false,
175
+ number: result == null ? void 0 : result.phone_number,
176
+ smsMethod: result == null ? void 0 : result.sms_method,
177
+ smsUrl: result == null ? void 0 : result.sms_url,
178
+ status: result == null ? void 0 : result.status
179
+ };
180
+ },
181
+ // RE-POINT AN OWNED NUMBER'S INBOUND WEBHOOK — POST on the same resource,
182
+ // the exact fields purchase sets. The health sweep's repair arm.
183
+ updateNumber: async ({ accountSid, authToken, request: request2 = request, sid, smsUrl }) => {
184
+ if (!accountSid || !authToken) throw new Error("Twilio credentials missing \u2014 accountSid, authToken");
185
+ if (!sid) throw new Error("Twilio update needs the IncomingPhoneNumber sid");
186
+ if (!smsUrl) throw new Error("Twilio update needs the smsUrl to point the number at");
187
+ const result = await request2({
188
+ method: "POST",
189
+ type: "form",
190
+ url: "https://api.twilio.com/2010-04-01/Accounts/" + accountSid + "/IncomingPhoneNumbers/" + sid + ".json",
191
+ headers: {
192
+ "Authorization": "Basic " + Buffer.from(accountSid + ":" + authToken).toString("base64")
193
+ },
194
+ body: {
195
+ SmsUrl: smsUrl,
196
+ SmsMethod: "POST"
197
+ }
198
+ });
199
+ return {
200
+ smsUrl: result == null ? void 0 : result.sms_url
201
+ };
202
+ },
152
203
  releaseNumber: async ({ accountSid, authToken, request: request2 = request, sid }) => {
153
204
  if (!accountSid || !authToken) throw new Error("Twilio credentials missing \u2014 accountSid, authToken");
154
205
  if (!sid) throw new Error("Twilio release needs the IncomingPhoneNumber sid");
package/package.json CHANGED
@@ -215,5 +215,5 @@
215
215
  "test": ". \"$HOME/.nvm/nvm.sh\" && nvm use && node --test"
216
216
  },
217
217
  "types": "dist/index.d.ts",
218
- "version": "0.0.134"
218
+ "version": "0.0.135"
219
219
  }