@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.
@@ -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,
@@ -1116,6 +1125,74 @@ var drawbridge_default = `<svg width="500" height="500" viewBox="0 0 500 500" fi
1116
1125
  </defs>
1117
1126
  </svg>`;
1118
1127
 
1128
+ // lib/transactions.js
1129
+ var import_drawbridge_telemetry = require("@drawbridge/drawbridge-telemetry");
1130
+
1131
+ // lib/billing.js
1132
+ var import_drawbridge_telemetry2 = require("@drawbridge/drawbridge-telemetry");
1133
+ var logger = (0, import_drawbridge_telemetry2.createLogger)();
1134
+ var MARKUP = 1.3;
1135
+ var cost = {
1136
+ // gemini-3.5-flash — verified against Google's pricing page 2026-07-09:
1137
+ // $0.15 cached / $1.50 input / $9.00 output per 1M tokens (thinking billed at
1138
+ // output). ~3.6x the retired 2.5-flash output rate.
1139
+ "gemini-3.5-flash": {
1140
+ cached: 15,
1141
+ input: 150,
1142
+ output: 900
1143
+ },
1144
+ // gemini-3.5-flash-lite — verified against Google's pricing page 2026-08-20:
1145
+ // $0.03 cached / $0.30 input / $2.50 output per 1M tokens (thinking billed at
1146
+ // output). A fifth of flash on input, ~a quarter on output. Growth's assistant
1147
+ // ranks the feed on this tier — one call per page — so its rows were the
1148
+ // unpriced ones until now.
1149
+ "gemini-3.5-flash-lite": {
1150
+ cached: 3,
1151
+ input: 30,
1152
+ output: 250
1153
+ },
1154
+ "gemini-2.5-flash": {
1155
+ cached: 3,
1156
+ input: 30,
1157
+ output: 250
1158
+ },
1159
+ "gemini-2.5-flash-image": {
1160
+ cached: 3,
1161
+ input: 30,
1162
+ output: 3e3
1163
+ },
1164
+ // gemini-3-pro-image-preview — verified against Google's pricing page
1165
+ // 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
1166
+ // tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
1167
+ // 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
1168
+ // that reproduces the per-image price from the tokens usageMetadata
1169
+ // reports. Growth's hero generation runs this model today.
1170
+ "gemini-3-pro-image-preview": {
1171
+ cached: 20,
1172
+ input: 200,
1173
+ output: 12e3
1174
+ }
1175
+ };
1176
+ var toolCost = {
1177
+ search: 3.5
1178
+ };
1179
+ var toolPricing = Object.fromEntries(
1180
+ Object.entries(toolCost).map(([tool, value]) => [
1181
+ tool,
1182
+ Math.ceil(value * MARKUP)
1183
+ ])
1184
+ );
1185
+ var pricing = Object.fromEntries(
1186
+ Object.entries(cost).map(([model, rates]) => [
1187
+ model,
1188
+ {
1189
+ cached: Math.round(rates.cached * MARKUP),
1190
+ input: Math.round(rates.input * MARKUP),
1191
+ output: Math.round(rates.output * MARKUP)
1192
+ }
1193
+ ])
1194
+ );
1195
+
1119
1196
  // lib/features.js
