@drawbridge/drawbridge-utils 0.0.136 → 0.0.139

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.
@@ -639,6 +639,9 @@ var attentive_default2 = {
639
639
  },
640
640
  // A contact destination, like Klaviyo and Mailchimp — a merchant could
641
641
  // reasonably keep several up to date at once.
642
+ // No vendor-facing event identities — declared empty so the contract stays
643
+ // explicit: a vendor that gains one adds it here, never as a side channel.
644
+ events: {},
642
645
  exclusive: false,
643
646
  feature: "organization:connection:attentive",
644
647
  fields: [
@@ -1819,6 +1822,9 @@ var drawbridge_default2 = {
1819
1822
  "Nothing to do. These steps are available in every workflow builder."
1820
1823
  ]
1821
1824
  },
1825
+ // No vendor-facing event identities — declared empty so the contract stays
1826
+ // explicit: a vendor that gains one adds it here, never as a side channel.
1827
+ events: {},
1822
1828
  exclusive: false,
1823
1829
  fields: [],
1824
1830
  group: "developer",
@@ -2603,6 +2609,9 @@ var klaviyo_default2 = {
2603
2609
  // current — so the exclusivity that once applied when these were SENDERS is
2604
2610
  // deliberately gone. That was removed once already; declaring it out loud is
2605
2611
  // what stops it coming back by inference.
2612
+ // No vendor-facing event identities — declared empty so the contract stays
2613
+ // explicit: a vendor that gains one adds it here, never as a side channel.
2614
+ events: {},
2606
2615
  exclusive: false,
2607
2616
  feature: "organization:connection:klaviyo",
2608
2617
  fields: [
@@ -3079,6 +3088,9 @@ var mailchimp_default2 = {
3079
3088
  // picking two providers to send the same mail was meaningless. As contact
3080
3089
  // syncs they are destinations, and a merchant could reasonably keep several
3081
3090
  // up to date, so the exclusivity is gone.
3091
+ // No vendor-facing event identities — declared empty so the contract stays
3092
+ // explicit: a vendor that gains one adds it here, never as a side channel.
3093
+ events: {},
3082
3094
  exclusive: false,
3083
3095
  feature: "organization:connection:mailchimp",
3084
3096
  fields: [
@@ -3354,6 +3366,22 @@ var toCanonicalEmail = (value) => {
3354
3366
  return local + "@" + domain;
3355
3367
  };
3356
3368
 
3369
+ // lib/slugify.js
3370
+ var import_slugify = __toESM(require("slugify"), 1);
3371
+ var slugify = (value, nochars = false) => {
3372
+ const remove = nochars ? /[-?*+~.()'"!:@#^_{}\[\];,/\\]/g : /[?*+~.()'"!:@#^_{}\[\];,/\\]/g;
3373
+ const replacement = nochars ? "" : "-";
3374
+ return (0, import_slugify.default)(
3375
+ value,
3376
+ {
3377
+ remove,
3378
+ replacement,
3379
+ lower: true,
3380
+ trim: true
3381
+ }
3382
+ );
3383
+ };
3384
+
3357
3385
  // lib/connections/providers/shopify.js
3358
3386
  var toLine = ({
3359
3387
  price,
@@ -3389,6 +3417,7 @@ var attributeLineItems = (lineItems = []) => lineItems.reduce(
3389
3417
  { attrMap: {}, attributedGross: 0, attributedLines: [] }
3390
3418
  );
3391
3419
  var generateDiscountCode = (0, import_nanoid2.customAlphabet)("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
3420
+ var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
3392
3421
  var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
3393
3422
  var OAUTH_ERROR_SOURCE = "oauth";
3394
3423
  var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
@@ -3446,7 +3475,7 @@ var shopify_default2 = {
3446
3475
  guide: [
3447
3476
  "Open the Drawbridge listing on the Shopify App Store.",
3448
3477
  "Install the app on the store you want to connect. It opens in Shopify admin and stays there.",
3449
- "Choose a plan when Shopify asks. The connection shows Pending until you do, then Active.",
3478
+ "Approve the Drawbridge plan when prompted \u2014 during install, or from the connection page here. The connection shows Pending until you do, then Active.",
3450
3479
  "Come back here \u2014 the connections list updates on its own once the install lands."
3451
3480
  ],
3452
3481
  // Names where the link GOES rather than what it does: installing happens on
@@ -3460,6 +3489,29 @@ var shopify_default2 = {
3460
3489
  // ONE STORE PER ORGANIZATION. Two Shopify stores on one org would give every
3461
3490
  // attributed order two possible sources.
3462
3491
  exclusive: true,
3492
+ // VENDOR-FACING EVENT IDENTITY, published so readers outside this package
3493
+ // (drawbridge-sync's usage queue, an operator checking the Dev Dashboard)
3494
+ // see the same handle the order hook mints into transactions and enqueues.
3495
+ events: {
3496
+ order: {
3497
+ handle: ORDER_EVENT_HANDLE,
3498
+ // THE SUBSCRIPTION THE APP MINTS FOR ITSELF (Billing API, not the
3499
+ // managed-pricing meter system): a $0 recurring line plus this usage
3500
+ // line, approved by the org owner at Shopify's confirmation screen.
3501
+ // `terms` is the merchant-visible consent copy; "up to" because the
3502
+ // actual rate is plan-derived (conversionRate) and must never exceed
3503
+ // what was approved. cappedAmount bounds one 30-day cycle's usage
3504
+ // charges — a record beyond it errors until the merchant raises it.
3505
+ // ponytail: $1,000 cap ≈ $66k attributed revenue/cycle at 1.5%; raise
3506
+ // via a re-approved subscription when a merchant outgrows it.
3507
+ pricing: {
3508
+ cappedAmount: 1e3,
3509
+ currency: "usd",
3510
+ name: "Drawbridge Orders",
3511
+ terms: "Up to 1.5% of revenue from orders attributed to Drawbridge campaigns"
3512
+ }
3513
+ }
3514
+ },
3463
3515
  feature: "organization:connection:shopify",
3464
3516
  fields: [
3465
3517
  {
@@ -3645,7 +3697,7 @@ var shopify_default2 = {
3645
3697
  // for one purchase. The hook used to loop for this itself; describing
3646
3698
  // the writes moved the retry to the queue, with the same guarantee.
3647
3699
  order: async ({ connection: connection2, context }, { logger: logger2, mintId, read } = {}) => {
3648
- var _a, _b, _c, _d, _e, _f;
3700
+ var _a, _b, _c, _d, _e, _f, _g, _h;
3649
3701
  const {
3650
3702
  advertisement,
3651
3703
  created_at: createdAt,
@@ -3887,8 +3939,26 @@ var shopify_default2 = {
3887
3939
  });
3888
3940
  }
3889
3941
  }
3890
- const enqueues = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && ((_f = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _f.id) && !backfill ? [{
3942
+ const billable = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && !backfill;
3943
+ if (billable && !((_f = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _f.id)) {
3944
+ (_g = logger2 == null ? void 0 : logger2.error) == null ? void 0 : _g.call(logger2, new Error(
3945
+ "shopify.usage.billing.skipped on order " + orderId + ": " + Math.round(fee * 100) + " cents not billed \u2014 connection " + (connection2 == null ? void 0 : connection2.id) + " has no source.id"
3946
+ ), {
3947
+ extra: {
3948
+ connectionId: connection2 == null ? void 0 : connection2.id,
3949
+ fee,
3950
+ orderDocId,
3951
+ orderId: String(orderId)
3952
+ }
3953
+ });
3954
+ }
3955
+ const enqueues = billable && ((_h = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _h.id) ? [{
3891
3956
  data: {
3957
+ // The meter handle travels WITH the job so the sender can
3958
+ // verify it against the transaction prefix and refuse a
3959
+ // drifted pair — idempotency keys are permanent, so a
3960
+ // mistraceable event can never be resent under its own key.
3961
+ handle: ORDER_EVENT_HANDLE,
3892
3962
  idempotencyKey: String(orderId),
3893
3963
  orderDocId,
3894
3964
  orderId: String(orderId),
@@ -3898,7 +3968,7 @@ var shopify_default2 = {
3898
3968
  // here — the event handle plus the order id — and sent as the
3899
3969
  // event's `reference`. queue/usage.js stamps the same id onto
3900
3970
  // the order as billed.transaction.
3901
- transaction: "drawbridge-orders." + orderId,
3971
+ transaction: ORDER_EVENT_HANDLE + "." + orderId,
3902
3972
  value: Math.round(fee * 100)
3903
3973
  },
3904
3974
  name: "billing",
@@ -4041,7 +4111,8 @@ var shopify_default2 = {
4041
4111
  // access token still works, reconciles the scopes the store granted
4042
4112
  // against the ones the app now needs, and queues a webhook
4043
4113
  // reconciliation.
4044
- health: async ({ connection: connection2, workflow }, { adminToken, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {}) => {
4114
+ health: async ({ connection: connection2, workflow }, { adminToken, logger: logger2, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {}) => {
4115
+ var _a, _b;
4045
4116
  const request2 = {
4046
4117
  connectionId: workflow.connection,
4047
4118
  organizationId: workflow.organization,
@@ -4060,7 +4131,50 @@ var shopify_default2 = {
4060
4131
  }
4061
4132
  await shopify.oauth.ping({ adminAccessToken, domain: connection2.shop });
4062
4133
  const scopesMissing = await reconcileScopes({ shop: connection2.shop });
4134
+ const writes = [];
4135
+ let source = connection2.source || null;
4136
+ let sourceRepaired = false;
4137
+ let metered = (source == null ? void 0 : source.metered) ?? null;
4138
+ let shopCurrency = null;
4139
+ let billingProbeFailed = false;
4140
+ try {
4141
+ if (!(source == null ? void 0 : source.id)) {
4142
+ const shopData = await shopify.oauth.getShop({ adminAccessToken, shop: connection2.shop });
4143
+ source = {
4144
+ domain: shopData.myshopifyDomain,
4145
+ id: String(shopData.id),
4146
+ label: shopData.name
4147
+ };
4148
+ shopCurrency = String(shopData.currency || "").toLowerCase() || null;
4149
+ sourceRepaired = true;
4150
+ }
4151
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
4152
+ adminAccessToken,
4153
+ domain: connection2.shop
4154
+ });
4155
+ if (subscriptions.length && subscriptions.every((subscription) => typeof subscription.metered === "boolean")) {
4156
+ metered = subscriptions.some((subscription) => subscription.metered);
4157
+ }
4158
+ } catch (probeError) {
4159
+ billingProbeFailed = true;
4160
+ (_a = logger2 == null ? void 0 : logger2.warn) == null ? void 0 : _a.call(logger2, "shopify.health.billing.probe.failed", {
4161
+ connectionId: connection2.id,
4162
+ message: probeError == null ? void 0 : probeError.message,
4163
+ shop: connection2.shop
4164
+ });
4165
+ }
4166
+ if (sourceRepaired || metered !== null && metered !== (((_b = connection2.source) == null ? void 0 : _b.metered) ?? null)) {
4167
+ writes.push({
4168
+ collection: "connection",
4169
+ data: {
4170
+ $set: sourceRepaired ? { source: { ...source, ...metered !== null && { metered } } } : { "source.metered": metered }
4171
+ },
4172
+ operation: "update",
4173
+ query: { id: connection2.id }
4174
+ });
4175
+ }
4063
4176
  return {
4177
+ writes,
4064
4178
  enqueues: [{
4065
4179
  data: {
4066
4180
  data: {
@@ -4073,13 +4187,19 @@ var shopify_default2 = {
4073
4187
  options: { jobId: "connection.update.register." + workflow.connection + "." + (0, import_node_crypto5.randomUUID)() },
4074
4188
  queue: "connection"
4075
4189
  }],
4076
- message: (scopesMissing == null ? void 0 : scopesMissing.length) ? "Health check: ping ok, webhooks reconciled \u2014 connection errored, granted scopes are missing: " + scopesMissing.join(", ") + "." : refreshTokenRotated ? "Health check passed \u2014 refresh token rotated, ping ok, webhooks reconciled." : "Health check passed \u2014 ping ok, webhooks reconciled.",
4190
+ message: ((scopesMissing == null ? void 0 : scopesMissing.length) ? "Health check: ping ok, webhooks reconciled \u2014 connection errored, granted scopes are missing: " + scopesMissing.join(", ") + "." : refreshTokenRotated ? "Health check passed \u2014 refresh token rotated, ping ok, webhooks reconciled." : "Health check passed \u2014 ping ok, webhooks reconciled.") + (sourceRepaired ? " Billing source repaired from the live shop." : "") + (metered === false ? " No usage line on the store's approved plan \u2014 order billing needs the merchant to approve the updated plan." : ""),
4077
4191
  request: request2,
4078
4192
  response: {
4193
+ billingProbeFailed,
4194
+ metered,
4079
4195
  pingedAt: /* @__PURE__ */ new Date(),
4080
4196
  refreshTokenExpiresAt: refreshTokenExpiresAt || null,
4081
4197
  refreshTokenRotated,
4082
4198
  scopesMissing,
4199
+ // The live settlement currency, for an operator — never
4200
+ // written, see above.
4201
+ ...shopCurrency && { shopCurrency },
4202
+ sourceRepaired,
4083
4203
  webhookReconciliationQueued: true
4084
4204
  }
4085
4205
  };
@@ -4347,12 +4467,35 @@ var shopify_default2 = {
4347
4467
  //
4348
4468
  // Scope drift is NOT here: drawbridge-sync writes it onto the connection
4349
4469
  // document, and the document's own warnings render beside these.
4350
- tasks: (data2) => (data2 == null ? void 0 : data2.status) === "pending" ? [
4351
- {
4352
- message: "Open the Drawbridge app in your Shopify admin and choose a plan. The connection activates once Shopify confirms it.",
4353
- title: "Choose a plan in Shopify"
4354
- }
4355
- ] : [],
4470
+ // Both cards carry `action : 'billing'`: the dashboard renders it as a real
4471
+ // button that mints the corrective subscription (POST .../billing) and sends
4472
+ // the owner to Shopify's confirmation screen the fix lives in OUR
4473
+ // dashboard, and the one Shopify-hosted step is the charge consent no app
4474
+ // can perform for a merchant.
4475
+ tasks: (data2) => {
4476
+ var _a;
4477
+ return [
4478
+ ...(data2 == null ? void 0 : data2.status) === "pending" ? [
4479
+ {
4480
+ action: "billing",
4481
+ message: "Approve the Drawbridge plan to activate this connection. You'll confirm the pricing on Shopify, then everything else happens here.",
4482
+ title: "Approve your plan"
4483
+ }
4484
+ ] : [],
4485
+ // The store's ACTIVE approval carries no usage component — stamped by
4486
+ // the daily health check and the approval webhook (`source.metered`).
4487
+ // Approved pricing is never retroactive, so the fix is a fresh approval.
4488
+ // Gated on an EXPLICIT false — a connection the probe hasn't reached
4489
+ // (or couldn't read) shows nothing rather than nagging on silence.
4490
+ ...(data2 == null ? void 0 : data2.status) === "active" && ((_a = data2 == null ? void 0 : data2.source) == null ? void 0 : _a.metered) === false ? [
4491
+ {
4492
+ action: "billing",
4493
+ message: "Order billing isn't set up for this store, so campaign-attributed orders aren't being charged. Approve the updated plan to fix it \u2014 one click here, one confirmation on Shopify.",
4494
+ title: "Fix order billing"
4495
+ }
4496
+ ] : []
4497
+ ];
4498
+ },
4356
4499
  title: "Shopify"
4357
4500
  };
4358
4501
 
@@ -4526,6 +4669,9 @@ var webhook_default = {
4526
4669
  // Nothing to be exclusive with — there is no second webhook vendor, and a
4527
4670
  // merchant with two endpoints is a step-level choice rather than a second
4528
4671
  // connection.
4672
+ // No vendor-facing event identities — declared empty so the contract stays
4673
+ // explicit: a vendor that gains one adds it here, never as a side channel.
4674
+ events: {},
4529
4675
  exclusive: false,
4530
4676
  feature: "organization:connection:webhook",
4531
4677
  fields: [
@@ -4817,16 +4963,16 @@ var connections = Object.freeze({
4817
4963
  (() => {
4818
4964
  var _a;
4819
4965
  const routes = {};
4820
- for (const [slug, manifest] of Object.entries(connections)) {
4966
+ for (const [slug2, manifest] of Object.entries(connections)) {
4821
4967
  for (const [name, step] of leaves(manifest.steps)) {
4822
4968
  const type = "step." + name;
4823
4969
  const queue = (_a = step({})) == null ? void 0 : _a.queue;
4824
4970
  if (routes[type] && routes[type].queue !== queue) {
4825
4971
  throw new Error(
4826
- "Step " + type + " routes to " + routes[type].queue + " for " + routes[type].slug + " and " + queue + " for " + slug + " \u2014 one of them would be enqueued nowhere"
4972
+ "Step " + type + " routes to " + routes[type].queue + " for " + routes[type].slug + " and " + queue + " for " + slug2 + " \u2014 one of them would be enqueued nowhere"
4827
4973
  );
4828
4974
  }
4829
- routes[type] = { queue, slug };
4975
+ routes[type] = { queue, slug: slug2 };
4830
4976
  }
4831
4977
  }
4832
4978
  })();
@@ -4839,21 +4985,21 @@ var catalogConnections = (env = {}) => Object.fromEntries(
4839
4985
  Object.entries(availableConnections(env)).filter(([, manifest]) => !manifest.private)
4840
4986
  );
4841
4987
  var publicSettingsBySlug = Object.fromEntries(
4842
- Object.entries(connections).map(([slug, manifest]) => [
4843
- slug,
4988
+ Object.entries(connections).map(([slug2, manifest]) => [
4989
+ slug2,
4844
4990
  manifest.fields.filter((field2) => !field2.redact).map((field2) => field2.key)
4845
4991
  ])
4846
4992
  );
4847
4993
  var connectionSteps = (env = {}) => Object.entries(availableConnections(env)).flatMap(
4848
- ([slug, manifest]) => leaves(manifest.steps).map(([name, step]) => ({ ...step({}), slug, type: "step." + name }))
4994
+ ([slug2, manifest]) => leaves(manifest.steps).map(([name, step]) => ({ ...step({}), slug: slug2, type: "step." + name }))
4849
4995
  );
4850
4996
  var hookSupport = (name) => ({
4851
- no: Object.keys(connections).filter((slug) => !implemented(connections[slug].hooks, name)),
4852
- yes: Object.keys(connections).filter((slug) => implemented(connections[slug].hooks, name))
4997
+ no: Object.keys(connections).filter((slug2) => !implemented(connections[slug2].hooks, name)),
4998
+ yes: Object.keys(connections).filter((slug2) => implemented(connections[slug2].hooks, name))
4853
4999
  });
4854
- var connectFields = (slug) => {
5000
+ var connectFields = (slug2) => {
4855
5001
  var _a;
4856
- return (((_a = connections[slug]) == null ? void 0 : _a.fields) || []).map(({ copy, from, hook, input, key, label, message, options, placeholder, redact, required, search }) => ({
5002
+ return (((_a = connections[slug2]) == null ? void 0 : _a.fields) || []).map(({ copy, from, hook, input, key, label, message, options, placeholder, redact, required, search }) => ({
4857
5003
  ...copy && { copy: true },
4858
5004
  ...hook && { hook },
4859
5005
  ...from && { from },
@@ -4873,18 +5019,18 @@ var connectFields = (slug) => {
4873
5019
  secret: Boolean(redact)
4874
5020
  }));
4875
5021
  };
4876
- var runHook = async (slug, name, props = {}, options = {}) => {
4877
- const manifest = connections[slug];
4878
- if (!manifest) return { outcome: OUTCOMES.unsupported, reason: "no such connection: " + slug };
5022
+ var runHook = async (slug2, name, props = {}, options = {}) => {
5023
+ const manifest = connections[slug2];
5024
+ if (!manifest) return { outcome: OUTCOMES.unsupported, reason: "no such connection: " + slug2 };
4879
5025
  const hook = name.split(".").reduce(
4880
5026
  (node, key) => node && typeof node === "object" && Object.hasOwn(node, key) ? node[key] : void 0,
4881
5027
  manifest.hooks
4882
5028
  );
4883
5029
  if (hook === false || hook == null) {
4884
- return { outcome: OUTCOMES.unsupported, reason: slug + " does not implement " + name };
5030
+ return { outcome: OUTCOMES.unsupported, reason: slug2 + " does not implement " + name };
4885
5031
  }
4886
5032
  if (typeof hook !== "function") {
4887
- return { outcome: OUTCOMES.unimplemented, reason: slug + " implements " + name + " outside this package" };
5033
+ return { outcome: OUTCOMES.unimplemented, reason: slug2 + " implements " + name + " outside this package" };
4888
5034
  }
4889
5035
  try {
4890
5036
  const result = await hook({ ...props, manifest }, options);
@@ -4919,9 +5065,9 @@ var mergeSettings = ({ existing, incoming }) => {
4919
5065
  ;
4920
5066
  return merged;
4921
5067
  };
4922
- var redactSettings = ({ slug, settings }) => {
5068
+ var redactSettings = ({ slug: slug2, settings }) => {
4923
5069
  if (!settings || typeof settings !== "object") return settings;
4924
- const allowed = publicSettingsBySlug[slug] || [];
5070
+ const allowed = publicSettingsBySlug[slug2] || [];
4925
5071
  return Object.fromEntries(
4926
5072
  Object.entries(settings).filter(([key]) => allowed.includes(key))
4927
5073
  );