@drawbridge/drawbridge-utils 0.0.134 → 0.0.136

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,
@@ -1022,6 +1031,74 @@ var drawbridge_default = `<svg width="500" height="500" viewBox="0 0 500 500" fi
1022
1031
  </defs>
1023
1032
  </svg>`;
1024
1033
 
1034
+ // lib/transactions.js
1035
+ var import_drawbridge_telemetry = require("@drawbridge/drawbridge-telemetry");
1036
+
1037
+ // lib/billing.js
1038
+ var import_drawbridge_telemetry2 = require("@drawbridge/drawbridge-telemetry");
1039
+ var logger = (0, import_drawbridge_telemetry2.createLogger)();
1040
+ var MARKUP = 1.3;
1041
+ var cost = {
1042
+ // gemini-3.5-flash — verified against Google's pricing page 2026-07-09:
1043
+ // $0.15 cached / $1.50 input / $9.00 output per 1M tokens (thinking billed at
1044
+ // output). ~3.6x the retired 2.5-flash output rate.
1045
+ "gemini-3.5-flash": {
1046
+ cached: 15,
1047
+ input: 150,
1048
+ output: 900
1049
+ },
1050
+ // gemini-3.5-flash-lite — verified against Google's pricing page 2026-08-20:
1051
+ // $0.03 cached / $0.30 input / $2.50 output per 1M tokens (thinking billed at
1052
+ // output). A fifth of flash on input, ~a quarter on output. Growth's assistant
1053
+ // ranks the feed on this tier — one call per page — so its rows were the
1054
+ // unpriced ones until now.
1055
+ "gemini-3.5-flash-lite": {
1056
+ cached: 3,
1057
+ input: 30,
1058
+ output: 250
1059
+ },
1060
+ "gemini-2.5-flash": {
1061
+ cached: 3,
1062
+ input: 30,
1063
+ output: 250
1064
+ },
1065
+ "gemini-2.5-flash-image": {
1066
+ cached: 3,
1067
+ input: 30,
1068
+ output: 3e3
1069
+ },
1070
+ // gemini-3-pro-image-preview — verified against Google's pricing page
1071
+ // 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
1072
+ // tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
1073
+ // 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
1074
+ // that reproduces the per-image price from the tokens usageMetadata
1075
+ // reports. Growth's hero generation runs this model today.
1076
+ "gemini-3-pro-image-preview": {
1077
+ cached: 20,
1078
+ input: 200,
1079
+ output: 12e3
1080
+ }
1081
+ };
1082
+ var toolCost = {
1083
+ search: 3.5
1084
+ };
1085
+ var toolPricing = Object.fromEntries(
1086
+ Object.entries(toolCost).map(([tool, value]) => [
1087
+ tool,
1088
+ Math.ceil(value * MARKUP)
1089
+ ])
1090
+ );
1091
+ var pricing = Object.fromEntries(
1092
+ Object.entries(cost).map(([model, rates]) => [
1093
+ model,
1094
+ {
1095
+ cached: Math.round(rates.cached * MARKUP),
1096
+ input: Math.round(rates.input * MARKUP),
1097
+ output: Math.round(rates.output * MARKUP)
1098
+ }
1099
+ ])
1100
+ );
1101
+
1025
1102
  // lib/features.js
