@drawbridge/drawbridge-utils 0.0.151 → 0.0.153

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.
@@ -38,7 +38,9 @@ __export(providers_exports, {
38
38
  providerMemo: () => providerMemo,
39
39
  providerSettings: () => providerSettings,
40
40
  providerSlugs: () => providerSlugs,
41
- saveProviderSettings: () => saveProviderSettings
41
+ saveProviderSettings: () => saveProviderSettings,
42
+ vendorSettings: () => vendorSettings,
43
+ vendors: () => vendors
42
44
  });
43
45
  module.exports = __toCommonJS(providers_exports);
44
46
 
@@ -765,10 +767,16 @@ var attentive_default2 = {
765
767
  // `redact` marks a secret — never returned by the api, and blank on save means
766
768
  // keep the stored value. `required` drives the live check.
767
769
  provider: {
768
- fields: [
769
- { input: "text", key: "clientId", credential: "ATTENTIVE_OAUTH_CLIENT_ID", label: "Client ID", message: "From our app's registration in the Attentive developer portal.", required: true },
770
- { input: "password", key: "clientSecret", credential: "ATTENTIVE_OAUTH_CLIENT_SECRET", label: "Client secret", message: "Beside the Client ID in the Attentive developer portal.", redact: true, required: true }
771
- ]
770
+ vendors: {
771
+ attentive: {
772
+ fields: [
773
+ { input: "text", key: "clientId", credential: "ATTENTIVE_OAUTH_CLIENT_ID", label: "Client ID", message: "From our app's registration in the Attentive developer portal.", required: true },
774
+ { input: "password", key: "clientSecret", credential: "ATTENTIVE_OAUTH_CLIENT_SECRET", label: "Client secret", message: "Beside the Client ID in the Attentive developer portal.", redact: true, required: true }
775
+ ],
776
+ icon: attentive_default,
777
+ name: "Attentive"
778
+ }
779
+ }
772
780
  },
773
781
  requires: [
774
782
  "ATTENTIVE_OAUTH_CLIENT_ID",
@@ -1034,6 +1042,43 @@ var drawbridge_default = `<svg width="500" height="500" viewBox="0 0 500 500" fi
1034
1042
  </defs>
1035
1043
  </svg>`;
1036
1044
 
1045
+ // lib/connections/icons/hubspot.js
1046
+ var hubspot_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
1047
+ <rect width="500" height="500" fill="#FF7A59"/>
1048
+ <circle cx="290" cy="290" r="78" stroke="white" stroke-width="30"/>
1049
+ <circle cx="290" cy="290" r="18" fill="white"/>
1050
+ <path d="M290 212V150" stroke="white" stroke-width="30" stroke-linecap="round"/>
1051
+ <circle cx="290" cy="125" r="28" fill="white"/>
1052
+ <path d="M270 137L172 96" stroke="white" stroke-width="22" stroke-linecap="round"/>
1053
+ <circle cx="160" cy="91" r="20" fill="white"/>
1054
+ <path d="M233 347L184 396" stroke="white" stroke-width="26" stroke-linecap="round"/>
1055
+ <circle cx="172" cy="408" r="22" fill="white"/>
1056
+ </svg>`;
1057
+
1058
+ // lib/connections/icons/sendgrid.js
1059
+ var sendgrid_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
1060
+ <rect width="500" height="500" fill="#F1FAFF"/>
1061
+ <g clip-path="url(#clip0_1011_2743)">
1062
+ <path d="M366.676 134.534V289.172H289.203V366.487H134.258L134.258 289.171L134.256 289.172V211.851H211.729V134.534H366.676Z" fill="#9DD6E3"/>
1063
+ <path d="M134.258 366.488H211.731V289.169H134.258V366.488Z" fill="#3F72AB"/>
1064
+ <path d="M289.203 289.172H366.677V211.852H289.203V289.172Z" fill="#00A9D1"/>
1065
+ <path d="M211.729 211.853H289.202V134.534H211.729V211.853Z" fill="#00A9D1"/>
1066
+ <path d="M211.729 289.17H289.202V211.852H211.729V289.17Z" fill="#2191C4"/>
1067
+ <path d="M289.203 211.853H366.677V134.534H289.203V211.853Z" fill="#3F72AB"/>
1068
+ </g>
1069
+ <defs>
1070
+ <clipPath id="clip0_1011_2743">
1071
+ <rect width="232.42" height="231.956" fill="white" transform="translate(134.256 134.534)"/>
1072
+ </clipPath>
1073
+ </defs>
1074
+ </svg>`;
1075
+
1076
+ // lib/connections/icons/twilio.js
1077
+ var twilio_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
1078
+ <rect width="500" height="500" fill="#F22F46"/>
1079
+ <path d="M250 134.954C186.341 134.954 134.954 186.341 134.954 250C134.954 313.659 186.341 365.047 250 365.047C313.659 365.047 365.047 313.659 365.047 250C365.047 186.341 313.659 134.954 250 134.954ZM250 334.368C203.215 334.368 165.633 296.786 165.633 250C165.633 203.215 203.215 165.633 250 165.633C296.786 165.633 334.368 203.215 334.368 250C334.368 296.786 296.786 334.368 250 334.368ZM302.155 221.622C302.155 234.661 291.417 245.399 278.378 245.399C265.34 245.399 254.602 234.661 254.602 221.622C254.602 208.584 265.34 197.846 278.378 197.846C291.417 197.846 302.155 208.584 302.155 221.622ZM302.155 278.378C302.155 291.417 291.417 302.155 278.378 302.155C265.34 302.155 254.602 291.417 254.602 278.378C254.602 265.34 265.34 254.602 278.378 254.602C291.417 254.602 302.155 265.34 302.155 278.378ZM245.399 278.378C245.399 291.417 234.661 302.155 221.622 302.155C208.584 302.155 197.846 291.417 197.846 278.378C197.846 265.34 208.584 254.602 221.622 254.602C234.661 254.602 245.399 265.34 245.399 278.378ZM245.399 221.622C245.399 234.661 234.661 245.399 221.622 245.399C208.584 245.399 197.846 234.661 197.846 221.622C197.846 208.584 208.584 197.846 221.622 197.846C234.661 197.846 245.399 208.584 245.399 221.622Z" fill="white"/>
1080
+ </svg>`;
1081
+
1037
1082
  // lib/transactions.js
1038
1083
  var import_drawbridge_telemetry = require("@drawbridge/drawbridge-telemetry");
1039
1084
 
@@ -2200,37 +2245,56 @@ var drawbridge_default2 = {
2200
2245
  //
2201
2246
  // They belong on THIS manifest because this is the connection that sends: the
2202
2247
  // email, sms and segment hooks below are the only things that spend them, and
2203
- // a private connection is still where a vendor fact lives.
2248
+ // a private connection is still where a vendor fact lives. They are THREE
2249
+ // VENDORS, not one, and each is its own row and its own admin card — so
2250
+ // SendGrid reads live on a deployment that never configured Twilio, instead
2251
+ // of one card for all three that could only ever be as live as its weakest.
2204
2252
  //
2205
2253
  // UNLIKE every public vendor, none of these appear in `requires` — see the
2206
2254
  // comment there. Availability and configuration are different questions, and a
2207
2255
  // missing CRM token must not take every base workflow step away.
2256
+ //
2257
+ // ponytail: the field keys keep the names they had on the shared row
2258
+ // (smsSid, hubspotToken) so the readers destructuring vendorSettings() did
2259
+ // not move. Rename them to sid/token when those readers are next touched.
2208
2260
  provider: {
2209
- fields: [
2210
- { input: "email", key: "accountSender", credential: "SENDGRID_FROM_ADDRESS", label: "Account sender", message: "Verification codes and security alerts send from here.", required: true },
2211
- { input: "password", key: "apiKey", credential: "SENDGRID_API_KEY", label: "SendGrid API key", message: "SendGrid dashboard, Settings, API Keys \u2014 full access for sends.", redact: true, required: true },
2212
- // NOT required. The CRM sync is best-effort internal tooling and no-ops
2213
- // without a token requiring it would make the whole drawbridge provider
2214
- // read not-live over something no merchant ever sees.
2215
- { input: "password", key: "hubspotToken", credential: "HUBSPOT_ACCESS_TOKEN", label: "HubSpot access token", message: "Drawbridge's own CRM portal. Internal \u2014 no merchant sees this.", redact: true },
2216
- // Optional: SENDGRID_SEND_FROM_ADDRESS is not boot-required in sync
2217
- // either. Unset, it degrades to the account sender rather than
2218
- // refusing to start.
2219
- { input: "email", key: "leadSender", credential: "SENDGRID_SEND_FROM_ADDRESS", label: "Lead sender", message: "The default for lead-facing mail when a merchant has not verified their own domain." },
2220
- // NOT required, the three of them together. SendGrid alone is enough to
2221
- // run the platform — sign-in codes, notifications and every lead-facing
2222
- // mail are email. Requiring Twilio here made the whole drawbridge
2223
- // provider read not-live on a deployment that never sends SMS, over a
2224
- // channel it was not using.
2225
- //
2261
+ vendors: {
2262
+ sendgrid: {
2263
+ fields: [
2264
+ { input: "email", key: "accountSender", credential: "SENDGRID_FROM_ADDRESS", label: "Account sender", message: "Verification codes and security alerts send from here.", required: true },
2265
+ { input: "password", key: "apiKey", credential: "SENDGRID_API_KEY", label: "SendGrid API key", message: "SendGrid dashboard, Settings, API Keys \u2014 full access for sends.", redact: true, required: true },
2266
+ // Optional: SENDGRID_SEND_FROM_ADDRESS is not boot-required in sync
2267
+ // either. Unset, it degrades to the account sender rather than
2268
+ // refusing to start.
2269
+ { input: "email", key: "leadSender", credential: "SENDGRID_SEND_FROM_ADDRESS", label: "Lead sender", message: "The default for lead-facing mail when a merchant has not verified their own domain." }
2270
+ ],
2271
+ icon: sendgrid_default,
2272
+ name: "SendGrid"
2273
+ },
2226
2274
  // Missing, SMS is LOCKED DOWN rather than broken: every send path
2227
2275
  // refuses cleanly (queue/notification.js cancels, the otc stream skips
2228
2276
  // and logs), the number-purchase routes deny, and identityDefaults.sms
2229
- // hides the org's SMS settings entirely.
2230
- { input: "text", key: "smsFrom", credential: "TWILIO_ACCOUNT_FROM", label: "SMS number", message: "Without Twilio credentials, SMS is disabled across the platform." },
2231
- { input: "password", key: "smsSid", credential: "TWILIO_ACCOUNT_SID", label: "Twilio account SID", message: "Twilio console home \u2014 the AC\u2026 identifier on the account dashboard.", redact: true },
2232
- { input: "password", key: "smsToken", credential: "TWILIO_AUTH_TOKEN", label: "Twilio auth token", message: "Beside the account SID on the Twilio console dashboard.", redact: true }
2233
- ]
2277
+ // hides the org's SMS settings entirely. The card reads not live, which
2278
+ // is the truth about Twilio and says nothing about email.
2279
+ twilio: {
2280
+ fields: [
2281
+ { input: "text", key: "smsFrom", credential: "TWILIO_ACCOUNT_FROM", label: "SMS number", message: "Without Twilio credentials, SMS is disabled across the platform.", required: true },
2282
+ { input: "password", key: "smsSid", credential: "TWILIO_ACCOUNT_SID", label: "Twilio account SID", message: "Twilio console home \u2014 the AC\u2026 identifier on the account dashboard.", redact: true, required: true },
2283
+ { input: "password", key: "smsToken", credential: "TWILIO_AUTH_TOKEN", label: "Twilio auth token", message: "Beside the account SID on the Twilio console dashboard.", redact: true, required: true }
2284
+ ],
2285
+ icon: twilio_default,
2286
+ name: "Twilio"
2287
+ },
2288
+ // The CRM sync is best-effort internal tooling and no-ops without a
2289
+ // token. Not live here costs nothing a merchant can see.
2290
+ hubspot: {
2291
+ fields: [
2292
+ { input: "password", key: "hubspotToken", credential: "HUBSPOT_ACCESS_TOKEN", label: "HubSpot access token", message: "Drawbridge's own CRM portal. Internal \u2014 no merchant sees this.", redact: true, required: true }
2293
+ ],
2294
+ icon: hubspot_default,
2295
+ name: "HubSpot"
2296
+ }
2297
+ }
2234
2298
  },
2235
2299
  // NOTHING, and HUBSPOT_ACCESS_TOKEN in particular must not be here.
2236
2300
  //
@@ -2816,12 +2880,20 @@ var klaviyo_default2 = {
2816
2880
  // DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
2817
2881
  // what an admin types on the provider screen. Declared here rather than in a
2818
2882
  // table in lib/providers.js, so a vendor's credentials sit beside the
2819
- // `requires` that names the same variables.
2883
+ // `requires` that names the same variables. Keyed by the VENDOR that holds
2884
+ // them — here the vendor and the connection are the same thing, which is not
2885
+ // true of every manifest.
2820
2886
  provider: {
2821
- fields: [
2822
- { input: "text", key: "clientId", credential: "KLAVIYO_OAUTH_CLIENT_ID", label: "Client ID", message: "From our app in the Klaviyo developer portal (developers.klaviyo.com).", required: true },
2823
- { input: "password", key: "clientSecret", credential: "KLAVIYO_OAUTH_CLIENT_SECRET", label: "Client secret", message: "Beside the Client ID on the Klaviyo app.", redact: true, required: true }
2824
- ]
2887
+ vendors: {
2888
+ klaviyo: {
2889
+ fields: [
2890
+ { input: "text", key: "clientId", credential: "KLAVIYO_OAUTH_CLIENT_ID", label: "Client ID", message: "From our app in the Klaviyo developer portal (developers.klaviyo.com).", required: true },
2891
+ { input: "password", key: "clientSecret", credential: "KLAVIYO_OAUTH_CLIENT_SECRET", label: "Client secret", message: "Beside the Client ID on the Klaviyo app.", redact: true, required: true }
2892
+ ],
2893
+ icon: klaviyo_default,
2894
+ name: "Klaviyo"
2895
+ }
2896
+ }
2825
2897
  },
2826
2898
  requires: [
2827
2899
  "KLAVIYO_OAUTH_CLIENT_ID",
@@ -3164,10 +3236,16 @@ var mailchimp_default2 = {
3164
3236
  // what an admin types on the provider screen, beside the `requires` naming the
3165
3237
  // same variables.
3166
3238
  provider: {
3167
- fields: [
3168
- { input: "text", key: "clientId", credential: "MAILCHIMP_OAUTH_CLIENT_ID", label: "Client ID", message: "From our registered app \u2014 Mailchimp account, Extras, Registered apps.", required: true },
3169
- { input: "password", key: "clientSecret", credential: "MAILCHIMP_OAUTH_CLIENT_SECRET", label: "Client secret", message: "Beside the Client ID on the registered app.", redact: true, required: true }
3170
- ]
3239
+ vendors: {
3240
+ mailchimp: {
3241
+ fields: [
3242
+ { input: "text", key: "clientId", credential: "MAILCHIMP_OAUTH_CLIENT_ID", label: "Client ID", message: "From our registered app \u2014 Mailchimp account, Extras, Registered apps.", required: true },
3243
+ { input: "password", key: "clientSecret", credential: "MAILCHIMP_OAUTH_CLIENT_SECRET", label: "Client secret", message: "Beside the Client ID on the registered app.", redact: true, required: true }
3244
+ ],
3245
+ icon: mailchimp_default,
3246
+ name: "Mailchimp"
3247
+ }
3248
+ }
3171
3249
  },
3172
3250
  // The OAuth client this deployment registered. Without both, the vendor drops
3173
3251
  // out of availableConnections rather than offering a Connect button that
@@ -4072,7 +4150,7 @@ var shopify_default2 = {
4072
4150
  // against the ones the app now needs, and queues a webhook
4073
4151
  // reconciliation.
4074
4152
  health: async ({ connection: connection2, workflow }, { adminToken, logger: logger2, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {}) => {
4075
- var _a, _b, _c, _d;
4153
+ var _a, _b, _c, _d, _e, _f;
4076
4154
  const request2 = {
4077
4155
  connectionId: workflow.connection,
4078
4156
  organizationId: workflow.organization,
@@ -4097,6 +4175,7 @@ var shopify_default2 = {
4097
4175
  let metered;
4098
4176
  let shopCurrency = null;
4099
4177
  let billingProbeFailed = false;
4178
+ let reconciliation = null;
4100
4179
  try {
4101
4180
  if (!(source == null ? void 0 : source.id)) {
4102
4181
  const shopData = await shopify.oauth.getShop({ adminAccessToken, shop: connection2.shop });
@@ -4117,45 +4196,99 @@ var shopify_default2 = {
4117
4196
  const partnerRow = (read == null ? void 0 : read.get) ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
4118
4197
  const partner = (partnerRow == null ? void 0 : partnerRow.settings) ? decrypt(partnerRow.settings) : {};
4119
4198
  if (partner.partnerToken && partner.partnerOrgId && partner.partnerAppId && (source == null ? void 0 : source.id) && ((_b = shopify.partner) == null ? void 0 : _b.getUsageChargeEvents)) {
4199
+ const ledgerFloor = new Date(Date.now() - 30 * 24 * 60 * 60 * 1e3);
4120
4200
  const accruals = await shopify.partner.getUsageChargeEvents({
4121
4201
  appId: "gid://shopify/App/" + partner.partnerAppId,
4122
- first: 5,
4123
- occurredAtMin: new Date(Date.now() - 30 * 24 * 60 * 60 * 1e3).toISOString(),
4202
+ first: 10,
4203
+ occurredAtMin: ledgerFloor.toISOString(),
4124
4204
  organizationId: partner.partnerOrgId,
4125
4205
  partnerToken: partner.partnerToken,
4126
4206
  shopId: "gid://shopify/Shop/" + source.id
4127
4207
  });
4128
- const accrued = accruals.find((event) => Number(event.usageQuantity) > 0);
4129
- if (accrued) {
4130
- metered = String(accrued.chargeId || accrued.id);
4131
- writes.push({
4208
+ const boundaries = accruals.filter((event) => event.occurredAt).sort((a, b) => new Date(a.occurredAt) - new Date(b.occurredAt));
4209
+ const accrued = [...boundaries].reverse().find((event) => Number(event.usageQuantity) > 0);
4210
+ if (accrued) metered = String(accrued.chargeId || accrued.id);
4211
+ const stampedAt = (_c = boundaries.find(
4212
+ (event) => {
4213
+ var _a2;
4214
+ return String(event.chargeId || event.id) === String(((_a2 = connection2.source) == null ? void 0 : _a2.metered) || "");
4215
+ }
4216
+ )) == null ? void 0 : _c.occurredAt;
4217
+ reconciliation = [];
4218
+ let windowStart = ledgerFloor;
4219
+ for (const boundary of boundaries) {
4220
+ const windowEnd = new Date(boundary.occurredAt);
4221
+ const windowOpensAt = windowStart;
4222
+ windowStart = windowEnd;
4223
+ if (stampedAt && windowEnd <= new Date(stampedAt)) continue;
4224
+ const charged = Math.round(Number(boundary.usageQuantity) || 0);
4225
+ const [tally] = await read.aggregate({
4132
4226
  collection: "order",
4133
- data: {
4134
- $set: { "billed.receipt": metered }
4135
- },
4136
- // Top-level, not an option — the controller takes
4137
- // `multiple` beside `options`, and the shell
4138
- // forwards it as such.
4139
- multiple: true,
4140
- operation: "update",
4141
- query: {
4142
- "billed.date": { $gte: new Date(accrued.occurredAt || 0) },
4143
- "billed.receipt": { $in: [null] },
4144
- organization: workflow.organization,
4145
- "provider.slug": "shopify"
4146
- }
4147
- });
4227
+ pipeline: [
4228
+ { $match: {
4229
+ "billed.date": { $gt: windowOpensAt, $lte: windowEnd },
4230
+ "billed.void": { $in: [null] },
4231
+ organization: workflow.organization,
4232
+ "provider.slug": "shopify"
4233
+ } },
4234
+ { $group: {
4235
+ _id: null,
4236
+ billed: { $sum: "$billed.amount" },
4237
+ orders: { $push: "$id" }
4238
+ } }
4239
+ ]
4240
+ }) || [];
4241
+ const billed = (tally == null ? void 0 : tally.billed) || 0;
4242
+ const orders = (tally == null ? void 0 : tally.orders) || [];
4243
+ const charge = String(boundary.chargeId || boundary.id);
4244
+ const balanced = charged === billed;
4245
+ if (!(charged || billed)) continue;
4246
+ if (balanced) {
4247
+ writes.push({
4248
+ collection: "order",
4249
+ data: {
4250
+ $set: { "billed.receipt": charge }
4251
+ },
4252
+ // Top-level, not an option — the controller takes
4253
+ // `multiple` beside `options`, and the shell
4254
+ // forwards it as such.
4255
+ multiple: true,
4256
+ operation: "update",
4257
+ query: {
4258
+ "billed.date": { $gt: windowOpensAt, $lte: windowEnd },
4259
+ "billed.receipt": { $in: [null] },
4260
+ "billed.void": { $in: [null] },
4261
+ organization: workflow.organization,
4262
+ "provider.slug": "shopify"
4263
+ }
4264
+ });
4265
+ } else {
4266
+ (_d = logger2 == null ? void 0 : logger2.error) == null ? void 0 : _d.call(logger2, new Error(
4267
+ "shopify.usage.reconciliation.mismatch on shop " + connection2.shop + ": charge " + charge + " charged " + charged + " cents but its window holds " + billed + " cents across " + orders.length + " billed orders \u2014 each cent of difference is commission that never charged"
4268
+ ), {
4269
+ extra: {
4270
+ billed,
4271
+ charge,
4272
+ charged,
4273
+ orders: orders.slice(0, 50),
4274
+ shop: connection2.shop,
4275
+ windowEnd,
4276
+ windowStart: windowOpensAt
4277
+ }
4278
+ });
4279
+ }
4280
+ reconciliation.push({ balanced, billed, charge, charged, occurredAt: boundary.occurredAt, orders: orders.length });
4148
4281
  }
4149
4282
  }
4150
4283
  } catch (probeError) {
4151
4284
  billingProbeFailed = true;
4152
- (_c = logger2 == null ? void 0 : logger2.warn) == null ? void 0 : _c.call(logger2, "shopify.health.billing.probe.failed", {
4285
+ (_e = logger2 == null ? void 0 : logger2.warn) == null ? void 0 : _e.call(logger2, "shopify.health.billing.probe.failed", {
4153
4286
  connectionId: connection2.id,
4154
4287
  message: probeError == null ? void 0 : probeError.message,
4155
4288
  shop: connection2.shop
4156
4289
  });
4157
4290
  }
4158
- if (sourceRepaired || metered !== void 0 && metered !== (((_d = connection2.source) == null ? void 0 : _d.metered) ?? void 0)) {
4291
+ if (sourceRepaired || metered !== void 0 && metered !== (((_f = connection2.source) == null ? void 0 : _f.metered) ?? void 0)) {
4159
4292
  writes.push({
4160
4293
  collection: "connection",
4161
4294
  data: {
@@ -4226,6 +4359,9 @@ var shopify_default2 = {
4226
4359
  metered: metered ?? null,
4227
4360
  meterProbed: metered !== void 0,
4228
4361
  pingedAt: /* @__PURE__ */ new Date(),
4362
+ // One entry per boundary charge judged this run: what
4363
+ // Shopify charged vs what the window's orders billed.
4364
+ ...reconciliation && { reconciliation },
4229
4365
  refreshTokenExpiresAt: refreshTokenExpiresAt || null,
4230
4366
  refreshTokenRotated,
4231
4367
  scopesMissing,
@@ -4365,28 +4501,34 @@ var shopify_default2 = {
4365
4501
  // name required by the manifest and enterable nowhere is a vendor that can
4366
4502
  // never go live from the admin screen.
4367
4503
  provider: {
4368
- fields: [
4369
- { input: "text", key: "apiKey", credential: "SHOPIFY_API_KEY", label: "API key", message: "The app's Client ID \u2014 Dev Dashboard, the app, Settings.", required: true },
4370
- { input: "password", key: "apiSecret", credential: "SHOPIFY_API_SECRET", label: "API secret", message: "The app's Client secret, beside the Client ID in the app's settings.", redact: true, required: true },
4371
- { input: "text", key: "appHandle", credential: "SHOPIFY_APP_HANDLE", label: "App handle", message: "The app's URL handle (for example drawbridge-development), from the app's configuration.", required: true },
4372
- { input: "text", key: "listingUrl", credential: "SHOPIFY_APP_LISTING_URL", label: "App listing URL", message: 'Where "View on the Shopify App Store" sends merchants \u2014 the public listing, or the Dev Dashboard install link for the dev app.', required: true },
4373
- // The usage meter's event handle, when the plan config's meter ever
4374
- // changes: stored value overrides the built-in default
4375
- // (events.order.handle) at the mint point, and `format : 'slug'`
4376
- // normalizes it on save a handle can carry no spaces or capitals,
4377
- // and classification against it is case-sensitive. Optional: empty
4378
- // means the default, and the sender still refuses any handle that
4379
- // disagrees with the transaction it minted.
4380
- { format: "slug", input: "text", key: "orderEventHandle", label: "Order event handle", message: "Must match the usage meter's Handle field on the plan exactly \u2014 lowercase and hyphens. Leave empty to use drawbridge-orders.", required: false, setting: true },
4381
- // The Partner API credential set — the ONE place meter billing is
4382
- // observable (CHARGE_USAGE accrual events). Org id and numeric app
4383
- // id come from the Partner Dashboard; the token from Settings
4384
- // Partner API clients (Manage-apps permission). All optional: the
4385
- // accrual probe simply stays off until they exist.
4386
- { input: "text", key: "partnerOrgId", label: "Partner organization ID", message: "The number in the Partner Dashboard URL \u2014 partners.shopify.com/<this number>/\u2026 \u2014 NOT the admin organization_id from the app listing link.", required: false, setting: true },
4387
- { input: "text", key: "partnerAppId", label: "Partner app ID (numeric)", message: "The numeric id in the app's Partner Dashboard URL \u2014 not the Client ID.", required: false, setting: true },
4388
- { input: "password", key: "partnerToken", label: "Partner API token", message: "Partner Dashboard, Settings, Partner API clients \u2014 create one with Manage apps and paste the prtapi_ token.", redact: true, required: false, setting: true }
4389
- ]
4504
+ vendors: {
4505
+ shopify: {
4506
+ fields: [
4507
+ { input: "text", key: "apiKey", credential: "SHOPIFY_API_KEY", label: "API key", message: "The app's Client ID \u2014 Dev Dashboard, the app, Settings.", required: true },
4508
+ { input: "password", key: "apiSecret", credential: "SHOPIFY_API_SECRET", label: "API secret", message: "The app's Client secret, beside the Client ID in the app's settings.", redact: true, required: true },
4509
+ { input: "text", key: "appHandle", credential: "SHOPIFY_APP_HANDLE", label: "App handle", message: "The app's URL handle (for example drawbridge-development), from the app's configuration.", required: true },
4510
+ { input: "text", key: "listingUrl", credential: "SHOPIFY_APP_LISTING_URL", label: "App listing URL", message: 'Where "View on the Shopify App Store" sends merchants \u2014 the public listing, or the Dev Dashboard install link for the dev app.', required: true },
4511
+ // The usage meter's event handle, when the plan config's meter ever
4512
+ // changes: stored value overrides the built-in default
4513
+ // (events.order.handle) at the mint point, and `format : 'slug'`
4514
+ // normalizes it on save a handle can carry no spaces or capitals,
4515
+ // and classification against it is case-sensitive. Optional: empty
4516
+ // means the default, and the sender still refuses any handle that
4517
+ // disagrees with the transaction it minted.
4518
+ { format: "slug", input: "text", key: "orderEventHandle", label: "Order event handle", message: "Must match the usage meter's Handle field on the plan exactly \u2014 lowercase and hyphens. Leave empty to use drawbridge-orders.", required: false, setting: true },
4519
+ // The Partner API credential set the ONE place meter billing is
4520
+ // observable (CHARGE_USAGE accrual events). Org id and numeric app
4521
+ // id come from the Partner Dashboard; the token from Settings →
4522
+ // Partner API clients (Manage-apps permission). All optional: the
4523
+ // accrual probe simply stays off until they exist.
4524
+ { input: "text", key: "partnerOrgId", label: "Partner organization ID", message: "The number in the Partner Dashboard URL \u2014 partners.shopify.com/<this number>/\u2026 \u2014 NOT the admin organization_id from the app listing link.", required: false, setting: true },
4525
+ { input: "text", key: "partnerAppId", label: "Partner app ID (numeric)", message: "The numeric id in the app's Partner Dashboard URL \u2014 not the Client ID.", required: false, setting: true },
4526
+ { input: "password", key: "partnerToken", label: "Partner API token", message: "Partner Dashboard, Settings, Partner API clients \u2014 create one with Manage apps and paste the prtapi_ token.", redact: true, required: false, setting: true }
4527
+ ],
4528
+ icon: shopify_default,
4529
+ name: "Shopify"
4530
+ }
4531
+ }
4390
4532
  },
4391
4533
  // A pre-launch integration: it only surfaces once the App Store listing
4392
4534
  // exists and the app is fully configured. Requiring all four means it can
@@ -4867,8 +5009,19 @@ var implemented = (hooks, path) => {
4867
5009
  var leaves = (node, path = []) => Object.entries(node || {}).flatMap(
4868
5010
  ([key, value]) => typeof value === "function" ? [[[...path, key].join("."), value]] : value && typeof value === "object" ? leaves(value, [...path, key]) : []
4869
5011
  );
5012
+ var checkIcon = (owner, icon) => {
5013
+ if (typeof icon !== "string" || !icon.includes("<svg")) {
5014
+ throw new Error(owner + " needs an icon \u2014 the svg markup itself, not a path to one");
5015
+ }
5016
+ if (!icon.includes("viewBox")) {
5017
+ throw new Error(owner + " icon has no viewBox, so it cannot scale");
5018
+ }
5019
+ if (icon.includes("<image")) {
5020
+ throw new Error(owner + " icon wraps a raster \u2014 re-export it as vector shapes");
5021
+ }
5022
+ };
4870
5023
  var build = (manifest) => {
4871
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q, _r, _s, _t;
5024
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q, _r, _s, _t, _u;
4872
5025
  if (!(manifest == null ? void 0 : manifest.slug)) throw new Error("A connection needs a slug");
4873
5026
  if (!(manifest == null ? void 0 : manifest.title)) throw new Error(manifest.slug + " needs a title");
4874
5027
  if (!(manifest == null ? void 0 : manifest.private) && !(manifest == null ? void 0 : manifest.feature)) throw new Error(manifest.slug + " needs a plan feature key");
@@ -4898,46 +5051,53 @@ var build = (manifest) => {
4898
5051
  }
4899
5052
  }
4900
5053
  }
4901
- for (const field2 of ((_c = manifest.provider) == null ? void 0 : _c.fields) || []) {
4902
- if (!(field2 == null ? void 0 : field2.key) || !(field2 == null ? void 0 : field2.label)) {
4903
- throw new Error(manifest.slug + " declares a provider field with no key or label");
4904
- }
4905
- if (!INPUTS.includes(field2.input)) {
4906
- throw new Error(manifest.slug + ".provider." + field2.key + " needs an input the admin form can render \u2014 one of " + INPUTS.join(", "));
5054
+ if ((_c = manifest.provider) == null ? void 0 : _c.fields) {
5055
+ throw new Error(manifest.slug + " declares provider.fields \u2014 fields live under provider.vendors.<vendor> now, keyed by the vendor that holds them");
5056
+ }
5057
+ const declaredKeys = /* @__PURE__ */ new Set();
5058
+ for (const [vendor, declared] of Object.entries(((_d = manifest.provider) == null ? void 0 : _d.vendors) || {})) {
5059
+ if (!(declared == null ? void 0 : declared.name)) throw new Error(manifest.slug + " declares vendor " + vendor + " with no name");
5060
+ checkIcon(manifest.slug + " vendor " + vendor, declared.icon);
5061
+ if (!Array.isArray(declared.fields) || !declared.fields.length) {
5062
+ throw new Error(manifest.slug + " declares vendor " + vendor + " with no fields \u2014 a vendor nothing is typed into holds nothing");
4907
5063
  }
4908
- if (field2.input === "password" && !field2.redact) {
4909
- throw new Error(manifest.slug + ".provider." + field2.key + " is a password and must declare redact : true \u2014 the api would hand the value back");
5064
+ for (const field2 of declared.fields) {
5065
+ if (!(field2 == null ? void 0 : field2.key) || !(field2 == null ? void 0 : field2.label)) {
5066
+ throw new Error(manifest.slug + " declares a provider field with no key or label");
5067
+ }
5068
+ if (!INPUTS.includes(field2.input)) {
5069
+ throw new Error(manifest.slug + ".provider." + field2.key + " needs an input the admin form can render \u2014 one of " + INPUTS.join(", "));
5070
+ }
5071
+ if (field2.input === "password" && !field2.redact) {
5072
+ throw new Error(manifest.slug + ".provider." + field2.key + " is a password and must declare redact : true \u2014 the api would hand the value back");
5073
+ }
5074
+ if (declaredKeys.has(field2.key)) {
5075
+ throw new Error(manifest.slug + " declares " + field2.key + " under two vendors \u2014 vendorSettings() merges them and one would win");
5076
+ }
5077
+ declaredKeys.add(field2.key);
4910
5078
  }
4911
5079
  }
4912
- if (typeof (manifest == null ? void 0 : manifest.icon) !== "string" || !manifest.icon.includes("<svg")) {
4913
- throw new Error(manifest.slug + " needs an icon \u2014 the svg markup itself, not a path to one");
4914
- }
4915
- if (!manifest.icon.includes("viewBox")) {
4916
- throw new Error(manifest.slug + " icon has no viewBox, so it cannot scale");
4917
- }
4918
- if (manifest.icon.includes("<image")) {
4919
- throw new Error(manifest.slug + " icon wraps a raster \u2014 re-export it as vector shapes");
4920
- }
5080
+ checkIcon(manifest.slug, manifest == null ? void 0 : manifest.icon);
4921
5081
  if (!GROUPS.includes(manifest == null ? void 0 : manifest.group)) {
4922
5082
  throw new Error(manifest.slug + " needs a group \u2014 one of " + GROUPS.join(", "));
4923
5083
  }
4924
- if ((_d = manifest == null ? void 0 : manifest.connect) == null ? void 0 : _d.type) {
5084
+ if ((_e = manifest == null ? void 0 : manifest.connect) == null ? void 0 : _e.type) {
4925
5085
  throw new Error(manifest.slug + " declares connect.type \u2014 that is auth.type now");
4926
5086
  }
4927
- if (!AUTH_TYPES.includes((_e = manifest == null ? void 0 : manifest.auth) == null ? void 0 : _e.type)) {
5087
+ if (!AUTH_TYPES.includes((_f = manifest == null ? void 0 : manifest.auth) == null ? void 0 : _f.type)) {
4928
5088
  throw new Error(manifest.slug + " needs auth.type \u2014 one of " + AUTH_TYPES.join(", "));
4929
5089
  }
4930
5090
  if (manifest.auth.type === "oauth") {
4931
5091
  for (const field2 of OAUTH_FIELDS) {
4932
- if (!((_f = manifest.auth.oauth) == null ? void 0 : _f[field2])) {
5092
+ if (!((_g = manifest.auth.oauth) == null ? void 0 : _g[field2])) {
4933
5093
  throw new Error(manifest.slug + " is oauth and must declare auth.oauth." + field2);
4934
5094
  }
4935
5095
  }
4936
- if (typeof ((_h = (_g = manifest.hooks) == null ? void 0 : _g.auth) == null ? void 0 : _h.token) !== "function") {
5096
+ if (typeof ((_i = (_h = manifest.hooks) == null ? void 0 : _h.auth) == null ? void 0 : _i.token) !== "function") {
4937
5097
  throw new Error(manifest.slug + " is oauth and must implement hooks.auth.token \u2014 point it at authToken() or wrap it");
4938
5098
  }
4939
5099
  for (const url of OAUTH_URLS) {
4940
- if (!((_j = (_i = manifest.auth.oauth) == null ? void 0 : _i.urls) == null ? void 0 : _j[url])) {
5100
+ if (!((_k = (_j = manifest.auth.oauth) == null ? void 0 : _j.urls) == null ? void 0 : _k[url])) {
4941
5101
  throw new Error(manifest.slug + " is oauth and must declare auth.oauth.urls." + url);
4942
5102
  }
4943
5103
  }
@@ -4947,16 +5107,16 @@ var build = (manifest) => {
4947
5107
  );
4948
5108
  }
4949
5109
  }
4950
- if (implemented(manifest.hooks, "inbound.event") && typeof ((_l = (_k = manifest.hooks) == null ? void 0 : _k.inbound) == null ? void 0 : _l.event) !== "function" && !((_n = (_m = manifest.inbound) == null ? void 0 : _m.headers) == null ? void 0 : _n.event)) {
5110
+ if (implemented(manifest.hooks, "inbound.event") && typeof ((_m = (_l = manifest.hooks) == null ? void 0 : _l.inbound) == null ? void 0 : _m.event) !== "function" && !((_o = (_n = manifest.inbound) == null ? void 0 : _n.headers) == null ? void 0 : _o.event)) {
4951
5111
  throw new Error(manifest.slug + " implements inbound.event but declares no inbound.headers.event");
4952
5112
  }
4953
- if (implemented(manifest.hooks, "inbound.verify") && !((_p = (_o = manifest.inbound) == null ? void 0 : _o.headers) == null ? void 0 : _p.signature)) {
5113
+ if (implemented(manifest.hooks, "inbound.verify") && !((_q = (_p = manifest.inbound) == null ? void 0 : _p.headers) == null ? void 0 : _q.signature)) {
4954
5114
  throw new Error(manifest.slug + " implements inbound.verify but declares no inbound.headers.signature");
4955
5115
  }
4956
5116
  if (typeof (manifest == null ? void 0 : manifest.status) !== "function") {
4957
5117
  throw new Error(manifest.slug + " must declare status( data ) \u2014 return null to accept the connection's own status, or { message, status } to override it");
4958
5118
  }
4959
- if (!Array.isArray((_q = manifest == null ? void 0 : manifest.content) == null ? void 0 : _q.guide) || !manifest.content.guide.length) {
5119
+ if (!Array.isArray((_r = manifest == null ? void 0 : manifest.content) == null ? void 0 : _r.guide) || !manifest.content.guide.length) {
4960
5120
  throw new Error(manifest.slug + " needs content.guide \u2014 an array of steps for its page");
4961
5121
  }
4962
5122
  if (typeof (manifest == null ? void 0 : manifest.status) !== "function") {
@@ -4968,8 +5128,8 @@ var build = (manifest) => {
4968
5128
  }
4969
5129
  for (const [domain, verbs] of Object.entries(HOOKS)) {
4970
5130
  for (const verb of verbs) {
4971
- const hook = (_s = (_r = manifest.hooks) == null ? void 0 : _r[domain]) == null ? void 0 : _s[verb];
4972
- if (((_t = manifest.hooks) == null ? void 0 : _t[domain]) === false) continue;
5131
+ const hook = (_t = (_s = manifest.hooks) == null ? void 0 : _s[domain]) == null ? void 0 : _t[verb];
5132
+ if (((_u = manifest.hooks) == null ? void 0 : _u[domain]) === false) continue;
4973
5133
  if (hook !== false && !implemented({ [domain]: { [verb]: hook } }, domain + "." + verb)) {
4974
5134
  throw new Error(manifest.slug + " must answer hooks." + domain + "." + verb + " \u2014 false, a function, or {} if another repo implements it");
4975
5135
  }
@@ -5014,6 +5174,44 @@ var connections = Object.freeze({
5014
5174
  shopify: build(shopify_default2),
5015
5175
  webhook: build(webhook_default)
5016
5176
  });
5177
+ var canonical = (field2) => JSON.stringify(Object.fromEntries(Object.entries(field2).sort()));
5178
+ var mergeVendors = (catalog) => {
5179
+ var _a;
5180
+ const merged = {};
5181
+ const claimed = /* @__PURE__ */ new Map();
5182
+ for (const [slug2, manifest] of Object.entries(catalog)) {
5183
+ for (const [vendor, declared] of Object.entries(((_a = manifest.provider) == null ? void 0 : _a.vendors) || {})) {
5184
+ const held = merged[vendor] || (merged[vendor] = { fields: [], icon: declared.icon, name: declared.name });
5185
+ if (held.name !== declared.name) {
5186
+ throw new Error(slug2 + " names vendor " + vendor + ' "' + declared.name + '" but another manifest names it "' + held.name + '"');
5187
+ }
5188
+ if (held.icon !== declared.icon) {
5189
+ throw new Error(slug2 + " declares a different icon for vendor " + vendor + " than another manifest \u2014 one vendor, one mark");
5190
+ }
5191
+ for (const field2 of declared.fields) {
5192
+ const existing = held.fields.find((entry) => entry.key === field2.key);
5193
+ if (existing) {
5194
+ if (canonical(existing) !== canonical(field2)) {
5195
+ throw new Error(slug2 + " declares " + vendor + "." + field2.key + " differently from another manifest \u2014 a field two connections spend is declared identically in both");
5196
+ }
5197
+ continue;
5198
+ }
5199
+ if (field2.credential) {
5200
+ const owner = claimed.get(field2.credential);
5201
+ if (owner) {
5202
+ throw new Error(field2.credential + " is claimed by both " + owner + " and " + vendor + "." + field2.key + " \u2014 one would silently overwrite the other");
5203
+ }
5204
+ claimed.set(field2.credential, vendor + "." + field2.key);
5205
+ }
5206
+ held.fields.push(field2);
5207
+ }
5208
+ }
5209
+ }
5210
+ return Object.freeze(Object.fromEntries(
5211
+ Object.entries(merged).map(([vendor, held]) => [vendor, Object.freeze({ ...held, fields: Object.freeze(held.fields) })])
5212
+ ));
5213
+ };
5214
+ var vendors = mergeVendors(connections);
5017
5215
  (() => {
5018
5216
  var _a;
5019
5217
  const routes = {};
@@ -5086,11 +5284,8 @@ var publicConnectionKeys = Object.freeze([
5086
5284
  ]);
5087
5285
 
5088
5286
  // lib/providers.js
5089
- var providerFields = (slug2) => {
5090
- var _a;
5091
- return Object.hasOwn(connections, slug2) ? ((_a = connections[slug2].provider) == null ? void 0 : _a.fields) || [] : [];
5092
- };
5093
- var providerSlugs = () => Object.keys(connections).filter((slug2) => connections[slug2].provider).sort();
5287
+ var providerFields = (slug2) => Object.hasOwn(vendors, slug2) ? vendors[slug2].fields : [];
5288
+ var providerSlugs = () => Object.keys(vendors).sort();
5094
5289
  var isLive = (slug2, settings) => {
5095
5290
  const fields2 = providerFields(slug2);
5096
5291
  if (!fields2.length) return false;
@@ -5115,6 +5310,15 @@ var providerSettings = async ({ controller, slug: slug2 }) => {
5115
5310
  providerMemo.set(slug2, { at: Date.now(), value });
5116
5311
  return value;
5117
5312
  };
5313
+ var vendorSettings = async ({ controller, slug: slug2 }) => {
5314
+ var _a;
5315
+ const declared = Object.hasOwn(connections, slug2) ? Object.keys(((_a = connections[slug2].provider) == null ? void 0 : _a.vendors) || {}) : [];
5316
+ let merged = {};
5317
+ for (const vendor of declared) {
5318
+ merged = { ...merged, ...await providerSettings({ controller, slug: vendor }) };
5319
+ }
5320
+ return merged;
5321
+ };
5118
5322
  var saveProviderSettings = async ({ authenticated, clear, controller, settings, slug: slug2 }) => {
5119
5323
  const fields2 = providerFields(slug2);
5120
5324
  if (!fields2.length) return null;
@@ -5177,5 +5381,7 @@ var providerCredentials = async ({ controller }) => {
5177
5381
  providerMemo,
5178
5382
  providerSettings,
5179
5383
  providerSlugs,
5180
- saveProviderSettings
5384
+ saveProviderSettings,
5385
+ vendorSettings,
5386
+ vendors
5181
5387
  });