@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.
@@ -545,6 +545,9 @@ var attentive_default2 = {
545
545
  },
546
546
  // A contact destination, like Klaviyo and Mailchimp — a merchant could
547
547
  // reasonably keep several up to date at once.
548
+ // No vendor-facing event identities — declared empty so the contract stays
549
+ // explicit: a vendor that gains one adds it here, never as a side channel.
550
+ events: {},
548
551
  exclusive: false,
549
552
  feature: "organization:connection:attentive",
550
553
  fields: [
@@ -1725,6 +1728,9 @@ var drawbridge_default2 = {
1725
1728
  "Nothing to do. These steps are available in every workflow builder."
1726
1729
  ]
1727
1730
  },
1731
+ // No vendor-facing event identities — declared empty so the contract stays
1732
+ // explicit: a vendor that gains one adds it here, never as a side channel.
1733
+ events: {},
1728
1734
  exclusive: false,
1729
1735
  fields: [],
1730
1736
  group: "developer",
@@ -2509,6 +2515,9 @@ var klaviyo_default2 = {
2509
2515
  // current — so the exclusivity that once applied when these were SENDERS is
2510
2516
  // deliberately gone. That was removed once already; declaring it out loud is
2511
2517
  // what stops it coming back by inference.
2518
+ // No vendor-facing event identities — declared empty so the contract stays
2519
+ // explicit: a vendor that gains one adds it here, never as a side channel.
2520
+ events: {},
2512
2521
  exclusive: false,
2513
2522
  feature: "organization:connection:klaviyo",
2514
2523
  fields: [
@@ -2985,6 +2994,9 @@ var mailchimp_default2 = {
2985
2994
  // picking two providers to send the same mail was meaningless. As contact
2986
2995
  // syncs they are destinations, and a merchant could reasonably keep several
2987
2996
  // up to date, so the exclusivity is gone.
2997
+ // No vendor-facing event identities — declared empty so the contract stays
2998
+ // explicit: a vendor that gains one adds it here, never as a side channel.
2999
+ events: {},
2988
3000
  exclusive: false,
2989
3001
  feature: "organization:connection:mailchimp",
2990
3002
  fields: [
@@ -3260,6 +3272,22 @@ var toCanonicalEmail = (value) => {
3260
3272
  return local + "@" + domain;
3261
3273
  };
3262
3274
 
3275
+ // lib/slugify.js
3276
+ var import_slugify = __toESM(require("slugify"), 1);
3277
+ var slugify = (value, nochars = false) => {
3278
+ const remove = nochars ? /[-?*+~.()'"!:@#^_{}\[\];,/\\]/g : /[?*+~.()'"!:@#^_{}\[\];,/\\]/g;
3279
+ const replacement = nochars ? "" : "-";
3280
+ return (0, import_slugify.default)(
3281
+ value,
3282
+ {
3283
+ remove,
3284
+ replacement,
3285
+ lower: true,
3286
+ trim: true
3287
+ }
3288
+ );
3289
+ };
3290
+
3263
3291
  // lib/connections/providers/shopify.js
3264
3292
  var toLine = ({
3265
3293
  price,
@@ -3295,6 +3323,7 @@ var attributeLineItems = (lineItems = []) => lineItems.reduce(
3295
3323
  { attrMap: {}, attributedGross: 0, attributedLines: [] }
3296
3324
  );
3297
3325
  var generateDiscountCode = (0, import_nanoid2.customAlphabet)("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
3326
+ var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
3298
3327
  var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
3299
3328
  var OAUTH_ERROR_SOURCE = "oauth";
3300
3329
  var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
@@ -3352,7 +3381,7 @@ var shopify_default2 = {
3352
3381
  guide: [
3353
3382
  "Open the Drawbridge listing on the Shopify App Store.",
3354
3383
  "Install the app on the store you want to connect. It opens in Shopify admin and stays there.",
3355
- "Choose a plan when Shopify asks. The connection shows Pending until you do, then Active.",
3384
+ "Approve the Drawbridge plan when prompted \u2014 during install, or from the connection page here. The connection shows Pending until you do, then Active.",
3356
3385
  "Come back here \u2014 the connections list updates on its own once the install lands."
3357
3386
  ],
3358
3387
  // Names where the link GOES rather than what it does: installing happens on
@@ -3366,6 +3395,29 @@ var shopify_default2 = {
3366
3395
  // ONE STORE PER ORGANIZATION. Two Shopify stores on one org would give every
3367
3396
  // attributed order two possible sources.
3368
3397
  exclusive: true,
3398
+ // VENDOR-FACING EVENT IDENTITY, published so readers outside this package
3399
+ // (drawbridge-sync's usage queue, an operator checking the Dev Dashboard)
3400
+ // see the same handle the order hook mints into transactions and enqueues.
3401
+ events: {
3402
+ order: {
3403
+ handle: ORDER_EVENT_HANDLE,
3404
+ // THE SUBSCRIPTION THE APP MINTS FOR ITSELF (Billing API, not the
3405
+ // managed-pricing meter system): a $0 recurring line plus this usage
3406
+ // line, approved by the org owner at Shopify's confirmation screen.
3407
+ // `terms` is the merchant-visible consent copy; "up to" because the
3408
+ // actual rate is plan-derived (conversionRate) and must never exceed
3409
+ // what was approved. cappedAmount bounds one 30-day cycle's usage
3410
+ // charges — a record beyond it errors until the merchant raises it.
3411
+ // ponytail: $1,000 cap ≈ $66k attributed revenue/cycle at 1.5%; raise
3412
+ // via a re-approved subscription when a merchant outgrows it.
3413
+ pricing: {
3414
+ cappedAmount: 1e3,
3415
+ currency: "usd",
3416
+ name: "Drawbridge Orders",
3417
+ terms: "Up to 1.5% of revenue from orders attributed to Drawbridge campaigns"
3418
+ }
3419
+ }
3420
+ },
3369
3421
  feature: "organization:connection:shopify",
3370
3422
  fields: [
3371
3423
  {
@@ -3551,7 +3603,7 @@ var shopify_default2 = {
3551
3603
  // for one purchase. The hook used to loop for this itself; describing
3552
3604
  // the writes moved the retry to the queue, with the same guarantee.
3553
3605
  order: async ({ connection: connection2, context }, { logger: logger2, mintId, read } = {}) => {
3554
- var _a, _b, _c, _d, _e, _f;
3606
+ var _a, _b, _c, _d, _e, _f, _g, _h;
3555
3607
  const {
3556
3608
  advertisement,
3557
3609
  created_at: createdAt,
@@ -3793,8 +3845,26 @@ var shopify_default2 = {
3793
3845
  });
3794
3846
  }
3795
3847
  }
3796
- const enqueues = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && ((_f = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _f.id) && !backfill ? [{
3848
+ const billable = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && !backfill;
3849
+ if (billable && !((_f = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _f.id)) {
3850
+ (_g = logger2 == null ? void 0 : logger2.error) == null ? void 0 : _g.call(logger2, new Error(
3851
+ "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"
3852
+ ), {
3853
+ extra: {
3854
+ connectionId: connection2 == null ? void 0 : connection2.id,
3855
+ fee,
3856
+ orderDocId,
3857
+ orderId: String(orderId)
3858
+ }
3859
+ });
3860
+ }
3861
+ const enqueues = billable && ((_h = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _h.id) ? [{
3797
3862
  data: {
3863
+ // The meter handle travels WITH the job so the sender can
3864
+ // verify it against the transaction prefix and refuse a
3865
+ // drifted pair — idempotency keys are permanent, so a
3866
+ // mistraceable event can never be resent under its own key.
3867
+ handle: ORDER_EVENT_HANDLE,
3798
3868
  idempotencyKey: String(orderId),
3799
3869
  orderDocId,
3800
3870
  orderId: String(orderId),
@@ -3804,7 +3874,7 @@ var shopify_default2 = {
3804
3874
  // here — the event handle plus the order id — and sent as the
3805
3875
  // event's `reference`. queue/usage.js stamps the same id onto
3806
3876
  // the order as billed.transaction.
3807
- transaction: "drawbridge-orders." + orderId,
3877
+ transaction: ORDER_EVENT_HANDLE + "." + orderId,
3808
3878
  value: Math.round(fee * 100)
3809
3879
  },
3810
3880
  name: "billing",
@@ -3947,7 +4017,8 @@ var shopify_default2 = {
3947
4017
  // access token still works, reconciles the scopes the store granted
3948
4018
  // against the ones the app now needs, and queues a webhook
3949
4019
  // reconciliation.
3950
- health: async ({ connection: connection2, workflow }, { adminToken, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {}) => {
4020
+ health: async ({ connection: connection2, workflow }, { adminToken, logger: logger2, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {}) => {
4021
+ var _a, _b;
3951
4022
  const request2 = {
3952
4023
  connectionId: workflow.connection,
3953
4024
  organizationId: workflow.organization,
@@ -3966,7 +4037,50 @@ var shopify_default2 = {
3966
4037
  }
3967
4038
  await shopify.oauth.ping({ adminAccessToken, domain: connection2.shop });
3968
4039
  const scopesMissing = await reconcileScopes({ shop: connection2.shop });
4040
+ const writes = [];
4041
+ let source = connection2.source || null;
4042
+ let sourceRepaired = false;
4043
+ let metered = (source == null ? void 0 : source.metered) ?? null;
4044
+ let shopCurrency = null;
4045
+ let billingProbeFailed = false;
4046
+ try {
4047
+ if (!(source == null ? void 0 : source.id)) {
4048
+ const shopData = await shopify.oauth.getShop({ adminAccessToken, shop: connection2.shop });
4049
+ source = {
4050
+ domain: shopData.myshopifyDomain,
4051
+ id: String(shopData.id),
4052
+ label: shopData.name
4053
+ };
4054
+ shopCurrency = String(shopData.currency || "").toLowerCase() || null;
4055
+ sourceRepaired = true;
4056
+ }
4057
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
4058
+ adminAccessToken,
4059
+ domain: connection2.shop
4060
+ });
4061
+ if (subscriptions.length && subscriptions.every((subscription) => typeof subscription.metered === "boolean")) {
4062
+ metered = subscriptions.some((subscription) => subscription.metered);
4063
+ }
4064
+ } catch (probeError) {
4065
+ billingProbeFailed = true;
4066
+ (_a = logger2 == null ? void 0 : logger2.warn) == null ? void 0 : _a.call(logger2, "shopify.health.billing.probe.failed", {
4067
+ connectionId: connection2.id,
4068
+ message: probeError == null ? void 0 : probeError.message,
4069
+ shop: connection2.shop
4070
+ });
4071
+ }
4072
+ if (sourceRepaired || metered !== null && metered !== (((_b = connection2.source) == null ? void 0 : _b.metered) ?? null)) {
4073
+ writes.push({
4074
+ collection: "connection",
4075
+ data: {
4076
+ $set: sourceRepaired ? { source: { ...source, ...metered !== null && { metered } } } : { "source.metered": metered }
4077
+ },
4078
+ operation: "update",
4079
+ query: { id: connection2.id }
4080
+ });
4081
+ }
3969
4082
  return {
4083
+ writes,
3970
4084
  enqueues: [{
3971
4085
  data: {
3972
4086
  data: {
@@ -3979,13 +4093,19 @@ var shopify_default2 = {
3979
4093
  options: { jobId: "connection.update.register." + workflow.connection + "." + (0, import_node_crypto5.randomUUID)() },
3980
4094
  queue: "connection"
3981
4095
  }],
3982
- 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.",
4096
+ 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." : ""),
3983
4097
  request: request2,
3984
4098
  response: {
4099
+ billingProbeFailed,
4100
+ metered,
3985
4101
  pingedAt: /* @__PURE__ */ new Date(),
3986
4102
  refreshTokenExpiresAt: refreshTokenExpiresAt || null,
3987
4103
  refreshTokenRotated,
3988
4104
  scopesMissing,
4105
+ // The live settlement currency, for an operator — never
4106
+ // written, see above.
4107
+ ...shopCurrency && { shopCurrency },
4108
+ sourceRepaired,
3989
4109
  webhookReconciliationQueued: true
3990
4110
  }
3991
4111
  };
@@ -4253,12 +4373,35 @@ var shopify_default2 = {
4253
4373
  //
4254
4374
  // Scope drift is NOT here: drawbridge-sync writes it onto the connection
4255
4375
  // document, and the document's own warnings render beside these.
4256
- tasks: (data2) => (data2 == null ? void 0 : data2.status) === "pending" ? [
4257
- {
4258
- message: "Open the Drawbridge app in your Shopify admin and choose a plan. The connection activates once Shopify confirms it.",
4259
- title: "Choose a plan in Shopify"
4260
- }
4261
- ] : [],
4376
+ // Both cards carry `action : 'billing'`: the dashboard renders it as a real
4377
+ // button that mints the corrective subscription (POST .../billing) and sends
4378
+ // the owner to Shopify's confirmation screen the fix lives in OUR
4379
+ // dashboard, and the one Shopify-hosted step is the charge consent no app
4380
+ // can perform for a merchant.
4381
+ tasks: (data2) => {
4382
+ var _a;
4383
+ return [
4384
+ ...(data2 == null ? void 0 : data2.status) === "pending" ? [
4385
+ {
4386
+ action: "billing",
4387
+ message: "Approve the Drawbridge plan to activate this connection. You'll confirm the pricing on Shopify, then everything else happens here.",
4388
+ title: "Approve your plan"
4389
+ }
4390
+ ] : [],
4391
+ // The store's ACTIVE approval carries no usage component — stamped by
4392
+ // the daily health check and the approval webhook (`source.metered`).
4393
+ // Approved pricing is never retroactive, so the fix is a fresh approval.
4394
+ // Gated on an EXPLICIT false — a connection the probe hasn't reached
4395
+ // (or couldn't read) shows nothing rather than nagging on silence.
4396
+ ...(data2 == null ? void 0 : data2.status) === "active" && ((_a = data2 == null ? void 0 : data2.source) == null ? void 0 : _a.metered) === false ? [
4397
+ {
4398
+ action: "billing",
4399
+ 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.",
4400
+ title: "Fix order billing"
4401
+ }
4402
+ ] : []
4403
+ ];
4404
+ },
4262
4405
  title: "Shopify"
4263
4406
  };
4264
4407
 
@@ -4432,6 +4575,9 @@ var webhook_default = {
4432
4575
  // Nothing to be exclusive with — there is no second webhook vendor, and a
4433
4576
  // merchant with two endpoints is a step-level choice rather than a second
4434
4577
  // connection.
4578
+ // No vendor-facing event identities — declared empty so the contract stays
4579
+ // explicit: a vendor that gains one adds it here, never as a side channel.
4580
+ events: {},
4435
4581
  exclusive: false,
4436
4582
  feature: "organization:connection:webhook",
4437
4583
  fields: [
@@ -4718,22 +4864,22 @@ var connections = Object.freeze({
4718
4864
  (() => {
4719
4865
  var _a;
4720
4866
  const routes = {};
4721
- for (const [slug, manifest] of Object.entries(connections)) {
4867
+ for (const [slug2, manifest] of Object.entries(connections)) {
4722
4868
  for (const [name, step] of leaves(manifest.steps)) {
4723
4869
  const type = "step." + name;
4724
4870
  const queue = (_a = step({})) == null ? void 0 : _a.queue;
4725
4871
  if (routes[type] && routes[type].queue !== queue) {
4726
4872
  throw new Error(
4727
- "Step " + type + " routes to " + routes[type].queue + " for " + routes[type].slug + " and " + queue + " for " + slug + " \u2014 one of them would be enqueued nowhere"
4873
+ "Step " + type + " routes to " + routes[type].queue + " for " + routes[type].slug + " and " + queue + " for " + slug2 + " \u2014 one of them would be enqueued nowhere"
4728
4874
  );
4729
4875
  }
4730
- routes[type] = { queue, slug };
4876
+ routes[type] = { queue, slug: slug2 };
4731
4877
  }
4732
4878
  }
4733
4879
  })();
4734
4880
  var publicSettingsBySlug = Object.fromEntries(
4735
- Object.entries(connections).map(([slug, manifest]) => [
4736
- slug,
4881
+ Object.entries(connections).map(([slug2, manifest]) => [
4882
+ slug2,
4737
4883
  manifest.fields.filter((field2) => !field2.redact).map((field2) => field2.key)
4738
4884
  ])
4739
4885
  );
@@ -4821,13 +4967,13 @@ var decrypt = (value) => {
4821
4967
  };
4822
4968
 
4823
4969
  // lib/providers.js
4824
- var providerFields = (slug) => {
4970
+ var providerFields = (slug2) => {
4825
4971
  var _a;
4826
- return Object.hasOwn(connections, slug) ? ((_a = connections[slug].provider) == null ? void 0 : _a.fields) || [] : [];
4972
+ return Object.hasOwn(connections, slug2) ? ((_a = connections[slug2].provider) == null ? void 0 : _a.fields) || [] : [];
4827
4973
  };
4828
- var providerSlugs = () => Object.keys(connections).filter((slug) => connections[slug].provider).sort();
4829
- var isLive = (slug, settings) => {
4830
- const fields2 = providerFields(slug);
4974
+ var providerSlugs = () => Object.keys(connections).filter((slug2) => connections[slug2].provider).sort();
4975
+ var isLive = (slug2, settings) => {
4976
+ const fields2 = providerFields(slug2);
4831
4977
  if (!fields2.length) return false;
4832
4978
  return fields2.filter((field2) => field2.required).every((field2) => Boolean(settings == null ? void 0 : settings[field2.key]));
4833
4979
  };
@@ -4839,23 +4985,23 @@ var mask = (value) => {
4839
4985
  var providerMemo = /* @__PURE__ */ new Map();
4840
4986
  var MEMO_TTL_MS = 60 * 1e3;
4841
4987
  var clearProviderMemo = () => providerMemo.clear();
4842
- var providerSettings = async ({ controller, slug }) => {
4843
- const memoized = providerMemo.get(slug);
4988
+ var providerSettings = async ({ controller, slug: slug2 }) => {
4989
+ const memoized = providerMemo.get(slug2);
4844
4990
  if (memoized && Date.now() - memoized.at < MEMO_TTL_MS) return memoized.value;
4845
4991
  const row = await controller.get({
4846
4992
  collection: "provider",
4847
- query: { slug }
4993
+ query: { slug: slug2 }
4848
4994
  });
4849
4995
  const value = (row == null ? void 0 : row.settings) ? decrypt(row.settings) : {};
4850
- providerMemo.set(slug, { at: Date.now(), value });
4996
+ providerMemo.set(slug2, { at: Date.now(), value });
4851
4997
  return value;
4852
4998
  };
4853
- var saveProviderSettings = async ({ authenticated, clear, controller, settings, slug }) => {
4854
- const fields2 = providerFields(slug);
4999
+ var saveProviderSettings = async ({ authenticated, clear, controller, settings, slug: slug2 }) => {
5000
+ const fields2 = providerFields(slug2);
4855
5001
  if (!fields2.length) return null;
4856
5002
  const existing = await controller.get({
4857
5003
  collection: "provider",
4858
- query: { slug }
5004
+ query: { slug: slug2 }
4859
5005
  });
4860
5006
  const stored = (existing == null ? void 0 : existing.settings) ? decrypt(existing.settings) : {};
4861
5007
  const pasted = (value) => typeof value === "string" ? value.trim().replace(/^['"]+|['"]+$/g, "") : value;
@@ -4877,20 +5023,20 @@ var saveProviderSettings = async ({ authenticated, clear, controller, settings,
4877
5023
  options: {
4878
5024
  upsert: true
4879
5025
  },
4880
- query: { slug }
5026
+ query: { slug: slug2 }
4881
5027
  });
4882
- providerMemo.delete(slug);
5028
+ providerMemo.delete(slug2);
4883
5029
  return result;
4884
5030
  };
4885
5031
  var providerEnvNames = () => new Set(
4886
- providerSlugs().flatMap((slug) => providerFields(slug)).map((field2) => field2.credential).filter(Boolean)
5032
+ providerSlugs().flatMap((slug2) => providerFields(slug2)).map((field2) => field2.credential).filter(Boolean)
4887
5033
  );
4888
5034
  var providerCredentials = async ({ controller }) => {
4889
5035
  const credentials2 = {};
4890
- for (const slug of providerSlugs()) {
5036
+ for (const slug2 of providerSlugs()) {
4891
5037
  try {
4892
- const settings = await providerSettings({ controller, slug });
4893
- for (const field2 of providerFields(slug)) {
5038
+ const settings = await providerSettings({ controller, slug: slug2 });
5039
+ for (const field2 of providerFields(slug2)) {
4894
5040
  const value = settings == null ? void 0 : settings[field2.key];
4895
5041
  if (field2.credential && value) credentials2[field2.credential] = value;
4896
5042
  }
@@ -18,6 +18,8 @@ import 'nanoid';
18
18
  import './color.cjs';
19
19
  import 'tinycolor2';
20
20
  import './email.cjs';
21
+ import './slugify.cjs';
22
+ import 'slugify';
21
23
  import './safe-http.cjs';
22
24
  import 'dns';
23
25
  import 'node:http';
@@ -18,6 +18,8 @@ import 'nanoid';
18
18
  import './color.js';
19
19
  import 'tinycolor2';
20
20
  import './email.js';
21
+ import './slugify.js';
22
+ import 'slugify';
21
23
  import './safe-http.js';
22
24
  import 'dns';
23
25
  import 'node:http';