@drawbridge/drawbridge-utils 0.0.142 → 0.0.144

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.
@@ -3272,6 +3272,45 @@ var toCanonicalEmail = (value) => {
3272
3272
  return local + "@" + domain;
3273
3273
  };
3274
3274
 
3275
+ // lib/encrypt.js
3276
+ var import_crypto2 = __toESM(require("crypto"), 1);
3277
+
3278
+ // lib/token.js
3279
+ var import_crypto = __toESM(require("crypto"), 1);
3280
+ var generate = (bytes = 32, encoding = "base64url") => {
3281
+ const buf = import_crypto.default.randomBytes(bytes);
3282
+ return encoding ? buf.toString(encoding) : buf;
3283
+ };
3284
+
3285
+ // lib/encrypt.js
3286
+ var ALGORITHM = "aes-256-gcm";
3287
+ var getKey = () => Buffer.from(process.env.ENCRYPT_CONNECTION_SECRET, "hex");
3288
+ var encrypt = (value) => {
3289
+ const iv = generate(12, null);
3290
+ const cipher = import_crypto2.default.createCipheriv(ALGORITHM, getKey(), iv);
3291
+ const data2 = Buffer.concat([
3292
+ cipher.update(JSON.stringify(value), "utf8"),
3293
+ cipher.final()
3294
+ ]);
3295
+ const tag = cipher.getAuthTag();
3296
+ return [iv, tag, data2].map((b) => b.toString("hex")).join(":");
3297
+ };
3298
+ var decrypt = (value) => {
3299
+ if (typeof value !== "string") return value;
3300
+ const [ivHex, tagHex, dataHex] = value.split(":");
3301
+ const decipher = import_crypto2.default.createDecipheriv(
3302
+ ALGORITHM,
3303
+ getKey(),
3304
+ Buffer.from(ivHex, "hex")
3305
+ );
3306
+ decipher.setAuthTag(Buffer.from(tagHex, "hex"));
3307
+ const result = Buffer.concat([
3308
+ decipher.update(Buffer.from(dataHex, "hex")),
3309
+ decipher.final()
3310
+ ]);
3311
+ return JSON.parse(result.toString("utf8"));
3312
+ };
3313
+
3275
3314
  // lib/slugify.js
3276
3315
  var import_slugify = __toESM(require("slugify"), 1);
