@drawbridge/drawbridge-utils 0.0.137 → 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.
package/dist/providers.js CHANGED
@@ -501,6 +501,9 @@ var attentive_default2 = {
501
501
  },
502
502
  // A contact destination, like Klaviyo and Mailchimp — a merchant could
503
503
  // reasonably keep several up to date at once.
504
+ // No vendor-facing event identities — declared empty so the contract stays
505
+ // explicit: a vendor that gains one adds it here, never as a side channel.
506
+ events: {},
504
507
  exclusive: false,
505
508
  feature: "organization:connection:attentive",
506
509
  fields: [
@@ -1681,6 +1684,9 @@ var drawbridge_default2 = {
1681
1684
  "Nothing to do. These steps are available in every workflow builder."
1682
1685
  ]
1683
1686
  },
1687
+ // No vendor-facing event identities — declared empty so the contract stays
1688
+ // explicit: a vendor that gains one adds it here, never as a side channel.
1689
+ events: {},
1684
1690
  exclusive: false,
1685
1691
  fields: [],
1686
1692
  group: "developer",
@@ -2465,6 +2471,9 @@ var klaviyo_default2 = {
2465
2471
  // current — so the exclusivity that once applied when these were SENDERS is
2466
2472
  // deliberately gone. That was removed once already; declaring it out loud is
2467
2473
  // what stops it coming back by inference.
2474
+ // No vendor-facing event identities — declared empty so the contract stays
2475
+ // explicit: a vendor that gains one adds it here, never as a side channel.
2476
+ events: {},
2468
2477
  exclusive: false,
2469
2478
  feature: "organization:connection:klaviyo",
2470
2479
  fields: [
@@ -2941,6 +2950,9 @@ var mailchimp_default2 = {
2941
2950
  // picking two providers to send the same mail was meaningless. As contact
2942
2951
  // syncs they are destinations, and a merchant could reasonably keep several
2943
2952
  // up to date, so the exclusivity is gone.
2953
+ // No vendor-facing event identities — declared empty so the contract stays
2954
+ // explicit: a vendor that gains one adds it here, never as a side channel.
2955
+ events: {},
2944
2956
  exclusive: false,
2945
2957
  feature: "organization:connection:mailchimp",
2946
2958
  fields: [
@@ -3216,6 +3228,22 @@ var toCanonicalEmail = (value) => {
3216
3228
  return local + "@" + domain;
3217
3229
  };
3218
3230
 
3231
+ // lib/slugify.js
3232
+ import slug from "slugify";
3233
+ var slugify = (value, nochars = false) => {
3234
+ const remove = nochars ? /[-?*+~.()'"!:@#^_{}\[\];,/\\]/g : /[?*+~.()'"!:@#^_{}\[\];,/\\]/g;
3235
+ const replacement = nochars ? "" : "-";
3236
+ return slug(
3237
+ value,
3238
+ {
3239
+ remove,
3240
+ replacement,
3241
+ lower: true,
3242
+ trim: true
3243
+ }
3244
+ );
3245
+ };
3246
+
3219
3247
  // lib/connections/providers/shopify.js
3220
3248
  var toLine = ({
3221
3249
  price,
@@ -3251,6 +3279,7 @@ var attributeLineItems = (lineItems = []) => lineItems.reduce(
3251
3279
  { attrMap: {}, attributedGross: 0, attributedLines: [] }
3252
3280
  );
3253
3281
  var generateDiscountCode = customAlphabet2("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
3282
+ var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
3254
3283
  var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
3255
3284
  var OAUTH_ERROR_SOURCE = "oauth";
3256
3285
  var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
@@ -3308,7 +3337,7 @@ var shopify_default2 = {
3308
3337
  guide: [
3309
3338
  "Open the Drawbridge listing on the Shopify App Store.",
3310
3339
  "Install the app on the store you want to connect. It opens in Shopify admin and stays there.",
3311
- "Choose a plan when Shopify asks. The connection shows Pending until you do, then Active.",
3340
+ "Approve the Drawbridge plan when prompted \u2014 during install, or from the connection page here. The connection shows Pending until you do, then Active.",
3312
3341
  "Come back here \u2014 the connections list updates on its own once the install lands."
3313
3342
  ],
3314
3343
  // Names where the link GOES rather than what it does: installing happens on
@@ -3322,6 +3351,29 @@ var shopify_default2 = {
3322
3351
  // ONE STORE PER ORGANIZATION. Two Shopify stores on one org would give every
3323
3352
  // attributed order two possible sources.
3324
3353
  exclusive: true,
3354
+ // VENDOR-FACING EVENT IDENTITY, published so readers outside this package
3355
+ // (drawbridge-sync's usage queue, an operator checking the Dev Dashboard)
3356
+ // see the same handle the order hook mints into transactions and enqueues.
3357
+ events: {
3358
+ order: {
3359
+ handle: ORDER_EVENT_HANDLE,
3360
+ // THE SUBSCRIPTION THE APP MINTS FOR ITSELF (Billing API, not the
3361
+ // managed-pricing meter system): a $0 recurring line plus this usage
3362
+ // line, approved by the org owner at Shopify's confirmation screen.
3363
+ // `terms` is the merchant-visible consent copy; "up to" because the
3364
+ // actual rate is plan-derived (conversionRate) and must never exceed
3365
+ // what was approved. cappedAmount bounds one 30-day cycle's usage
3366
+ // charges — a record beyond it errors until the merchant raises it.
3367
+ // ponytail: $1,000 cap ≈ $66k attributed revenue/cycle at 1.5%; raise
3368
+ // via a re-approved subscription when a merchant outgrows it.
3369
+ pricing: {
3370
+ cappedAmount: 1e3,
3371
+ currency: "usd",
3372
+ name: "Drawbridge Orders",
3373
+ terms: "Up to 1.5% of revenue from orders attributed to Drawbridge campaigns"
3374
+ }
3375
+ }
3376
+ },
3325
3377
  feature: "organization:connection:shopify",
3326
3378
  fields: [
3327
3379
  {
@@ -3507,7 +3559,7 @@ var shopify_default2 = {
3507
3559
  // for one purchase. The hook used to loop for this itself; describing
3508
3560
  // the writes moved the retry to the queue, with the same guarantee.
3509
3561
  order: async ({ connection: connection2, context }, { logger: logger2, mintId, read } = {}) => {
3510
- var _a, _b, _c, _d, _e, _f;
3562
+ var _a, _b, _c, _d, _e, _f, _g, _h;
3511
3563
  const {
3512
3564
  advertisement,
3513
3565
  created_at: createdAt,
@@ -3749,8 +3801,26 @@ var shopify_default2 = {
3749
3801
  });
3750
3802
  }
3751
3803
  }
3752
- const enqueues = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && ((_f = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _f.id) && !backfill ? [{
3804
+ const billable = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && !backfill;
3805
+ if (billable && !((_f = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _f.id)) {
3806
+ (_g = logger2 == null ? void 0 : logger2.error) == null ? void 0 : _g.call(logger2, new Error(
3807
+ "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"
3808
+ ), {
3809
+ extra: {
3810
+ connectionId: connection2 == null ? void 0 : connection2.id,
3811
+ fee,
3812
+ orderDocId,
3813
+ orderId: String(orderId)
3814
+ }
3815
+ });
3816
+ }
3817
+ const enqueues = billable && ((_h = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _h.id) ? [{
3753
3818
  data: {
3819
+ // The meter handle travels WITH the job so the sender can
3820
+ // verify it against the transaction prefix and refuse a
3821
+ // drifted pair — idempotency keys are permanent, so a
3822
+ // mistraceable event can never be resent under its own key.
3823
+ handle: ORDER_EVENT_HANDLE,
3754
3824
  idempotencyKey: String(orderId),
3755
3825
  orderDocId,
3756
3826
  orderId: String(orderId),
@@ -3760,7 +3830,7 @@ var shopify_default2 = {
3760
3830
  // here — the event handle plus the order id — and sent as the
3761
3831
  // event's `reference`. queue/usage.js stamps the same id onto
3762
3832
  // the order as billed.transaction.
3763
- transaction: "drawbridge-orders." + orderId,
3833
+ transaction: ORDER_EVENT_HANDLE + "." + orderId,
3764
3834
  value: Math.round(fee * 100)
3765
3835
  },
3766
3836
  name: "billing",
@@ -3903,7 +3973,8 @@ var shopify_default2 = {
3903
3973
  // access token still works, reconciles the scopes the store granted
3904
3974
  // against the ones the app now needs, and queues a webhook
3905
3975
  // reconciliation.
3906
- health: async ({ connection: connection2, workflow }, { adminToken, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {}) => {
3976
+ health: async ({ connection: connection2, workflow }, { adminToken, logger: logger2, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {}) => {
3977
+ var _a, _b;
3907
3978
  const request2 = {
3908
3979
  connectionId: workflow.connection,
3909
3980
  organizationId: workflow.organization,
@@ -3922,7 +3993,50 @@ var shopify_default2 = {
3922
3993
  }
3923
3994
  await shopify.oauth.ping({ adminAccessToken, domain: connection2.shop });
3924
3995
  const scopesMissing = await reconcileScopes({ shop: connection2.shop });
3996
+ const writes = [];
3997
+ let source = connection2.source || null;
3998
+ let sourceRepaired = false;
3999
+ let metered = (source == null ? void 0 : source.metered) ?? null;
4000
+ let shopCurrency = null;
4001
+ let billingProbeFailed = false;
4002
+ try {
4003
+ if (!(source == null ? void 0 : source.id)) {
4004
+ const shopData = await shopify.oauth.getShop({ adminAccessToken, shop: connection2.shop });
4005
+ source = {
4006
+ domain: shopData.myshopifyDomain,
4007
+ id: String(shopData.id),
4008
+ label: shopData.name
4009
+ };
4010
+ shopCurrency = String(shopData.currency || "").toLowerCase() || null;
4011
+ sourceRepaired = true;
4012
+ }
4013
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
4014
+ adminAccessToken,
4015
+ domain: connection2.shop
4016
+ });
4017
+ if (subscriptions.length && subscriptions.every((subscription) => typeof subscription.metered === "boolean")) {
4018
+ metered = subscriptions.some((subscription) => subscription.metered);
4019
+ }
4020
+ } catch (probeError) {
4021
+ billingProbeFailed = true;
4022
+ (_a = logger2 == null ? void 0 : logger2.warn) == null ? void 0 : _a.call(logger2, "shopify.health.billing.probe.failed", {
4023
+ connectionId: connection2.id,
4024
+ message: probeError == null ? void 0 : probeError.message,
4025
+ shop: connection2.shop
4026
+ });
4027
+ }
4028
+ if (sourceRepaired || metered !== null && metered !== (((_b = connection2.source) == null ? void 0 : _b.metered) ?? null)) {
4029
+ writes.push({
4030
+ collection: "connection",
4031
+ data: {
4032
+ $set: sourceRepaired ? { source: { ...source, ...metered !== null && { metered } } } : { "source.metered": metered }
4033
+ },
4034
+ operation: "update",
4035
+ query: { id: connection2.id }
4036
+ });
4037
+ }
3925
4038
  return {
4039
+ writes,
3926
4040
  enqueues: [{
3927
4041
  data: {
3928
4042
  data: {
@@ -3935,13 +4049,19 @@ var shopify_default2 = {
3935
4049
  options: { jobId: "connection.update.register." + workflow.connection + "." + randomUUID() },
3936
4050
  queue: "connection"
3937
4051
  }],
3938
- 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.",
4052
+ 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." : ""),
3939
4053
  request: request2,
3940
4054
  response: {
4055
+ billingProbeFailed,
4056
+ metered,
3941
4057
  pingedAt: /* @__PURE__ */ new Date(),
3942
4058
  refreshTokenExpiresAt: refreshTokenExpiresAt || null,
3943
4059
  refreshTokenRotated,
3944
4060
  scopesMissing,
4061
+ // The live settlement currency, for an operator — never
4062
+ // written, see above.
4063
+ ...shopCurrency && { shopCurrency },
4064
+ sourceRepaired,
3945
4065
  webhookReconciliationQueued: true
3946
4066
  }
3947
4067
  };
@@ -4209,12 +4329,35 @@ var shopify_default2 = {
4209
4329
  //
4210
4330
  // Scope drift is NOT here: drawbridge-sync writes it onto the connection
4211
4331
  // document, and the document's own warnings render beside these.
4212
- tasks: (data2) => (data2 == null ? void 0 : data2.status) === "pending" ? [
4213
- {
4214
- message: "Open the Drawbridge app in your Shopify admin and choose a plan. The connection activates once Shopify confirms it.",
4215
- title: "Choose a plan in Shopify"
4216
- }
4217
- ] : [],
4332
+ // Both cards carry `action : 'billing'`: the dashboard renders it as a real
4333
+ // button that mints the corrective subscription (POST .../billing) and sends
4334
+ // the owner to Shopify's confirmation screen the fix lives in OUR
4335
+ // dashboard, and the one Shopify-hosted step is the charge consent no app
4336
+ // can perform for a merchant.
4337
+ tasks: (data2) => {
4338
+ var _a;
4339
+ return [
4340
+ ...(data2 == null ? void 0 : data2.status) === "pending" ? [
4341
+ {
4342
+ action: "billing",
4343
+ message: "Approve the Drawbridge plan to activate this connection. You'll confirm the pricing on Shopify, then everything else happens here.",
4344
+ title: "Approve your plan"
4345
+ }
4346
+ ] : [],
4347
+ // The store's ACTIVE approval carries no usage component — stamped by
4348
+ // the daily health check and the approval webhook (`source.metered`).
4349
+ // Approved pricing is never retroactive, so the fix is a fresh approval.
4350
+ // Gated on an EXPLICIT false — a connection the probe hasn't reached
4351
+ // (or couldn't read) shows nothing rather than nagging on silence.
4352
+ ...(data2 == null ? void 0 : data2.status) === "active" && ((_a = data2 == null ? void 0 : data2.source) == null ? void 0 : _a.metered) === false ? [
4353
+ {
4354
+ action: "billing",
4355
+ 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.",
4356
+ title: "Fix order billing"
4357
+ }
4358
+ ] : []
4359
+ ];
4360
+ },
4218
4361
  title: "Shopify"
4219
4362
  };
4220
4363
 
@@ -4388,6 +4531,9 @@ var webhook_default = {
4388
4531
  // Nothing to be exclusive with — there is no second webhook vendor, and a
4389
4532
  // merchant with two endpoints is a step-level choice rather than a second
4390
4533
  // connection.
4534
+ // No vendor-facing event identities — declared empty so the contract stays
4535
+ // explicit: a vendor that gains one adds it here, never as a side channel.
4536
+ events: {},
4391
4537
  exclusive: false,
4392
4538
  feature: "organization:connection:webhook",
4393
4539
  fields: [
@@ -4674,22 +4820,22 @@ var connections = Object.freeze({
4674
4820
  (() => {
4675
4821
  var _a;
4676
4822
  const routes = {};
4677
- for (const [slug, manifest] of Object.entries(connections)) {
4823
+ for (const [slug2, manifest] of Object.entries(connections)) {
4678
4824
  for (const [name, step] of leaves(manifest.steps)) {
4679
4825
  const type = "step." + name;
4680
4826
  const queue = (_a = step({})) == null ? void 0 : _a.queue;
4681
4827
  if (routes[type] && routes[type].queue !== queue) {
4682
4828
  throw new Error(
4683
- "Step " + type + " routes to " + routes[type].queue + " for " + routes[type].slug + " and " + queue + " for " + slug + " \u2014 one of them would be enqueued nowhere"
4829
+ "Step " + type + " routes to " + routes[type].queue + " for " + routes[type].slug + " and " + queue + " for " + slug2 + " \u2014 one of them would be enqueued nowhere"
4684
4830
  );
4685
4831
  }
4686
- routes[type] = { queue, slug };
4832
+ routes[type] = { queue, slug: slug2 };
4687
4833
  }
4688
4834
  }
4689
4835
  })();
4690
4836
  var publicSettingsBySlug = Object.fromEntries(
4691
- Object.entries(connections).map(([slug, manifest]) => [
4692
- slug,
4837
+ Object.entries(connections).map(([slug2, manifest]) => [
4838
+ slug2,
4693
4839
  manifest.fields.filter((field2) => !field2.redact).map((field2) => field2.key)
4694
4840
  ])
4695
4841
  );
@@ -4777,13 +4923,13 @@ var decrypt = (value) => {
4777
4923
  };
4778
4924
 
4779
4925
  // lib/providers.js
4780
- var providerFields = (slug) => {
4926
+ var providerFields = (slug2) => {
4781
4927
  var _a;
4782
- return Object.hasOwn(connections, slug) ? ((_a = connections[slug].provider) == null ? void 0 : _a.fields) || [] : [];
4928
+ return Object.hasOwn(connections, slug2) ? ((_a = connections[slug2].provider) == null ? void 0 : _a.fields) || [] : [];
4783
4929
  };
4784
- var providerSlugs = () => Object.keys(connections).filter((slug) => connections[slug].provider).sort();
4785
- var isLive = (slug, settings) => {
4786
- const fields2 = providerFields(slug);
4930
+ var providerSlugs = () => Object.keys(connections).filter((slug2) => connections[slug2].provider).sort();
4931
+ var isLive = (slug2, settings) => {
4932
+ const fields2 = providerFields(slug2);
4787
4933
  if (!fields2.length) return false;
4788
4934
  return fields2.filter((field2) => field2.required).every((field2) => Boolean(settings == null ? void 0 : settings[field2.key]));
4789
4935
  };
@@ -4795,23 +4941,23 @@ var mask = (value) => {
4795
4941
  var providerMemo = /* @__PURE__ */ new Map();
4796
4942
  var MEMO_TTL_MS = 60 * 1e3;
4797
4943
  var clearProviderMemo = () => providerMemo.clear();
4798
- var providerSettings = async ({ controller, slug }) => {
4799
- const memoized = providerMemo.get(slug);
4944
+ var providerSettings = async ({ controller, slug: slug2 }) => {
4945
+ const memoized = providerMemo.get(slug2);
4800
4946
  if (memoized && Date.now() - memoized.at < MEMO_TTL_MS) return memoized.value;
4801
4947
  const row = await controller.get({
4802
4948
  collection: "provider",
4803
- query: { slug }
4949
+ query: { slug: slug2 }
4804
4950
  });
4805
4951
  const value = (row == null ? void 0 : row.settings) ? decrypt(row.settings) : {};
4806
- providerMemo.set(slug, { at: Date.now(), value });
4952
+ providerMemo.set(slug2, { at: Date.now(), value });
4807
4953
  return value;
4808
4954
  };
4809
- var saveProviderSettings = async ({ authenticated, clear, controller, settings, slug }) => {
4810
- const fields2 = providerFields(slug);
4955
+ var saveProviderSettings = async ({ authenticated, clear, controller, settings, slug: slug2 }) => {
4956
+ const fields2 = providerFields(slug2);
4811
4957
  if (!fields2.length) return null;
4812
4958
  const existing = await controller.get({
4813
4959
  collection: "provider",
4814
- query: { slug }
4960
+ query: { slug: slug2 }
4815
4961
  });
4816
4962
  const stored = (existing == null ? void 0 : existing.settings) ? decrypt(existing.settings) : {};
4817
4963
  const pasted = (value) => typeof value === "string" ? value.trim().replace(/^['"]+|['"]+$/g, "") : value;
@@ -4833,20 +4979,20 @@ var saveProviderSettings = async ({ authenticated, clear, controller, settings,
4833
4979
  options: {
4834
4980
  upsert: true
4835
4981
  },
4836
- query: { slug }
4982
+ query: { slug: slug2 }
4837
4983
  });
4838
- providerMemo.delete(slug);
4984
+ providerMemo.delete(slug2);
4839
4985
  return result;
4840
4986
  };
4841
4987
  var providerEnvNames = () => new Set(
4842
- providerSlugs().flatMap((slug) => providerFields(slug)).map((field2) => field2.credential).filter(Boolean)
4988
+ providerSlugs().flatMap((slug2) => providerFields(slug2)).map((field2) => field2.credential).filter(Boolean)
4843
4989
  );
4844
4990
  var providerCredentials = async ({ controller }) => {
4845
4991
  const credentials2 = {};
4846
- for (const slug of providerSlugs()) {
4992
+ for (const slug2 of providerSlugs()) {
4847
4993
  try {
4848
- const settings = await providerSettings({ controller, slug });
4849
- for (const field2 of providerFields(slug)) {
4994
+ const settings = await providerSettings({ controller, slug: slug2 });
4995
+ for (const field2 of providerFields(slug2)) {
4850
4996
  const value = settings == null ? void 0 : settings[field2.key];
4851
4997
  if (field2.credential && value) credentials2[field2.credential] = value;
4852
4998
  }
package/package.json CHANGED
@@ -215,5 +215,5 @@
215
215
  "test": ". \"$HOME/.nvm/nvm.sh\" && nvm use && node --test"
216
216
  },
217
217
  "types": "dist/index.d.ts",
218
- "version": "0.0.137"
218
+ "version": "0.0.139"
219
219
  }