1120
1197
  var page = {
1121
1198
  qrcode: {
@@ -1578,74 +1655,6 @@ var conversionRate = (subscription) => {
1578
1655
  return ((_a = resolvePlan(subscription)) == null ? void 0 : _a.conversion) ?? free.conversion;
1579
1656
  };
1580
1657
 
1581
- // lib/transactions.js
1582
- var import_drawbridge_telemetry = require("@drawbridge/drawbridge-telemetry");
1583
-
1584
- // lib/billing.js
1585
- var import_drawbridge_telemetry2 = require("@drawbridge/drawbridge-telemetry");
1586
- var logger = (0, import_drawbridge_telemetry2.createLogger)();
1587
- var MARKUP = 1.3;
1588
- var cost = {
1589
- // gemini-3.5-flash — verified against Google's pricing page 2026-07-09:
1590
- // $0.15 cached / $1.50 input / $9.00 output per 1M tokens (thinking billed at
1591
- // output). ~3.6x the retired 2.5-flash output rate.
1592
- "gemini-3.5-flash": {
1593
- cached: 15,
1594
- input: 150,
1595
- output: 900
1596
- },
1597
- // gemini-3.5-flash-lite — verified against Google's pricing page 2026-08-20:
1598
- // $0.03 cached / $0.30 input / $2.50 output per 1M tokens (thinking billed at
1599
- // output). A fifth of flash on input, ~a quarter on output. Growth's assistant
1600
- // ranks the feed on this tier — one call per page — so its rows were the
1601
- // unpriced ones until now.
1602
- "gemini-3.5-flash-lite": {
1603
- cached: 3,
1604
- input: 30,
1605
- output: 250
1606
- },
1607
- "gemini-2.5-flash": {
1608
- cached: 3,
1609
- input: 30,
1610
- output: 250
1611
- },
1612
- "gemini-2.5-flash-image": {
1613
- cached: 3,
1614
- input: 30,
1615
- output: 3e3
1616
- },
1617
- // gemini-3-pro-image-preview — verified against Google's pricing page
1618
- // 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
1619
- // tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
1620
- // 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
1621
- // that reproduces the per-image price from the tokens usageMetadata
1622
- // reports. Growth's hero generation runs this model today.
1623
- "gemini-3-pro-image-preview": {
1624
- cached: 20,
1625
- input: 200,
1626
- output: 12e3
1627
- }
1628
- };
1629
- var toolCost = {
1630
- search: 3.5
1631
- };
1632
- var toolPricing = Object.fromEntries(
1633
- Object.entries(toolCost).map(([tool, value]) => [
1634
- tool,
1635
- Math.ceil(value * MARKUP)
1636
- ])
1637
- );
1638
- var pricing = Object.fromEntries(
1639
- Object.entries(cost).map(([model, rates]) => [
1640
- model,
1641
- {
1642
- cached: Math.round(rates.cached * MARKUP),
1643
- input: Math.round(rates.input * MARKUP),
1644
- output: Math.round(rates.output * MARKUP)
1645
- }
1646
- ])
1647
- );
1648
-
1649
1658
  // lib/pricing.js
1650
1659
  var emailPlans = {
1651
1660
  essentials50k: {
@@ -1715,6 +1724,33 @@ var sending = {
1715
1724
  plan: emailPlan,
1716
1725
  plans: emailPlans,
1717
1726
  title: emailPlans[emailPlan].title
1727
+ },
1728
+ // What one outbound SMS SEGMENT costs us, against what channels.sms below
1729
+ // sells it for. Here for the same reason the email figures are: this file is
1730
+ // where a rate is found without knowing whose module owns it — and its
1731
+ // absence is why "does an SMS action cover its cost" could not be answered
1732
+ // from this repo at all.
1733
+ //
1734
+ // US toll-free outbound, read from Twilio's SMS pricing page 2026-09-03
1735
+ // (https://www.twilio.com/en-us/sms/pricing/us): $0.0083 per segment, plus a
1736
+ // carrier fee that varies by the DESTINATION carrier — $0.0035 AT&T, $0.0045
1737
+ // T-Mobile / Verizon / US Cellular, $0.004 everywhere else. CENTS per
1738
+ // segment, like every other rate in this file.
1739
+ //
1740
+ // WORST CASE, deliberately. A price floor computed from the average is a
1741
+ // floor that half of real traffic sits underneath.
1742
+ //
1743
+ // A segment is 153 GSM-7 characters once a message spans more than one — or
1744
+ // 67 if the body contains a single emoji or curly quote, which flips the
1745
+ // whole message to UCS-2. Twilio reports the count as `num_segments` on the
1746
+ // Message resource, which is what the send bills on.
1747
+ sms: {
1748
+ baseCents: 0.83,
1749
+ // The dearest carrier fee, not the mean: see WORST CASE above.
1750
+ carrierCents: 0.45,
1751
+ // Stated, not summed — 0.83 + 0.45 is 1.2800000000000002 in binary
1752
+ // floating point, and this number is compared against money.
1753
+ segmentCostCents: 1.28
1718
1754
  }
1719
1755
  };
1720
1756
  var channels = {
@@ -1730,6 +1766,9 @@ var channels = {
1730
1766
  includedInAllowance: false
1731
1767
  }
1732
1768
  };
1769
+ var MINIMUM_ACTION_CENTS = Math.round(
1770
+ sending.sms.segmentCostCents / channels.sms.actionsPerSegment * MARKUP * 1e3
1771
+ ) / 1e3;
1733
1772
 
1734
1773
  // lib/connections/providers/drawbridge.js
1735
1774
  var STOP_KEYWORDS = ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"];
@@ -1754,13 +1793,16 @@ var teamRecipients = async ({ memberIds = [], organization: organization2, read
1754
1793
  ]
1755
1794
  }) : [];
1756
1795
  const seen = /* @__PURE__ */ new Set();
1757
- return [owner, ...members].filter((member) => {
1758
- if (!(member == null ? void 0 : member.id) || !(member == null ? void 0 : member.email)) return false;
1759
- const address = member.email.toLowerCase();
1760
- if (seen.has(address)) return false;
1761
- seen.add(address);
1762
- return true;
1763
- });
1796
+ return {
1797
+ org,
1798
+ recipients: [owner, ...members].filter((member) => {
1799
+ if (!(member == null ? void 0 : member.id) || !(member == null ? void 0 : member.email)) return false;
1800
+ const address = member.email.toLowerCase();
1801
+ if (seen.has(address)) return false;
1802
+ seen.add(address);
1803
+ return true;
1804
+ })
1805
+ };
1764
1806
  };
1765
1807
  var queueNotification = (data2) => ({ collection: "notification", data: data2, operation: "create" });
1766
1808
  var drawbridge_default2 = {
@@ -1836,7 +1878,7 @@ var drawbridge_default2 = {
1836
1878
  const count = Number((counted == null ? void 0 : counted.count) || 0);
1837
1879
  const request2 = { campaign, count, days };
1838
1880
  if (!count) return { message: "No new leads in the period \u2014 digest skipped.", request: request2, response: { skipped: true }, skipped: true };
1839
- const recipients = await teamRecipients({
1881
+ const { recipients } = await teamRecipients({
1840
1882
  memberIds: ((_d = step.settings) == null ? void 0 : _d.members) || [],
1841
1883
  organization: workflow.organization,
1842
1884
  read
@@ -1872,7 +1914,7 @@ var drawbridge_default2 = {
1872
1914
  var _a;
1873
1915
  const memberIds = ((_a = step.settings) == null ? void 0 : _a.members) || [];
1874
1916
  const request2 = { members: memberIds.length };
1875
- const recipients = await teamRecipients({ memberIds, organization: workflow.organization, read });
1917
+ const { org, recipients } = await teamRecipients({ memberIds, organization: workflow.organization, read });
1876
1918
  if (!recipients.length) {
1877
1919
  return {
1878
1920
  message: "No owner or accepted member with an email address \u2014 team notification skipped.",
@@ -1882,32 +1924,37 @@ var drawbridge_default2 = {
1882
1924
  };
1883
1925
  }
1884
1926
  const bucket = Math.floor(Date.now() / (15 * 60 * 1e3));
1927
+ const campaign = (context == null ? void 0 : context.campaign) ? await read.get({ collection: "campaign", query: { id: context.campaign } }) : null;
1928
+ const link = (context == null ? void 0 : context.campaign) && (context == null ? void 0 : context.lead) ? {
1929
+ href: "/organizations/" + workflow.organization + "/campaigns/" + context.campaign + "/leads/" + context.lead,
1930
+ text: "View lead"
1931
+ } : null;
1932
+ 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 : "") + ".";
1933
+ const subject = "New lead" + ((campaign == null ? void 0 : campaign.title) ? " \u2014 " + campaign.title : "");
1885
1934
  return {
1886
1935
  message: "Team notification queued for " + recipients.length + " recipient(s).",
1887
1936
  request: request2,
1888
1937
  response: { notified: recipients.length },
1889
- writes: recipients.map((member) => {
1890
- var _a2, _b;
1891
- return {
1892
- ...queueNotification({
1893
- audience: "member",
1894
- // Per workflow, recipient AND bucket, so one recipient's damper
1895
- // can never swallow another's mail and a later bucket is never
1896
- // mistaken for a duplicate of an earlier one.
1897
- key: "team.notify." + workflow.id + "." + member.id + "." + bucket,
1898
- message: interpolate((_a2 = step.settings) == null ? void 0 : _a2.message, context),
1899
- organization: workflow.organization,
1900
- send: { type: "email", email: member.email },
1901
- title: interpolate((_b = step.settings) == null ? void 0 : _b.subject, context),
1902
- workflow: workflow.id
1903
- }),
1904
- // E11000 IS THE DAMPER WORKING: this recipient has already been
1905
- // told within the bucket. Declared per write rather than assumed by
1906
- // the shell, because on every other write here a duplicate key is a
1907
- // real failure.
1908
- ignoreDuplicate: true
1909
- };
1910
- })
1938
+ writes: recipients.map((member) => ({
1939
+ ...queueNotification({
1940
+ audience: "member",
1941
+ // Per workflow, recipient AND bucket, so one recipient's damper
1942
+ // can never swallow another's mail and a later bucket is never
1943
+ // mistaken for a duplicate of an earlier one.
1944
+ key: "team.notify." + workflow.id + "." + member.id + "." + bucket,
1945
+ link,
1946
+ message: body,
1947
+ organization: workflow.organization,
1948
+ send: { type: "email", email: member.email },
1949
+ title: subject,
1950
+ workflow: workflow.id
1951
+ }),
1952
+ // E11000 IS THE DAMPER WORKING: this recipient has already been
1953
+ // told within the bucket. Declared per write rather than assumed by
1954
+ // the shell, because on every other write here a duplicate key is a
1955
+ // real failure.
1956
+ ignoreDuplicate: true
1957
+ }))
1911
1958
  };
1912
1959
  },
1913
1960
  // Drawbridge sends lead-facing email itself — no merchant provider gates
@@ -2313,14 +2360,24 @@ var drawbridge_default2 = {
2313
2360
  }),
2314
2361
  // To organization MEMBERS. Never suppressed — an entrant's opt-out must
2315
2362
  // not silence an alert to staff — and not billed.
2363
+ //
2364
+ // ITS COPY IS WRITTEN, NOT ASKED FOR. This says the same thing every
2365
+ // time — a lead arrived, here it is — so the merchant chooses who hears
2366
+ // it, not how it reads, and the hook composes the subject, the body and
2367
+ // the link to the lead.
2368
+ //
2369
+ // `message` and `subject` stay declared, optional and IGNORED: stored
2370
+ // workflows still carry them and the api validates settings with
2371
+ // noUnknown().strict(), so dropping the keys would 400 the next save of
2372
+ // a workflow built before this.
2316
2373
  notify: () => ({
2317
2374
  hook: "email.notify",
2318
2375
  key: "Email \u2014 Notification",
2319
2376
  queue: "notification",
2320
2377
  settings: {
2321
2378
  members: { of: "string", type: "array" },
2322
- message: { required: true, type: "string" },
2323
- subject: { required: true, type: "string" }
2379
+ message: { type: "string" },
2380
+ subject: { type: "string" }
2324
2381
  },
2325
2382
  triggers: ["lead.insert"],
2326
2383
  // Zero is a PRICE, and a deliberate one. Declared rather than omitted
@@ -2735,8 +2792,62 @@ var klaviyo_default2 = {
2735
2792
  sms: false,
2736
2793
  inbound: false,
2737
2794
  // 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,
2795
+ // integration. What CAN rot is the grant itself, so health is the one
2796
+ // lifecycle hook Klaviyo carries.
2797
+ lifecycle: {
2798
+ // Nothing to set up, tear down, or re-register at the vendor — the
2799
+ // grant is the whole integration.
2800
+ cleanup: false,
2801
+ register: false,
2802
+ rehydrate: false,
2803
+ // KEEP THE GRANT PROVEN AND WARM. The shell mints a fresh access
2804
+ // token before every hook run — for Klaviyo that spends the refresh
2805
+ // token, which is the only question that reaches Klaviyo (see
2806
+ // auth.probe) — so this body running at all proves the grant still
2807
+ // rotates, and the daily run keeps it inside Klaviyo's 90-day idle
2808
+ // window. A dead grant never gets here: the shell's mint throws
2809
+ // `grant_refused` and the step runner errors the connection itself.
2810
+ //
2811
+ // The one call below proves the minted token is HONOURED — mint and
2812
+ // acceptance are different facts, and /accounts is already the call
2813
+ // the connect flow makes (auth.connect), so it needs no new scope.
2814
+ health: async ({ connection: connection2, token }, { fetcher, read } = {}) => {
2815
+ const request2 = { connectionId: connection2.id };
2816
+ try {
2817
+ await api2("/accounts", { fetcher, token });
2818
+ return {
2819
+ message: "Health check passed \u2014 token minted and accepted.",
2820
+ request: request2,
2821
+ response: { pingedAt: /* @__PURE__ */ new Date() }
2822
+ };
2823
+ } catch (error) {
2824
+ if ([401, 403].includes(error.status) && read) {
2825
+ const current = await read.get({ collection: "connection", query: { id: connection2.id } });
2826
+ if (current) {
2827
+ const others = (current.errors || []).filter((entry) => entry.source !== "oauth");
2828
+ error.writes = [{
2829
+ collection: "connection",
2830
+ data: {
2831
+ $set: {
2832
+ errors: [
2833
+ ...others,
2834
+ {
2835
+ message: "Klaviyo no longer accepts this connection. Reconnect Klaviyo to resume syncing.",
2836
+ source: "oauth"
2837
+ }
2838
+ ],
2839
+ status: "error"
2840
+ }
2841
+ },
2842
+ operation: "update",
2843
+ query: { id: connection2.id }
2844
+ }];
2845
+ }
2846
+ }
2847
+ throw error;
2848
+ }
2849
+ }
2850
+ },
2740
2851
  resources: {
2741
2852
  // The lists a merchant can sync into, for the picker on their
2742
2853
  // connection.
@@ -2812,6 +2923,19 @@ var klaviyo_default2 = {
2812
2923
  return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? data2.status : "pending";
2813
2924
  },
2814
2925
  steps: {
2926
+ connection: {
2927
+ // The daily grant check — same step type and workflow shape as
2928
+ // Shopify's, provisioned as a system workflow per connection.
2929
+ health: {
2930
+ check: () => ({
2931
+ 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.",
2932
+ hook: "lifecycle.health",
2933
+ key: "Klaviyo Connection Health",
2934
+ queue: "connection",
2935
+ system: true
2936
+ })
2937
+ }
2938
+ },
2815
2939
  contacts: {
2816
2940
  // A DECLARATION, not the work. It names the hook that does the work, and
2817
2941
  // says where that hook's values belong. Nested like the hooks, and the