3277
3316
  var slugify = (value, nochars = false) => {
@@ -3326,6 +3365,8 @@ var generateDiscountCode = (0, import_nanoid2.customAlphabet)("0123456789ABCDEFG
3326
3365
  var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
3327
3366
  var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
3328
3367
  var OAUTH_ERROR_SOURCE = "oauth";
3368
+ var BILLING_ERROR_SOURCE = "billing";
3369
+ var BILLING_ERROR_MESSAGE = "Order billing isn't set up for this store \u2014 reselect and approve the plan from the Shopify connection page.";
3329
3370
  var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
3330
3371
  var inbound = {
3331
3372
  headers: {
@@ -3846,6 +3887,9 @@ var shopify_default2 = {
3846
3887
  }
3847
3888
  }
3848
3889
  const billable = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && !backfill;
3890
+ const providerRow = billable ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
3891
+ const { orderEventHandle } = (providerRow == null ? void 0 : providerRow.settings) ? decrypt(providerRow.settings) : {};
3892
+ const handle = typeof orderEventHandle === "string" && slugify(orderEventHandle) || ORDER_EVENT_HANDLE;
3849
3893
  if (billable && !((_f = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _f.id)) {
3850
3894
  (_g = logger2 == null ? void 0 : logger2.error) == null ? void 0 : _g.call(logger2, new Error(
3851
3895
  "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"
@@ -3864,7 +3908,7 @@ var shopify_default2 = {
3864
3908
  // verify it against the transaction prefix and refuse a
3865
3909
  // drifted pair — idempotency keys are permanent, so a
3866
3910
  // mistraceable event can never be resent under its own key.
3867
- handle: ORDER_EVENT_HANDLE,
3911
+ handle,
3868
3912
  idempotencyKey: String(orderId),
3869
3913
  orderDocId,
3870
3914
  orderId: String(orderId),
@@ -3874,7 +3918,7 @@ var shopify_default2 = {
3874
3918
  // here — the event handle plus the order id — and sent as the
3875
3919
  // event's `reference`. queue/usage.js stamps the same id onto
3876
3920
  // the order as billed.transaction.
3877
- transaction: ORDER_EVENT_HANDLE + "." + orderId,
3921
+ transaction: handle + "." + orderId,
3878
3922
  value: Math.round(fee * 100)
3879
3923
  },
3880
3924
  name: "billing",
@@ -4018,7 +4062,7 @@ var shopify_default2 = {
4018
4062
  // against the ones the app now needs, and queues a webhook
4019
4063
  // reconciliation.
4020
4064
  health: async ({ connection: connection2, workflow }, { adminToken, logger: logger2, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {}) => {
4021
- var _a, _b;
4065
+ var _a, _b, _c;
4022
4066
  const request2 = {
4023
4067
  connectionId: workflow.connection,
4024
4068
  organizationId: workflow.organization,
@@ -4040,7 +4084,7 @@ var shopify_default2 = {
4040
4084
  const writes = [];
4041
4085
  let source = connection2.source || null;
4042
4086
  let sourceRepaired = false;
4043
- let metered = (source == null ? void 0 : source.metered) ?? null;
4087
+ let metered;
4044
4088
  let shopCurrency = null;
4045
4089
  let billingProbeFailed = false;
4046
4090
  try {
@@ -4059,26 +4103,66 @@ var shopify_default2 = {
4059
4103
  domain: connection2.shop
4060
4104
  });
4061
4105
  if (subscriptions.length && subscriptions.every((subscription) => typeof subscription.metered === "boolean")) {
4062
- metered = subscriptions.some((subscription) => subscription.metered);
4106
+ metered = ((_a = subscriptions.find((subscription) => subscription.usageLineItemId)) == null ? void 0 : _a.usageLineItemId) || null;
4063
4107
  }
4064
4108
  } catch (probeError) {
4065
4109
  billingProbeFailed = true;
4066
- (_a = logger2 == null ? void 0 : logger2.warn) == null ? void 0 : _a.call(logger2, "shopify.health.billing.probe.failed", {
4110
+ (_b = logger2 == null ? void 0 : logger2.warn) == null ? void 0 : _b.call(logger2, "shopify.health.billing.probe.failed", {
4067
4111
  connectionId: connection2.id,
4068
4112
  message: probeError == null ? void 0 : probeError.message,
4069
4113
  shop: connection2.shop
4070
4114
  });
4071
4115
  }
4072
- if (sourceRepaired || metered !== null && metered !== (((_b = connection2.source) == null ? void 0 : _b.metered) ?? null)) {
4116
+ if (sourceRepaired || metered !== void 0 && metered !== (((_c = connection2.source) == null ? void 0 : _c.metered) ?? void 0)) {
4073
4117
  writes.push({
4074
4118
  collection: "connection",
4075
4119
  data: {
4076
- $set: sourceRepaired ? { source: { ...source, ...metered !== null && { metered } } } : { "source.metered": metered }
4120
+ $set: sourceRepaired ? { source: { ...source, ...metered !== void 0 && { metered } } } : { "source.metered": metered }
4077
4121
  },
4078
4122
  operation: "update",
4079
4123
  query: { id: connection2.id }
4080
4124
  });
4081
4125
  }
4126
+ const hasBillingIssue = (connection2.errors || []).some((entry) => entry.source === BILLING_ERROR_SOURCE);
4127
+ if (metered === null && !hasBillingIssue) {
4128
+ writes.push({
4129
+ collection: "connection",
4130
+ data: {
4131
+ $push: {
4132
+ errors: {
4133
+ message: BILLING_ERROR_MESSAGE,
4134
+ source: BILLING_ERROR_SOURCE
4135
+ }
4136
+ }
4137
+ },
4138
+ operation: "update",
4139
+ query: { id: connection2.id }
4140
+ });
4141
+ }
4142
+ if (typeof metered === "string" && hasBillingIssue) {
4143
+ writes.push({
4144
+ collection: "connection",
4145
+ data: {
4146
+ $pull: {
4147
+ errors: { source: BILLING_ERROR_SOURCE }
4148
+ }
4149
+ },
4150
+ operation: "update",
4151
+ query: { id: connection2.id }
4152
+ });
4153
+ writes.push({
4154
+ collection: "connection",
4155
+ data: {
4156
+ $set: { status: "active" }
4157
+ },
4158
+ operation: "update",
4159
+ query: {
4160
+ id: connection2.id,
4161
+ status: "error",
4162
+ "errors.0": { $exists: false }
4163
+ }
4164
+ });
4165
+ }
4082
4166
  return {
4083
4167
  writes,
4084
4168
  enqueues: [{
@@ -4093,11 +4177,12 @@ var shopify_default2 = {
4093
4177
  options: { jobId: "connection.update.register." + workflow.connection + "." + (0, import_node_crypto5.randomUUID)() },
4094
4178
  queue: "connection"
4095
4179
  }],
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." : ""),
4180
+ 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 === null ? " No usage line on the store's approved plan \u2014 order billing needs the merchant to approve the updated plan." : ""),
4097
4181
  request: request2,
4098
4182
  response: {
4099
4183
  billingProbeFailed,
4100
- metered,
4184
+ metered: metered ?? null,
4185
+ meterProbed: metered !== void 0,
4101
4186
  pingedAt: /* @__PURE__ */ new Date(),
4102
4187
  refreshTokenExpiresAt: refreshTokenExpiresAt || null,
4103
4188
  refreshTokenRotated,
@@ -4242,7 +4327,15 @@ var shopify_default2 = {
4242
4327
  { input: "text", key: "apiKey", credential: "SHOPIFY_API_KEY", label: "API key", required: true },
4243
4328
  { input: "password", key: "apiSecret", credential: "SHOPIFY_API_SECRET", label: "API secret", redact: true, required: true },
4244
4329
  { input: "text", key: "appHandle", credential: "SHOPIFY_APP_HANDLE", label: "App handle", required: true },
4245
- { input: "text", key: "listingUrl", credential: "SHOPIFY_APP_LISTING_URL", label: "App listing URL", required: true }
4330
+ { input: "text", key: "listingUrl", credential: "SHOPIFY_APP_LISTING_URL", label: "App listing URL", required: true },
4331
+ // The usage meter's event handle, when the plan config's meter ever
4332
+ // changes: stored value overrides the built-in default
4333
+ // (events.order.handle) at the mint point, and `format : 'slug'`
4334
+ // normalizes it on save — a handle can carry no spaces or capitals,
4335
+ // and classification against it is case-sensitive. Optional: empty
4336
+ // means the default, and the sender still refuses any handle that
4337
+ // disagrees with the transaction it minted.
4338
+ { format: "slug", input: "text", key: "orderEventHandle", label: "Order event handle", required: false, setting: true }
4246
4339
  ]
4247
4340
  },
4248
4341
  // A pre-launch integration: it only surfaces once the App Store listing
@@ -4384,20 +4477,24 @@ var shopify_default2 = {
4384
4477
  ...(data2 == null ? void 0 : data2.status) === "pending" ? [
4385
4478
  {
4386
4479
  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"
4480
+ message: "Approve the Drawbridge plan to activate this connection. You'll be asked to select a plan on Shopify, then everything else happens here.",
4481
+ title: "Approve your plan",
4482
+ type: "warning"
4389
4483
  }
4390
4484
  ] : [],
4391
4485
  // 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 ? [
4486
+ // the daily health check and the approval webhook (`source.metered`:
4487
+ // the usage line's id when present, null when probed-and-absent).
4488
+ // Approved pricing is never retroactive, so the fix is a fresh
4489
+ // approval. Gated on an EXPLICIT null a connection the probe hasn't
4490
+ // reached (or couldn't read) has no key and shows nothing rather than
4491
+ // nagging on silence.
4492
+ ...(data2 == null ? void 0 : data2.status) === "active" && ((_a = data2 == null ? void 0 : data2.source) == null ? void 0 : _a.metered) === null ? [
4397
4493
  {
4398
4494
  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"
4495
+ message: "Order billing isn't set up for this store, so campaign-attributed orders aren't being charged. You'll need to reselect your plan on Shopify and approve it to fix this.",
4496
+ title: "Fix order billing",
4497
+ type: "error"
4401
4498
  }
4402
4499
  ] : []
4403
4500
  ];
@@ -4927,45 +5024,6 @@ var publicConnectionKeys = Object.freeze([
4927
5024
  "warnings"
4928
5025
  ]);
4929
5026
 
4930
- // lib/encrypt.js
4931
- var import_crypto2 = __toESM(require("crypto"), 1);
4932
-
4933
- // lib/token.js
4934
- var import_crypto = __toESM(require("crypto"), 1);
4935
- var generate = (bytes = 32, encoding = "base64url") => {
4936
- const buf = import_crypto.default.randomBytes(bytes);
4937
- return encoding ? buf.toString(encoding) : buf;
4938
- };
4939
-
4940
- // lib/encrypt.js
4941
- var ALGORITHM = "aes-256-gcm";
4942
- var getKey = () => Buffer.from(process.env.ENCRYPT_CONNECTION_SECRET, "hex");
4943
- var encrypt = (value) => {
4944
- const iv = generate(12, null);
4945
- const cipher = import_crypto2.default.createCipheriv(ALGORITHM, getKey(), iv);
4946
- const data2 = Buffer.concat([
4947
- cipher.update(JSON.stringify(value), "utf8"),
4948
- cipher.final()
4949
- ]);
4950
- const tag = cipher.getAuthTag();
4951
- return [iv, tag, data2].map((b) => b.toString("hex")).join(":");
4952
- };
4953
- var decrypt = (value) => {
4954
- if (typeof value !== "string") return value;
4955
- const [ivHex, tagHex, dataHex] = value.split(":");
4956
- const decipher = import_crypto2.default.createDecipheriv(
4957
- ALGORITHM,
4958
- getKey(),
4959
- Buffer.from(ivHex, "hex")
4960
- );
4961
- decipher.setAuthTag(Buffer.from(tagHex, "hex"));
4962
- const result = Buffer.concat([
4963
- decipher.update(Buffer.from(dataHex, "hex")),
4964
- decipher.final()
4965
- ]);
4966
- return JSON.parse(result.toString("utf8"));
4967
- };
4968
-
4969
5027
  // lib/providers.js
4970
5028
  var providerFields = (slug2) => {
4971
5029
  var _a;
@@ -5005,9 +5063,10 @@ var saveProviderSettings = async ({ authenticated, clear, controller, settings,
5005
5063
  });
5006
5064
  const stored = (existing == null ? void 0 : existing.settings) ? decrypt(existing.settings) : {};
5007
5065
  const pasted = (value) => typeof value === "string" ? value.trim().replace(/^['"]+|['"]+$/g, "") : value;
5066
+ const normalized = (field2, value) => field2.format === "slug" && typeof value === "string" && value ? slugify(value) : value;
5008
5067
  const merged = mergeSettings({
5009
5068
  existing: stored,
5010
- incoming: Object.fromEntries(fields2.map((field2) => [field2.key, pasted(settings == null ? void 0 : settings[field2.key])]))
5069
+ incoming: Object.fromEntries(fields2.map((field2) => [field2.key, normalized(field2, pasted(settings == null ? void 0 : settings[field2.key]))]))
5011
5070
  });
5012
5071
  for (const key of Array.isArray(clear) ? clear : []) {
5013
5072
  if (fields2.some((field2) => field2.key === key)) delete merged[key];
@@ -1,5 +1,6 @@
1
1
  import { connections, mergeSettings } from './connections/index.cjs';
2
2
  import { decrypt, encrypt } from './encrypt.cjs';
3
+ import { slugify } from './slugify.cjs';
3
4
  import './connections/oauth.cjs';
4
5
  import 'node:crypto';
5
6
  import './phone.cjs';
@@ -18,8 +19,6 @@ import 'nanoid';
18
19
  import './color.cjs';
19
20
  import 'tinycolor2';
20
21
  import './email.cjs';
21
- import './slugify.cjs';
22
- import 'slugify';
23
22
  import './safe-http.cjs';
24
23
  import 'dns';
25
24
  import 'node:http';
@@ -29,6 +28,7 @@ import 'axios';
29
28
  import 'net';
30
29
  import 'crypto';
31
30
  import './token.cjs';
31
+ import 'slugify';
32
32
 
33
33
  // DRAWBRIDGE'S OWN CREDENTIALS for a vendor, as opposed to a merchant's.
34
34
  //
@@ -202,6 +202,17 @@ const saveProviderSettings = async ({ authenticated, clear, controller, settings
202
202
  ? value.trim().replace( /^['"]+|['"]+$/g, '' )
203
203
  : value;
204
204
 
205
+ // A field declared `format : 'slug'` is normalized on the way IN — a value
206
+ // like Shopify's meter event handle can legally hold no spaces or capitals,
207
+ // and classification against it is case-sensitive, so the stored copy is
208
+ // forced into the only shape the vendor accepts rather than trusting the
209
+ // admin's typing.
210
+ const normalized = ( field, value ) => (
211
+ field.format === 'slug' && typeof value === 'string' && value
212
+ ? slugify( value )
213
+ : value
214
+ );
215
+
205
216
  // mergeSettings is the blank-keeps rule, already used for merchant
206
217
  // connection settings in route/organization-connection.js — same behaviour,
207
218
  // one implementation. The declared-field allowlist is the part that is ours:
@@ -209,7 +220,7 @@ const saveProviderSettings = async ({ authenticated, clear, controller, settings
209
220
  // not declare reaching the document.
210
221
  const merged = mergeSettings({
211
222
  existing : stored,
212
- incoming : Object.fromEntries( fields.map( ( field ) => [ field.key, pasted( settings?.[ field.key ] ) ] ) )
223
+ incoming : Object.fromEntries( fields.map( ( field ) => [ field.key, normalized( field, pasted( settings?.[ field.key ] ) ) ] ) )
213
224
  });
214
225
 
215
226
  // CLEARING IS EXPLICIT, because blank already means keep and the two cannot
@@ -1,5 +1,6 @@
1
1
  import { connections, mergeSettings } from './connections/index.js';
2
2
  import { decrypt, encrypt } from './encrypt.js';
3
+ import { slugify } from './slugify.js';
3
4
  import './connections/oauth.js';
4
5
  import 'node:crypto';
5
6
  import './phone.js';
@@ -18,8 +19,6 @@ import 'nanoid';
18
19
  import './color.js';
19
20
  import 'tinycolor2';
20
21
  import './email.js';
21
- import './slugify.js';
22
- import 'slugify';
23
22
  import './safe-http.js';
24
23
  import 'dns';
25
24
  import 'node:http';
@@ -29,6 +28,7 @@ import 'axios';
29
28
  import 'net';
30
29
  import 'crypto';
31
30
  import './token.js';
31
+ import 'slugify';
32
32
 
33
33
  // DRAWBRIDGE'S OWN CREDENTIALS for a vendor, as opposed to a merchant's.
34
34
  //
@@ -202,6 +202,17 @@ const saveProviderSettings = async ({ authenticated, clear, controller, settings
202
202
  ? value.trim().replace( /^['"]+|['"]+$/g, '' )
203
203
  : value;
204
204
 
205
+ // A field declared `format : 'slug'` is normalized on the way IN — a value
206
+ // like Shopify's meter event handle can legally hold no spaces or capitals,
207
+ // and classification against it is case-sensitive, so the stored copy is
208
+ // forced into the only shape the vendor accepts rather than trusting the
209
+ // admin's typing.
210
+ const normalized = ( field, value ) => (
211
+ field.format === 'slug' && typeof value === 'string' && value
212
+ ? slugify( value )
213
+ : value
214
+ );
215
+
205
216
  // mergeSettings is the blank-keeps rule, already used for merchant
206
217
  // connection settings in route/organization-connection.js — same behaviour,
207
218
  // one implementation. The declared-field allowlist is the part that is ours:
@@ -209,7 +220,7 @@ const saveProviderSettings = async ({ authenticated, clear, controller, settings
209
220
  // not declare reaching the document.
210
221
  const merged = mergeSettings({
211
222
  existing : stored,
212
- incoming : Object.fromEntries( fields.map( ( field ) => [ field.key, pasted( settings?.[ field.key ] ) ] ) )
223
+ incoming : Object.fromEntries( fields.map( ( field ) => [ field.key, normalized( field, pasted( settings?.[ field.key ] ) ) ] ) )
213
224
  });
214
225
 
215
226
  // CLEARING IS EXPLICIT, because blank already means keep and the two cannot