1026
1103
  var page = {
1027
1104
  qrcode: {
@@ -1484,74 +1561,6 @@ var conversionRate = (subscription) => {
1484
1561
  return ((_a = resolvePlan(subscription)) == null ? void 0 : _a.conversion) ?? free.conversion;
1485
1562
  };
1486
1563
 
1487
- // lib/transactions.js
1488
- var import_drawbridge_telemetry = require("@drawbridge/drawbridge-telemetry");
1489
-
1490
- // lib/billing.js
1491
- var import_drawbridge_telemetry2 = require("@drawbridge/drawbridge-telemetry");
1492
- var logger = (0, import_drawbridge_telemetry2.createLogger)();
1493
- var MARKUP = 1.3;
1494
- var cost = {
1495
- // gemini-3.5-flash — verified against Google's pricing page 2026-07-09:
1496
- // $0.15 cached / $1.50 input / $9.00 output per 1M tokens (thinking billed at
1497
- // output). ~3.6x the retired 2.5-flash output rate.
1498
- "gemini-3.5-flash": {
1499
- cached: 15,
1500
- input: 150,
1501
- output: 900
1502
- },
1503
- // gemini-3.5-flash-lite — verified against Google's pricing page 2026-08-20:
1504
- // $0.03 cached / $0.30 input / $2.50 output per 1M tokens (thinking billed at
1505
- // output). A fifth of flash on input, ~a quarter on output. Growth's assistant
1506
- // ranks the feed on this tier — one call per page — so its rows were the
1507
- // unpriced ones until now.
1508
- "gemini-3.5-flash-lite": {
1509
- cached: 3,
1510
- input: 30,
1511
- output: 250
1512
- },
1513
- "gemini-2.5-flash": {
1514
- cached: 3,
1515
- input: 30,
1516
- output: 250
1517
- },
1518
- "gemini-2.5-flash-image": {
1519
- cached: 3,
1520
- input: 30,
1521
- output: 3e3
1522
- },
1523
- // gemini-3-pro-image-preview — verified against Google's pricing page
1524
- // 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
1525
- // tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
1526
- // 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
1527
- // that reproduces the per-image price from the tokens usageMetadata
1528
- // reports. Growth's hero generation runs this model today.
1529
- "gemini-3-pro-image-preview": {
1530
- cached: 20,
1531
- input: 200,
1532
- output: 12e3
1533
- }
1534
- };
1535
- var toolCost = {
1536
- search: 3.5
1537
- };
1538
- var toolPricing = Object.fromEntries(
1539
- Object.entries(toolCost).map(([tool, value]) => [
1540
- tool,
1541
- Math.ceil(value * MARKUP)
1542
- ])
1543
- );
1544
- var pricing = Object.fromEntries(
1545
- Object.entries(cost).map(([model, rates]) => [
1546
- model,
1547
- {
1548
- cached: Math.round(rates.cached * MARKUP),
1549
- input: Math.round(rates.input * MARKUP),
1550
- output: Math.round(rates.output * MARKUP)
1551
- }
1552
- ])
1553
- );
1554
-
1555
1564
  // lib/pricing.js
1556
1565
  var emailPlans = {
1557
1566
  essentials50k: {
@@ -1621,6 +1630,33 @@ var sending = {
1621
1630
  plan: emailPlan,
1622
1631
  plans: emailPlans,
1623
1632
  title: emailPlans[emailPlan].title
1633
+ },
1634
+ // What one outbound SMS SEGMENT costs us, against what channels.sms below
1635
+ // sells it for. Here for the same reason the email figures are: this file is
1636
+ // where a rate is found without knowing whose module owns it — and its
1637
+ // absence is why "does an SMS action cover its cost" could not be answered
1638
+ // from this repo at all.
1639
+ //
1640
+ // US toll-free outbound, read from Twilio's SMS pricing page 2026-09-03
1641
+ // (https://www.twilio.com/en-us/sms/pricing/us): $0.0083 per segment, plus a
1642
+ // carrier fee that varies by the DESTINATION carrier — $0.0035 AT&T, $0.0045
1643
+ // T-Mobile / Verizon / US Cellular, $0.004 everywhere else. CENTS per
1644
+ // segment, like every other rate in this file.
1645
+ //
1646
+ // WORST CASE, deliberately. A price floor computed from the average is a
1647
+ // floor that half of real traffic sits underneath.
1648
+ //
1649
+ // A segment is 153 GSM-7 characters once a message spans more than one — or
1650
+ // 67 if the body contains a single emoji or curly quote, which flips the
1651
+ // whole message to UCS-2. Twilio reports the count as `num_segments` on the
1652
+ // Message resource, which is what the send bills on.
1653
+ sms: {
1654
+ baseCents: 0.83,
1655
+ // The dearest carrier fee, not the mean: see WORST CASE above.
1656
+ carrierCents: 0.45,
1657
+ // Stated, not summed — 0.83 + 0.45 is 1.2800000000000002 in binary
1658
+ // floating point, and this number is compared against money.
1659
+ segmentCostCents: 1.28
1624
1660
  }
1625
1661
  };
1626
1662
  var channels = {
@@ -1636,6 +1672,9 @@ var channels = {
1636
1672
  includedInAllowance: false
1637
1673
  }
1638
1674
  };
1675
+ var MINIMUM_ACTION_CENTS = Math.round(
1676
+ sending.sms.segmentCostCents / channels.sms.actionsPerSegment * MARKUP * 1e3
1677
+ ) / 1e3;
1639
1678
 
1640
1679
  // lib/connections/providers/drawbridge.js
1641
1680
  var STOP_KEYWORDS = ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"];
@@ -1660,13 +1699,16 @@ var teamRecipients = async ({ memberIds = [], organization: organization2, read
1660
1699
  ]
1661
1700
  }) : [];
1662
1701
  const seen = /* @__PURE__ */ new Set();
1663
- return [owner, ...members].filter((member) => {
1664
- if (!(member == null ? void 0 : member.id) || !(member == null ? void 0 : member.email)) return false;
1665
- const address = member.email.toLowerCase();
1666
- if (seen.has(address)) return false;
1667
- seen.add(address);
1668
- return true;
1669
- });
1702
+ return {
1703
+ org,
1704
+ recipients: [owner, ...members].filter((member) => {
1705
+ if (!(member == null ? void 0 : member.id) || !(member == null ? void 0 : member.email)) return false;
1706
+ const address = member.email.toLowerCase();
1707
+ if (seen.has(address)) return false;
1708
+ seen.add(address);
1709
+ return true;
1710
+ })
1711
+ };
1670
1712
  };
1671
1713
  var queueNotification = (data2) => ({ collection: "notification", data: data2, operation: "create" });
1672
1714
  var drawbridge_default2 = {
@@ -1742,7 +1784,7 @@ var drawbridge_default2 = {
1742
1784
  const count = Number((counted == null ? void 0 : counted.count) || 0);
1743
1785
  const request2 = { campaign, count, days };
1744
1786
  if (!count) return { message: "No new leads in the period \u2014 digest skipped.", request: request2, response: { skipped: true }, skipped: true };
1745
- const recipients = await teamRecipients({
1787
+ const { recipients } = await teamRecipients({
1746
1788
  memberIds: ((_d = step.settings) == null ? void 0 : _d.members) || [],
1747
1789
  organization: workflow.organization,
1748
1790
  read
@@ -1778,7 +1820,7 @@ var drawbridge_default2 = {
1778
1820
  var _a;
1779
1821
  const memberIds = ((_a = step.settings) == null ? void 0 : _a.members) || [];
1780
1822
  const request2 = { members: memberIds.length };
1781
- const recipients = await teamRecipients({ memberIds, organization: workflow.organization, read });
1823
+ const { org, recipients } = await teamRecipients({ memberIds, organization: workflow.organization, read });
1782
1824
  if (!recipients.length) {
1783
1825
  return {
1784
1826
  message: "No owner or accepted member with an email address \u2014 team notification skipped.",
@@ -1788,32 +1830,37 @@ var drawbridge_default2 = {
1788
1830
  };
1789
1831
  }
1790
1832
  const bucket = Math.floor(Date.now() / (15 * 60 * 1e3));
1833
+ const campaign = (context == null ? void 0 : context.campaign) ? await read.get({ collection: "campaign", query: { id: context.campaign } }) : null;
1834
+ const link = (context == null ? void 0 : context.campaign) && (context == null ? void 0 : context.lead) ? {
1835
+ href: "/organizations/" + workflow.organization + "/campaigns/" + context.campaign + "/leads/" + context.lead,
1836
+ text: "View lead"
1837
+ } : null;
1838
+ const body = ((context == null ? void 0 : context.email) || "Someone") + " just entered" + ((campaign == null ? void 0 : campaign.title) ? " " + campaign.title : "") + ((org == null ? void 0 : org.title) ? " at " + org.title : "") + ".";
1839
+ const subject = "New lead" + ((campaign == null ? void 0 : campaign.title) ? " \u2014 " + campaign.title : "");
1791
1840
  return {
1792
1841
  message: "Team notification queued for " + recipients.length + " recipient(s).",
1793
1842
  request: request2,
1794
1843
  response: { notified: recipients.length },
1795
- writes: recipients.map((member) => {
1796
- var _a2, _b;
1797
- return {
1798
- ...queueNotification({
1799
- audience: "member",
1800
- // Per workflow, recipient AND bucket, so one recipient's damper
1801
- // can never swallow another's mail and a later bucket is never
1802
- // mistaken for a duplicate of an earlier one.
1803
- key: "team.notify." + workflow.id + "." + member.id + "." + bucket,
1804
- message: interpolate((_a2 = step.settings) == null ? void 0 : _a2.message, context),
1805
- organization: workflow.organization,
1806
- send: { type: "email", email: member.email },
1807
- title: interpolate((_b = step.settings) == null ? void 0 : _b.subject, context),
1808
- workflow: workflow.id
1809
- }),
1810
- // E11000 IS THE DAMPER WORKING: this recipient has already been
1811
- // told within the bucket. Declared per write rather than assumed by
1812
- // the shell, because on every other write here a duplicate key is a
1813
- // real failure.
1814
- ignoreDuplicate: true
1815
- };
1816
- })
1844
+ writes: recipients.map((member) => ({
1845
+ ...queueNotification({
1846
+ audience: "member",
1847
+ // Per workflow, recipient AND bucket, so one recipient's damper
1848
+ // can never swallow another's mail and a later bucket is never
1849
+ // mistaken for a duplicate of an earlier one.
1850
+ key: "team.notify." + workflow.id + "." + member.id + "." + bucket,
1851
+ link,
1852
+ message: body,
1853
+ organization: workflow.organization,
1854
+ send: { type: "email", email: member.email },
1855
+ title: subject,
1856
+ workflow: workflow.id
1857
+ }),
1858
+ // E11000 IS THE DAMPER WORKING: this recipient has already been
1859
+ // told within the bucket. Declared per write rather than assumed by
1860
+ // the shell, because on every other write here a duplicate key is a
1861
+ // real failure.
1862
+ ignoreDuplicate: true
1863
+ }))
1817
1864
  };
1818
1865
  },
1819
1866
  // Drawbridge sends lead-facing email itself — no merchant provider gates
@@ -2219,14 +2266,24 @@ var drawbridge_default2 = {
2219
2266
  }),
2220
2267
  // To organization MEMBERS. Never suppressed — an entrant's opt-out must
2221
2268
  // not silence an alert to staff — and not billed.
2269
+ //
2270
+ // ITS COPY IS WRITTEN, NOT ASKED FOR. This says the same thing every
2271
+ // time — a lead arrived, here it is — so the merchant chooses who hears
2272
+ // it, not how it reads, and the hook composes the subject, the body and
2273
+ // the link to the lead.
2274
+ //
2275
+ // `message` and `subject` stay declared, optional and IGNORED: stored
2276
+ // workflows still carry them and the api validates settings with
2277
+ // noUnknown().strict(), so dropping the keys would 400 the next save of
2278
+ // a workflow built before this.
2222
2279
  notify: () => ({
2223
2280
  hook: "email.notify",
2224
2281
  key: "Email \u2014 Notification",
2225
2282
  queue: "notification",
2226
2283
  settings: {
2227
2284
  members: { of: "string", type: "array" },
2228
- message: { required: true, type: "string" },
2229
- subject: { required: true, type: "string" }
2285
+ message: { type: "string" },
2286
+ subject: { type: "string" }
2230
2287
  },
2231
2288
  triggers: ["lead.insert"],
2232
2289
  // Zero is a PRICE, and a deliberate one. Declared rather than omitted
@@ -2641,8 +2698,62 @@ var klaviyo_default2 = {
2641
2698
  sms: false,
2642
2699
  inbound: false,
2643
2700
  // 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,
2701
+ // integration. What CAN rot is the grant itself, so health is the one
2702
+ // lifecycle hook Klaviyo carries.
2703
+ lifecycle: {
2704
+ // Nothing to set up, tear down, or re-register at the vendor — the
2705
+ // grant is the whole integration.
2706
+ cleanup: false,
2707
+ register: false,
2708
+ rehydrate: false,
2709
+ // KEEP THE GRANT PROVEN AND WARM. The shell mints a fresh access
2710
+ // token before every hook run — for Klaviyo that spends the refresh
2711
+ // token, which is the only question that reaches Klaviyo (see
2712
+ // auth.probe) — so this body running at all proves the grant still
2713
+ // rotates, and the daily run keeps it inside Klaviyo's 90-day idle
2714
+ // window. A dead grant never gets here: the shell's mint throws
2715
+ // `grant_refused` and the step runner errors the connection itself.
2716
+ //
2717
+ // The one call below proves the minted token is HONOURED — mint and
2718
+ // acceptance are different facts, and /accounts is already the call
2719
+ // the connect flow makes (auth.connect), so it needs no new scope.
2720
+ health: async ({ connection: connection2, token }, { fetcher, read } = {}) => {
2721
+ const request2 = { connectionId: connection2.id };
2722
+ try {
2723
+ await api2("/accounts", { fetcher, token });
2724
+ return {
2725
+ message: "Health check passed \u2014 token minted and accepted.",
2726
+ request: request2,
2727
+ response: { pingedAt: /* @__PURE__ */ new Date() }
2728
+ };
2729
+ } catch (error) {
2730
+ if ([401, 403].includes(error.status) && read) {
2731
+ const current = await read.get({ collection: "connection", query: { id: connection2.id } });
2732
+ if (current) {
2733
+ const others = (current.errors || []).filter((entry) => entry.source !== "oauth");
2734
+ error.writes = [{
2735
+ collection: "connection",
2736
+ data: {
2737
+ $set: {
2738
+ errors: [
2739
+ ...others,
2740
+ {
2741
+ message: "Klaviyo no longer accepts this connection. Reconnect Klaviyo to resume syncing.",
2742
+ source: "oauth"
2743
+ }
2744
+ ],
2745
+ status: "error"
2746
+ }
2747
+ },
2748
+ operation: "update",
2749
+ query: { id: connection2.id }
2750
+ }];
2751
+ }
2752
+ }
2753
+ throw error;
2754
+ }
2755
+ }
2756
+ },
2646
2757
  resources: {
2647
2758
  // The lists a merchant can sync into, for the picker on their
2648
2759
  // connection.
@@ -2718,6 +2829,19 @@ var klaviyo_default2 = {
2718
2829
  return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? data2.status : "pending";
2719
2830
  },
2720
2831
  steps: {
2832
+ connection: {
2833
+ // The daily grant check — same step type and workflow shape as
2834
+ // Shopify's, provisioned as a system workflow per connection.
2835
+ health: {
2836
+ check: () => ({
2837
+ 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.",
2838
+ hook: "lifecycle.health",
2839
+ key: "Klaviyo Connection Health",
2840
+ queue: "connection",
2841
+ system: true
2842
+ })
2843
+ }
2844
+ },
2721
2845
  contacts: {
2722
2846
  // A DECLARATION, not the work. It names the hook that does the work, and
2723
2847
  // says where that hook's values belong. Nested like the hooks, and the
@@ -6,6 +6,10 @@ import './phone.cjs';
6
6
  import 'libphonenumber-js';
7
7
  import './http.cjs';
8
8
  import './pricing.cjs';
9
+ import './billing.cjs';
10
+ import './transactions.cjs';
11
+ import '@drawbridge/drawbridge-telemetry';
12
+ import './usage.cjs';
9
13
  import './plans.cjs';
10
14
  import './features.cjs';
11
15
  import './index.cjs';
@@ -13,10 +17,6 @@ import 'currency-codes';
13
17
  import 'nanoid';
14
18
  import './color.cjs';
15
19
  import 'tinycolor2';
16
- import './usage.cjs';
17
- import './billing.cjs';
18
- import './transactions.cjs';
19
- import '@drawbridge/drawbridge-telemetry';
20
20
  import './email.cjs';
21
21
  import './safe-http.cjs';
22
22
  import 'dns';
@@ -6,6 +6,10 @@ import './phone.js';
6
6
  import 'libphonenumber-js';
7
7
  import './http.js';
8
8
  import './pricing.js';
9
+ import './billing.js';
10
+ import './transactions.js';
11
+ import '@drawbridge/drawbridge-telemetry';
12
+ import './usage.js';
9
13
  import './plans.js';
10
14
  import './features.js';
11
15
  import './index.js';
@@ -13,10 +17,6 @@ import 'currency-codes';
13
17
  import 'nanoid';
14
18
  import './color.js';
15
19
  import 'tinycolor2';
16
- import './usage.js';
17
- import './billing.js';
18
- import './transactions.js';
19
- import '@drawbridge/drawbridge-telemetry';
20
20
  import './email.js';
21
21
  import './safe-http.js';
22
22
  import 'dns';