@drawbridge/drawbridge-utils 0.0.133 → 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.
@@ -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
@@ -107,28 +107,37 @@ var twilio = {
107
107
  // NUMBER LIFECYCLE, same transport discipline as sms above. Endpoints and
108
108
  // parameter names are Twilio's own, from the AvailablePhoneNumbers and
109
109
  // IncomingPhoneNumbers resource docs:
110
- // GET /2010-04-01/Accounts/{AccountSid}/AvailablePhoneNumbers/{CountryCode}/Local.json
111
- // query: SmsEnabled, AreaCode (US/CA only), PageSize (1-1000)
110
+ // GET /2010-04-01/Accounts/{AccountSid}/AvailablePhoneNumbers/{CountryCode}/TollFree.json
111
+ // query: SmsEnabled, Contains, PageSize (1-1000)
112
112
  // POST /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json
113
113
  // body: PhoneNumber (E.164), SmsUrl, SmsMethod
114
114
  // DELETE /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers/{Sid}.json
115
115
  // Basic auth throughout, like Messages.
116
- searchNumbers: async ({ accountSid, areaCode, authToken, contains, country = "US", cursor, limit = 10, request: request2 = request }) => {
116
+ //
117
+ // TOLL-FREE, not Local, and that is a carrier-registration decision rather
118
+ // than an inventory one. A US local number may not send A2P SMS without full
119
+ // 10DLC brand-and-campaign registration; a toll-free number needs only
120
+ // Toll-Free Verification — one API submission per number (see
121
+ // submitVerification below), no TCR brand, no campaign fees. Until a
122
+ // verification is approved, Twilio blocks the number's US-bound messages
123
+ // outright — which is why a purchased number starts `pending` and the send
124
+ // path refuses anything but `verified`.
125
+ searchNumbers: async ({ accountSid, authToken, contains, country = "US", cursor, limit = 10, request: request2 = request }) => {
117
126
  if (!accountSid || !authToken) throw new Error("Twilio credentials missing \u2014 accountSid, authToken");
118
127
  const page = Number(cursor) || 0;
119
128
  const result = await request2({
120
129
  method: "GET",
121
- url: "https://api.twilio.com/2010-04-01/Accounts/" + accountSid + "/AvailablePhoneNumbers/" + country + "/Local.json",
130
+ url: "https://api.twilio.com/2010-04-01/Accounts/" + accountSid + "/AvailablePhoneNumbers/" + country + "/TollFree.json",
122
131
  headers: {
123
132
  "Authorization": "Basic " + Buffer.from(accountSid + ":" + authToken).toString("base64")
124
133
  },
125
134
  query: {
126
135
  SmsEnabled: true,
127
136
  PageSize: limit,
128
- ...areaCode && { AreaCode: areaCode },
129
137
  // `Contains` is the vendor's matching-pattern filter — digits
130
- // match anywhere in the number, so one search box serves "my
131
- // area code" and "ends in 7777" alike.
138
+ // match anywhere in the number, so one search box serves "an 888
139
+ // prefix" and "ends in 7777" alike. (AreaCode is a Local-only
140
+ // filter and went with Local.json.)
132
141
  ...contains && { Contains: contains },
133
142
  ...page && { Page: page }
134
143
  }
@@ -165,6 +174,57 @@ var twilio = {
165
174
  sid: result == null ? void 0 : result.sid
166
175
  };
167
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
+ },
168
228
  releaseNumber: async ({ accountSid, authToken, request: request2 = request, sid }) => {
169
229
  if (!accountSid || !authToken) throw new Error("Twilio credentials missing \u2014 accountSid, authToken");
170
230
  if (!sid) throw new Error("Twilio release needs the IncomingPhoneNumber sid");
@@ -175,6 +235,84 @@ var twilio = {
175
235
  "Authorization": "Basic " + Buffer.from(accountSid + ":" + authToken).toString("base64")
176
236
  }
177
237
  });
238
+ },
239
+ // TOLL-FREE VERIFICATION — the carrier registration that lets a purchased
240
+ // number actually deliver. From the Tollfree Verification resource docs
241
+ // (messaging.twilio.com, docs/messaging/api/tollfree-verification-resource):
242
+ // POST /v1/Tollfree/Verifications
243
+ // required: BusinessName, BusinessWebsite, NotificationEmail,
244
+ // UseCaseCategories (array), UseCaseSummary,
245
+ // ProductionMessageSample, OptInImageUrls (array), OptInType,
246
+ // MessageVolume, TollfreePhoneNumberSid (PN…)
247
+ // GET /v1/Tollfree/Verifications/{Sid}
248
+ // status: PENDING_REVIEW | IN_REVIEW | TWILIO_APPROVED |
249
+ // TWILIO_REJECTED, with rejection_reason beside a rejection.
250
+ // Basic auth, form-encoded, like everything above. There is no status
251
+ // callback on this resource — the caller polls the fetch.
252
+ //
253
+ // THE END BUSINESS'S DETAILS, never the platform's. Twilio rejects ISV
254
+ // submissions carrying the ISV's own identity (Toll-Free Verification for
255
+ // ISVs, support.twilio.com) — so businessName, website and email here are
256
+ // the merchant's, collected at purchase.
257
+ //
258
+ // The use-case fields are PLATFORM facts and live here beside the message
259
+ // composition for the same reason it does: how Drawbridge collects opt-in
260
+ // (its own hosted entry forms) and what its messages look like are
261
+ // properties of sending through Drawbridge, not of any one merchant.
262
+ submitVerification: async ({
263
+ accountSid,
264
+ authToken,
265
+ businessName,
266
+ email,
267
+ numberSid,
268
+ optInImage,
269
+ request: request2 = request,
270
+ volume,
271
+ website
272
+ }) => {
273
+ const missing = Object.entries({ accountSid, authToken, businessName, email, numberSid, optInImage, website }).filter(([, value]) => !value).map(([name]) => name);
274
+ if (missing.length) throw new Error("Toll-free verification submission missing \u2014 " + missing.join(", "));
275
+ const body = new URLSearchParams();
276
+ body.append("BusinessName", businessName);
277
+ body.append("BusinessWebsite", website);
278
+ body.append("NotificationEmail", email);
279
+ body.append("UseCaseCategories", "MARKETING");
280
+ body.append("UseCaseSummary", "Prize-draw entry confirmations, winner notifications and campaign updates to consumers who opted in on this business's Drawbridge-hosted entry form.");
281
+ body.append("ProductionMessageSample", businessName + "\nThanks for entering! We'll text you here about your entry.\nReply STOP to opt out");
282
+ body.append("OptInImageUrls", optInImage);
283
+ body.append("OptInType", "WEB_FORM");
284
+ body.append("MessageVolume", volume || "1,000");
285
+ body.append("TollfreePhoneNumberSid", numberSid);
286
+ const result = await request2({
287
+ method: "POST",
288
+ type: "form",
289
+ url: "https://messaging.twilio.com/v1/Tollfree/Verifications",
290
+ headers: {
291
+ "Authorization": "Basic " + Buffer.from(accountSid + ":" + authToken).toString("base64")
292
+ },
293
+ body
294
+ });
295
+ return {
296
+ sid: result == null ? void 0 : result.sid,
297
+ status: result == null ? void 0 : result.status
298
+ };
299
+ },
300
+ fetchVerification: async ({ accountSid, authToken, request: request2 = request, sid }) => {
301
+ if (!accountSid || !authToken) throw new Error("Twilio credentials missing \u2014 accountSid, authToken");
302
+ if (!sid) throw new Error("Twilio verification fetch needs the verification sid");
303
+ const result = await request2({
304
+ method: "GET",
305
+ url: "https://messaging.twilio.com/v1/Tollfree/Verifications/" + sid,
306
+ headers: {
307
+ "Authorization": "Basic " + Buffer.from(accountSid + ":" + authToken).toString("base64")
308
+ }
309
+ });
310
+ return {
311
+ // TWILIO_APPROVED → verified, TWILIO_REJECTED → rejected, both
312
+ // review states → pending.
313
+ outcome: (result == null ? void 0 : result.status) === "TWILIO_APPROVED" ? "approved" : (result == null ? void 0 : result.status) === "TWILIO_REJECTED" ? "rejected" : "pending",
314
+ reason: (result == null ? void 0 : result.rejection_reason) || null
315
+ };
178
316
  }
179
317
  };
180
318
  // Annotate the CommonJS export names for ESM import in node:
package/dist/twilio.d.cts CHANGED
@@ -70,14 +70,23 @@ const twilio = {
70
70
  // NUMBER LIFECYCLE, same transport discipline as sms above. Endpoints and
71
71
  // parameter names are Twilio's own, from the AvailablePhoneNumbers and
72
72
  // IncomingPhoneNumbers resource docs:
73
- // GET /2010-04-01/Accounts/{AccountSid}/AvailablePhoneNumbers/{CountryCode}/Local.json
74
- // query: SmsEnabled, AreaCode (US/CA only), PageSize (1-1000)
73
+ // GET /2010-04-01/Accounts/{AccountSid}/AvailablePhoneNumbers/{CountryCode}/TollFree.json
74
+ // query: SmsEnabled, Contains, PageSize (1-1000)
75
75
  // POST /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json
76
76
  // body: PhoneNumber (E.164), SmsUrl, SmsMethod
77
77
  // DELETE /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers/{Sid}.json
78
78
  // Basic auth throughout, like Messages.
79
+ //
80
+ // TOLL-FREE, not Local, and that is a carrier-registration decision rather
81
+ // than an inventory one. A US local number may not send A2P SMS without full
82
+ // 10DLC brand-and-campaign registration; a toll-free number needs only
83
+ // Toll-Free Verification — one API submission per number (see
84
+ // submitVerification below), no TCR brand, no campaign fees. Until a
85
+ // verification is approved, Twilio blocks the number's US-bound messages
86
+ // outright — which is why a purchased number starts `pending` and the send
87
+ // path refuses anything but `verified`.
79
88
 
80
- searchNumbers : async ({ accountSid, areaCode, authToken, contains, country = 'US', cursor, limit = 10, request: request$1 = request }) => {
89
+ searchNumbers : async ({ accountSid, authToken, contains, country = 'US', cursor, limit = 10, request: request$1 = request }) => {
81
90
 
82
91
  if( ! accountSid || ! authToken ) throw new Error( 'Twilio credentials missing — accountSid, authToken' );
83
92
 
@@ -93,17 +102,17 @@ const twilio = {
93
102
 
94
103
  const result = await request$1({
95
104
  method : 'GET',
96
- url : 'https://api.twilio.com/2010-04-01/Accounts/' + accountSid + '/AvailablePhoneNumbers/' + country + '/Local.json',
105
+ url : 'https://api.twilio.com/2010-04-01/Accounts/' + accountSid + '/AvailablePhoneNumbers/' + country + '/TollFree.json',
97
106
  headers : {
98
107
  'Authorization' : 'Basic ' + Buffer.from( accountSid + ':' + authToken ).toString( 'base64' )
99
108
  },
100
109
  query : {
101
110
  SmsEnabled : true,
102
111
  PageSize : limit,
103
- ...( areaCode && { AreaCode : areaCode } ),
104
112
  // `Contains` is the vendor's matching-pattern filter — digits
105
- // match anywhere in the number, so one search box serves "my
106
- // area code" and "ends in 7777" alike.
113
+ // match anywhere in the number, so one search box serves "an 888
114
+ // prefix" and "ends in 7777" alike. (AreaCode is a Local-only
115
+ // filter and went with Local.json.)
107
116
  ...( contains && { Contains : contains } ),
108
117
  ...( page && { Page : page } )
109
118
  }
@@ -150,6 +159,73 @@ const twilio = {
150
159
 
151
160
  },
152
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
+
153
229
  releaseNumber : async ({ accountSid, authToken, request: request$1 = request, sid }) => {
154
230
 
155
231
  if( ! accountSid || ! authToken ) throw new Error( 'Twilio credentials missing — accountSid, authToken' );
@@ -163,6 +239,111 @@ const twilio = {
163
239
  }
164
240
  });
165
241
 
242
+ },
243
+
244
+ // TOLL-FREE VERIFICATION — the carrier registration that lets a purchased
245
+ // number actually deliver. From the Tollfree Verification resource docs
246
+ // (messaging.twilio.com, docs/messaging/api/tollfree-verification-resource):
247
+ // POST /v1/Tollfree/Verifications
248
+ // required: BusinessName, BusinessWebsite, NotificationEmail,
249
+ // UseCaseCategories (array), UseCaseSummary,
250
+ // ProductionMessageSample, OptInImageUrls (array), OptInType,
251
+ // MessageVolume, TollfreePhoneNumberSid (PN…)
252
+ // GET /v1/Tollfree/Verifications/{Sid}
253
+ // status: PENDING_REVIEW | IN_REVIEW | TWILIO_APPROVED |
254
+ // TWILIO_REJECTED, with rejection_reason beside a rejection.
255
+ // Basic auth, form-encoded, like everything above. There is no status
256
+ // callback on this resource — the caller polls the fetch.
257
+ //
258
+ // THE END BUSINESS'S DETAILS, never the platform's. Twilio rejects ISV
259
+ // submissions carrying the ISV's own identity (Toll-Free Verification for
260
+ // ISVs, support.twilio.com) — so businessName, website and email here are
261
+ // the merchant's, collected at purchase.
262
+ //
263
+ // The use-case fields are PLATFORM facts and live here beside the message
264
+ // composition for the same reason it does: how Drawbridge collects opt-in
265
+ // (its own hosted entry forms) and what its messages look like are
266
+ // properties of sending through Drawbridge, not of any one merchant.
267
+ submitVerification : async ({
268
+ accountSid,
269
+ authToken,
270
+ businessName,
271
+ email,
272
+ numberSid,
273
+ optInImage,
274
+ request: request$1 = request,
275
+ volume,
276
+ website
277
+ }) => {
278
+
279
+ const missing = Object.entries({ accountSid, authToken, businessName, email, numberSid, optInImage, website })
280
+ .filter( ( [ , value ] ) => ! value )
281
+ .map( ( [ name ] ) => name );
282
+
283
+ if( missing.length ) throw new Error( 'Toll-free verification submission missing — ' + missing.join( ', ' ) );
284
+
285
+ // URLSearchParams rather than an object: UseCaseCategories and
286
+ // OptInImageUrls are array parameters, which Twilio takes as REPEATED
287
+ // form keys — an object body would comma-join them into one value.
288
+ const body = new URLSearchParams();
289
+
290
+ body.append( 'BusinessName', businessName );
291
+ body.append( 'BusinessWebsite', website );
292
+ body.append( 'NotificationEmail', email );
293
+ body.append( 'UseCaseCategories', 'MARKETING' );
294
+ body.append( 'UseCaseSummary', 'Prize-draw entry confirmations, winner notifications and campaign updates to consumers who opted in on this business\'s Drawbridge-hosted entry form.' );
295
+ // Mirrors the sms() composition above — newline-separated, brand first,
296
+ // opt-out line appended — so the reviewed sample is the sent shape.
297
+ body.append( 'ProductionMessageSample', businessName + '\nThanks for entering! We\'ll text you here about your entry.\nReply STOP to opt out' );
298
+ body.append( 'OptInImageUrls', optInImage );
299
+ // The entry form is a web form; the phone field is beside the consent
300
+ // language on the merchant's campaign page.
301
+ body.append( 'OptInType', 'WEB_FORM' );
302
+ body.append( 'MessageVolume', volume || '1,000' );
303
+ body.append( 'TollfreePhoneNumberSid', numberSid );
304
+
305
+ const result = await request$1({
306
+ method : 'POST',
307
+ type : 'form',
308
+ url : 'https://messaging.twilio.com/v1/Tollfree/Verifications',
309
+ headers : {
310
+ 'Authorization' : 'Basic ' + Buffer.from( accountSid + ':' + authToken ).toString( 'base64' )
311
+ },
312
+ body
313
+ });
314
+
315
+ return {
316
+ sid : result?.sid,
317
+ status : result?.status
318
+ };
319
+
320
+ },
321
+
322
+ fetchVerification : async ({ accountSid, authToken, request: request$1 = request, sid }) => {
323
+
324
+ if( ! accountSid || ! authToken ) throw new Error( 'Twilio credentials missing — accountSid, authToken' );
325
+ if( ! sid ) throw new Error( 'Twilio verification fetch needs the verification sid' );
326
+
327
+ const result = await request$1({
328
+ method : 'GET',
329
+ url : 'https://messaging.twilio.com/v1/Tollfree/Verifications/' + sid,
330
+ headers : {
331
+ 'Authorization' : 'Basic ' + Buffer.from( accountSid + ':' + authToken ).toString( 'base64' )
332
+ }
333
+ });
334
+
335
+ // The caller-facing shape is ours; the vendor's vocabulary stays here.
336
+ return {
337
+ // TWILIO_APPROVED → verified, TWILIO_REJECTED → rejected, both
338
+ // review states → pending.
339
+ outcome : result?.status === 'TWILIO_APPROVED'
340
+ ? 'approved'
341
+ : result?.status === 'TWILIO_REJECTED'
342
+ ? 'rejected'
343
+ : 'pending',
344
+ reason : result?.rejection_reason || null
345
+ };
346
+
166
347
  }
167
348
 
168
349
  };