@drawbridge/drawbridge-utils 0.0.176 → 0.0.178

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.
Files changed (69) hide show
  1. package/dist/admin-B9ZaLvan.d.cts +697 -0
  2. package/dist/admin-C3HtEM6h.d.ts +697 -0
  3. package/dist/billing-Bc4yo9XG.d.cts +175 -0
  4. package/dist/billing-mNsKflmQ.d.ts +175 -0
  5. package/dist/billing.d.cts +1 -1
  6. package/dist/billing.d.ts +1 -1
  7. package/dist/connections/index.cjs +3391 -271
  8. package/dist/connections/index.d.cts +13 -4
  9. package/dist/connections/index.d.ts +13 -4
  10. package/dist/connections/index.js +3388 -270
  11. package/dist/features.cjs +3080 -241
  12. package/dist/features.d.cts +13 -4
  13. package/dist/features.d.ts +13 -4
  14. package/dist/features.js +3120 -275
  15. package/dist/http.cjs +10 -1
  16. package/dist/http.d.cts +10 -1
  17. package/dist/http.d.ts +10 -1
  18. package/dist/http.js +10 -1
  19. package/dist/{index-qY18QITf.d.cts → index-C2rxasGZ.d.cts} +2670 -354
  20. package/dist/{index-B8JhYfvU.d.ts → index-DOYCXtd7.d.ts} +2670 -354
  21. package/dist/oauth/index.d.cts +1 -1
  22. package/dist/oauth/index.d.ts +1 -1
  23. package/dist/oauth-BJDh0sdM.d.cts +527 -0
  24. package/dist/oauth-DveZMLHx.d.ts +527 -0
  25. package/dist/partner-BOZltuh2.d.ts +94 -0
  26. package/dist/partner-ed2OfW1J.d.cts +94 -0
  27. package/dist/plans.cjs +3079 -240
  28. package/dist/plans.d.cts +13 -4
  29. package/dist/plans.d.ts +13 -4
  30. package/dist/plans.js +3120 -275
  31. package/dist/pricing.cjs +3112 -273
  32. package/dist/pricing.d.cts +13 -4
  33. package/dist/pricing.d.ts +13 -4
  34. package/dist/pricing.js +3117 -272
  35. package/dist/providers.cjs +3082 -262
  36. package/dist/providers.d.cts +12 -3
  37. package/dist/providers.d.ts +12 -3
  38. package/dist/providers.js +3086 -260
  39. package/dist/sendgrid.cjs +10 -1
  40. package/dist/sendgrid.js +10 -1
  41. package/dist/shopify/admin.cjs +562 -0
  42. package/dist/shopify/admin.d.cts +3 -0
  43. package/dist/shopify/admin.d.ts +3 -0
  44. package/dist/shopify/admin.js +528 -0
  45. package/dist/shopify/billing.cjs +166 -0
  46. package/dist/shopify/billing.d.cts +3 -0
  47. package/dist/shopify/billing.d.ts +3 -0
  48. package/dist/shopify/billing.js +140 -0
  49. package/dist/shopify/constants.cjs +63 -0
  50. package/dist/shopify/constants.d.cts +58 -0
  51. package/dist/shopify/constants.d.ts +58 -0
  52. package/dist/shopify/constants.js +32 -0
  53. package/dist/shopify/oauth.cjs +509 -0
  54. package/dist/shopify/oauth.d.cts +7 -0
  55. package/dist/shopify/oauth.d.ts +7 -0
  56. package/dist/shopify/oauth.js +466 -0
  57. package/dist/shopify/partner.cjs +156 -0
  58. package/dist/shopify/partner.d.cts +3 -0
  59. package/dist/shopify/partner.d.ts +3 -0
  60. package/dist/shopify/partner.js +130 -0
  61. package/dist/shopify/storefront.cjs +611 -0
  62. package/dist/shopify/storefront.d.cts +3 -0
  63. package/dist/shopify/storefront.d.ts +3 -0
  64. package/dist/shopify/storefront.js +576 -0
  65. package/dist/storefront-C8FKOGeD.d.cts +659 -0
  66. package/dist/storefront-DJFGLqPl.d.ts +659 -0
  67. package/dist/twilio.cjs +10 -1
  68. package/dist/twilio.js +10 -1
  69. package/package.json +98 -68
@@ -1,11 +1,18 @@
1
1
  import { isDuplicateKey } from './usage.cjs';
2
+ import { decrypt, encrypt } from './encrypt.cjs';
2
3
  import { authToken } from './connections/oauth.cjs';
3
4
  import { toE164, detectCountry } from './phone.cjs';
4
5
  import crypto, { randomUUID, createHmac, timingSafeEqual, createVerify, createHash } from 'node:crypto';
5
6
  import { request } from './http.cjs';
6
7
  import { customAlphabet } from 'nanoid';
8
+ import { i as shopifyAdmin } from './admin-B9ZaLvan.cjs';
9
+ import { a as shopifyBilling } from './billing-Bc4yo9XG.cjs';
10
+ import { s as shopifyOauth } from './oauth-BJDh0sdM.cjs';
11
+ import { s as shopifyPartner } from './partner-ed2OfW1J.cjs';
12
+ import { s as shopifyStorefront } from './storefront-C8FKOGeD.cjs';
7
13
  import { toCanonicalEmail } from './email.cjs';
8
- import { decrypt } from './encrypt.cjs';
14
+ import { tokenTypes, hashToken } from './oauth/index.cjs';
15
+ import { sanitizeDomain } from './sanitize.cjs';
9
16
  import { slugify } from './slugify.cjs';
10
17
  import { safeRequest } from './safe-http.cjs';
11
18
 
@@ -158,6 +165,10 @@ const HOOKS = Object.freeze({
158
165
  'code',
159
166
  // Create the buyer at the vendor, so an order can be attributed to them.
160
167
  'customer',
168
+ // HOW MANY OF THIS PRODUCT ARE LEFT, at the vendor. Read at the moment a
169
+ // campaign's product list is rendered rather than stored, because stock is
170
+ // the one product fact that is stale the instant it is written down.
171
+ 'inventory',
161
172
  // An order arrived at the vendor: attribute it, record it, update totals.
162
173
  'order',
163
174
  // Pull product data across on a vendor update.
@@ -233,7 +244,19 @@ const HOOKS = Object.freeze({
233
244
 
234
245
  // What the merchant pays the VENDOR. Pairs with auth.install.billing, which
235
246
  // declares the plan they are being charged on.
236
- plan : Object.freeze([ 'cancel', 'reselect' ]),
247
+ //
248
+ // `verify` is the odd one of the three: not something a merchant does, but
249
+ // the question "is this store's billing actually metered right now". It is
250
+ // here because it is the same subject — and because the answer was being
251
+ // derived in TWO places, the vendor's health check and drawbridge-sync's
252
+ // approval webhook, whose copy said in its own comment that it matched the
253
+ // other and did not.
254
+ // `charge` and `verify` are the two that are not merchant actions: one
255
+ // sends a usage charge to the vendor's meter, the other asks whether that
256
+ // meter is actually working. Both are here because both are the same
257
+ // subject — what the merchant pays the vendor — and both were being done
258
+ // from drawbridge-sync with a direct import of the vendor's client.
259
+ plan : Object.freeze([ 'cancel', 'charge', 'reselect', 'verify' ]),
237
260
 
238
261
  // The embedded app's own session, stored by us on the vendor's behalf.
239
262
  session : Object.freeze([ 'clear', 'read', 'save' ]),
@@ -535,12 +558,22 @@ const WRITE_OPERATIONS = Object.freeze([ 'create', 'delete', 'update' ]);
535
558
  // -> features)
536
559
  // chunkSize the worker fleet's fan-out width — deployment tuning,
537
560
  // passed, never published
561
+ // currencies the settlement currencies Drawbridge can bill, so a
562
+ // store outside them is refused with the reason rather
563
+ // than failing a validator inside a transaction
538
564
  // dispatch() the caller's own coordinator table, for hooks that are
539
565
  // dispatches
566
+ // mark() the one write a description cannot carry because of its
567
+ // ORDER — install.plan.reselect must persist the marker
568
+ // BEFORE it releases the approval, and a described write
569
+ // lands after the hook returns
540
570
  // fetcher fetch-shaped transport: ( url, options )
541
571
  // logger the caller's logger
542
572
  // mintId() an id minted before the write it belongs to, so one
543
573
  // described write can reference another
574
+ // planTitle() the name of the Drawbridge plan an organization is on,
575
+ // given its subscription. Injected for the same reason as
576
+ // conversionRate, and from the same catalogue
544
577
  // read the controller's READ methods — get, aggregate, count —
545
578
  // and never anything that writes; not `controller`, not
546
579
  // `db`, because a name that implies writing invites it
@@ -553,10 +586,15 @@ const WRITE_OPERATIONS = Object.freeze([ 'create', 'delete', 'update' ]);
553
586
  // decides billing
554
587
  // resolveSettings() fresh from wherever the vendor keeps credentials, per
555
588
  // call, never a snapshot
589
+ // shopify the vendor's client namespaces. DEFAULTED to the real
590
+ // client, which the manifest imports directly — production
591
+ // injects nothing. Overridable because it is the SDK
592
+ // boundary, and that is where a hook test belongs: the
593
+ // health tests stub getActiveAppSubscriptions and assert
594
+ // the arguments it was called with
556
595
  // rotateToken() deliberate rotation, for the health check that rotates
557
596
  // before the window closes
558
- // shopify the SDK namespaces, injected — this package cannot
559
- // import what depends on it
597
+
560
598
  //
561
599
  // WHICH OF THESE A GIVEN SLOT ACTUALLY CARRIES is the table below, not this
562
600
  // glossary. A name here means one thing everywhere it appears; the table says
@@ -564,7 +602,13 @@ const WRITE_OPERATIONS = Object.freeze([ 'create', 'delete', 'update' ]);
564
602
  // Three signatures shared by a whole family, declared once so the family cannot
565
603
  // drift apart the way resources.* was starting to. Named rather than repeated:
566
604
  // a copy-pasted array is a copy that can be edited alone.
567
- const INSTALL_SIGNATURE = Object.freeze({ options : [ 'read', 'request' ], props : [ 'context' ] });
605
+ // THE INSTALL BASELINE: the request's own body, and the ability to read. Seven
606
+ // of the thirteen slots need nothing else — they answer from the shared install
607
+ // record and describe their writes. `request` came off it when the slots were
608
+ // implemented and none of them made an outbound call of its own; four reach the
609
+ // VENDOR, and they do it through `shopify`, which is the seam every other
610
+ // vendor-calling slot already uses.
611
+ const INSTALL_SIGNATURE = Object.freeze({ options : [ 'read' ], props : [ 'context' ] });
568
612
 
569
613
  const LIFECYCLE_SIGNATURE = Object.freeze({ options : [ 'dispatch' ], props : [ 'context' ] });
570
614
 
@@ -617,15 +661,30 @@ const HOOK_SLOT_PROPS = Object.freeze({
617
661
  // stubbed-fetcher-reaches-production failure the split exists to prevent.
618
662
  'auth.token' : false,
619
663
 
620
- // COMMERCE. `cart` is declared and unimplemented the Checkout Kit path in
621
- // api/route/subdomain.js writes nothing, which is why it never became a hook
622
- // so its signature mirrors `code`, its nearest sibling, and moving the
623
- // route here is a deliberate widening of this line rather than a silent one.
624
- 'commerce.cart' : Object.freeze({ options : [ 'adminToken', 'shopify' ], props : [ 'connection', 'context', 'step' ] }),
664
+ // COMMERCE. `cart` is the odd one: it runs for a SHOPPER on a public page,
665
+ // not for an organization inside a workflow, so it takes no `connection` and
666
+ // no `step`. It reads the storefront with the store's own storefront token
667
+ // and mints a checkout token from DRAWBRIDGE'S app credentials, which is why
668
+ // `clientId`/`clientSecret` are here as props, the way auth.disconnect
669
+ // already carries them, because a credential is a fact of the run rather
670
+ // than a service.
671
+ 'commerce.cart' : Object.freeze({
672
+ options : [ 'fetcher', 'shopify' ],
673
+ props : [ 'clientId', 'clientSecret', 'context', 'settings' ]
674
+ }),
625
675
  'commerce.code' : Object.freeze({ options : [ 'adminToken', 'shopify' ], props : [ 'connection', 'context', 'step' ] }),
626
676
  'commerce.customer' : Object.freeze({ options : [ 'adminToken', 'read', 'shopify' ], props : [ 'connection', 'context' ] }),
677
+ 'commerce.inventory' : Object.freeze({ options : [ 'adminToken', 'shopify' ], props : [ 'connection', 'context' ] }),
627
678
  'commerce.order' : Object.freeze({ options : [ 'conversionRate', 'logger', 'mintId', 'read' ], props : [ 'connection', 'context' ] }),
628
- 'commerce.product' : Object.freeze({ options : [ 'mintId', 'read' ], props : [ 'connection', 'context', 'workflow' ] }),
679
+ // THREE OPERATIONS, one slot record, sync, variants. `settings` and
680
+ // `adminToken` arrived with the pull half: the storefront read needs the
681
+ // store's storefront token and the product-feedback send needs its admin one,
682
+ // and both are resolved by the caller because minting either needs a
683
+ // controller.
684
+ 'commerce.product' : Object.freeze({
685
+ options : [ 'adminToken', 'mintId', 'read', 'shopify' ],
686
+ props : [ 'connection', 'context', 'settings', 'workflow' ]
687
+ }),
629
688
 
630
689
  // THE ADDRESS IS `contact` IN BOTH, and the shell already says why: it is the
631
690
  // person, not the lead document. `remove` called it `email` and `sync` called
@@ -641,19 +700,58 @@ const HOOK_SLOT_PROPS = Object.freeze({
641
700
  'inbound.receive' : Object.freeze({ options : [], props : [ 'channel', 'event', 'headers', 'payload' ] }),
642
701
  'inbound.verify' : Object.freeze({ options : [], props : [ 'body', 'channel', 'headers', 'secret', 'url' ] }),
643
702
 
644
- // INSTALL — thirteen slots, none implemented yet. They are what the thirteen
645
- // routes in api/route/shopify-embedded.js become, and the signature is
646
- // deliberately narrow: `context` is the request's own body, `read` and
647
- // `request` are the only services an install surface has been shown to need.
648
- // A route that cannot be expressed inside this line is a finding to bring
649
- // back here, not a name to add at the call site.
703
+ // INSTALL — thirteen slots, and they are the thirteen routes that used to be
704
+ // api/route/shopify-embedded.js. `context` is the request's own body in all
705
+ // of them; the options are where they differ, and every difference below was
706
+ // forced by a line in those routes rather than chosen.
707
+ //
708
+ // SEVEN NEED NOTHING BUT `read`. The two session slots that write, the two
709
+ // token slots that write, the two clears, and both account slots answer from
710
+ // the shared install record alone.
711
+ //
712
+ // SIX TAKE MORE, and the widenings are worth naming because each is a fact
713
+ // Drawbridge owns that the manifest must not:
714
+ //
715
+ // conversionRate, planTitle what Drawbridge charges this organization.
716
+ // The org list renders both, and importing the
717
+ // plan catalogue here is the cycle
718
+ // features -> connections -> shopify -> plans
719
+ // -> features that was just broken.
720
+ // currencies which settlement currencies Drawbridge can
721
+ // bill. A store outside them fails the
722
+ // connection validator INSIDE the transaction,
723
+ // which surfaces as an opaque 500, so `add`
724
+ // checks first and says so.
725
+ // mintId the connection's id, known before the write
726
+ // that creates it, because the organization's
727
+ // own refs have to name it in the same
728
+ // transaction.
729
+ // shopify the vendor. Four slots call the store: two
730
+ // read the live plan, two release it.
731
+ // mark THE ONE WRITE A DESCRIPTION CANNOT CARRY, and
732
+ // `reselect` is the only slot with one. See its
733
+ // implementation for why the order is the point.
650
734
  'install.account.link' : INSTALL_SIGNATURE,
651
735
  'install.account.unlink' : INSTALL_SIGNATURE,
652
- 'install.organizations.add' : INSTALL_SIGNATURE,
653
- 'install.organizations.list' : INSTALL_SIGNATURE,
736
+ 'install.organizations.add' : Object.freeze({
737
+ options : [ 'currencies', 'mintId', 'read', 'shopify' ],
738
+ props : [ 'context' ]
739
+ }),
740
+ 'install.organizations.list' : Object.freeze({
741
+ options : [ 'conversionRate', 'planTitle', 'read', 'shopify' ],
742
+ props : [ 'context' ]
743
+ }),
654
744
  'install.organizations.remove' : INSTALL_SIGNATURE,
655
- 'install.plan.cancel' : INSTALL_SIGNATURE,
656
- 'install.plan.reselect' : INSTALL_SIGNATURE,
745
+ 'install.plan.cancel' : Object.freeze({ options : [ 'read', 'shopify' ], props : [ 'context' ] }),
746
+ // `manifest` because the handle this charge bills under is the vendor's own
747
+ // declaration (auth.install.billing.handle), and checking the job against the
748
+ // declaration is what the enqueuer-checks-itself version could not do.
749
+ 'install.plan.charge' : Object.freeze({
750
+ options : [ 'shopify' ],
751
+ props : [ 'clientId', 'clientSecret', 'context', 'manifest' ]
752
+ }),
753
+ 'install.plan.reselect' : Object.freeze({ options : [ 'mark', 'read', 'shopify' ], props : [ 'context' ] }),
754
+ 'install.plan.verify' : Object.freeze({ options : [ 'adminToken', 'read', 'shopify' ], props : [ 'context' ] }),
657
755
  'install.session.clear' : INSTALL_SIGNATURE,
658
756
  'install.session.read' : INSTALL_SIGNATURE,
659
757
  'install.session.save' : INSTALL_SIGNATURE,
@@ -837,6 +935,16 @@ const TRIGGERS = Object.freeze({
837
935
  'range.ended' : Object.freeze({ event : 'range.ended', type : 'range' }),
838
936
  'range.started' : Object.freeze({ event : 'range.started', type : 'range' }),
839
937
  'schedule.day' : Object.freeze({ event : 'day', type : 'schedule' }),
938
+ // THE THREE SHOPIFY TRIGGERS, which were never in this vocabulary because the
939
+ // four workflows they fire were hand-written in drawbridge-sync's register job
940
+ // rather than provisioned from the manifest. `webhook` is a fourth trigger
941
+ // TYPE, and it is the honest name: these fire when the vendor calls us, which
942
+ // is neither an event Drawbridge raised nor a schedule it keeps.
943
+ 'shopify.order.create' : Object.freeze({ event : 'shopify.order.create', type : 'webhook' }),
944
+ 'shopify.product.update' : Object.freeze({ event : 'shopify.product.update', type : 'webhook' }),
945
+ // The token audit, dispatched programmatically by the register job when a
946
+ // store's grant is exchanged or rotated.
947
+ 'shopify.token' : Object.freeze({ event : 'shopify.token', type : 'event' }),
840
948
  'schedule.month' : Object.freeze({ event : 'month', type : 'schedule' }),
841
949
  'schedule.week' : Object.freeze({ event : 'week', type : 'schedule' }),
842
950
  'segment.contact.add' : Object.freeze({ event : 'segment.contact.add', type : 'event' }),
@@ -952,7 +1060,7 @@ const HOOK_NAMES = Object.freeze( names( HOOKS ) );
952
1060
  // matched on it. A fifth outcome that no producer produces and no consumer reads
953
1061
  // is a branch somebody writes one day for a case that cannot happen.
954
1062
  //
955
- // Not to be confused with STATUSES.disconnected below, which is a CONNECTION's
1063
+ // Not to be confused with a CONNECTION's own status, which is
956
1064
  // state and stays until a count says it can go.
957
1065
  const OUTCOMES = Object.freeze({
958
1066
  answered : 'answered',
@@ -978,7 +1086,13 @@ const OUTCOMES = Object.freeze({
978
1086
  // NO `incomplete`. That is an ORGANIZATION status meaning outstanding invoices,
979
1087
  // and reusing the word for a connection would leave nobody able to say which
980
1088
  // object a status referred to.
981
- const STATUSES = Object.freeze([ 'active', 'disconnected', 'error', 'pending' ]);
1089
+ //
1090
+ // NO `disconnected` EITHER, as of 2026-09-13. Nothing wrote it — drawbridge-api
1091
+ // DELETES a connection on disconnect — and its removal was gated on a count
1092
+ // rather than on the reading, because a value a live document still holds cannot
1093
+ // leave a strict validator without rejecting that document on its next write.
1094
+ // Counted read-only on both clusters the day it went: 0 on dev, 0 on prod.
1095
+ const STATUSES = Object.freeze([ 'active', 'error', 'pending' ]);
982
1096
 
983
1097
  // HOW A VENDOR IS CONNECTED — SAID BY THE SHAPE, not by an enum beside it.
984
1098
  //
@@ -1198,18 +1312,16 @@ const bearing = ( entry ) => ERROR_SOURCES[ entry?.source ] !== false;
1198
1312
  // checked status alone, so saving a workflow flipped it active against a
1199
1313
  // connection sync immediately re-errored.
1200
1314
  //
1201
- // TERMINAL STATES ARE NOT REASSESSED. `disconnected` is a merchant's decision,
1202
- // and a passing health check proves a credential works — never that somebody
1203
- // wants the connection. (Nothing writes it today: the api DELETES a connection on
1204
- // disconnect. It stays in the vocabulary, and stays honoured here, until a count
1205
- // on both clusters says it can leave.)
1206
- const assess = ({ errors = [], status = null } = {}) => {
1207
-
1208
- if( status === 'disconnected' ) return status;
1209
-
1210
- return ( errors || [] ).some( bearing ) ? 'error' : 'active';
1211
-
1212
- };
1315
+ // NO TERMINAL STATE TO HONOUR ANY MORE. This used to return `disconnected`
1316
+ // untouched, on the reasoning that a passing health check proves a credential
1317
+ // works and never that somebody wants the connection. True but nothing ever
1318
+ // wrote that status, because drawbridge-api deletes a connection on disconnect,
1319
+ // and it left the vocabulary once a count on both clusters read zero.
1320
+ //
1321
+ // So every status this can see is derived, and it derives all of them.
1322
+ const assess = ({ errors = [] } = {}) => (
1323
+ ( errors || [] ).some( bearing ) ? 'error' : 'active'
1324
+ );
1213
1325
 
1214
1326
  // WHAT SURVIVES A PASS THAT PROVED SOMETHING. A health run proves the credential,
1215
1327
  // so it may clear `oauth` and whatever else it judged — and must leave every
@@ -1223,6 +1335,301 @@ const surviving = ({ errors = [], proved = [] } = {}) => (
1223
1335
  // as a Mongo "Document failed validation" naming no field.
1224
1336
  const isStatus = ( status ) => STATUSES.includes( status );
1225
1337
 
1338
+ // CARRYING OUT WHAT A HOOK DESCRIBED, and the only place that does it.
1339
+ //
1340
+ // It lived in drawbridge-sync until drawbridge-api needed it too: the install
1341
+ // hooks describe writes, and an api route that performed them itself would be a
1342
+ // second implementation of this file. Two performers is exactly the drift this
1343
+ // package exists to remove — and the subtle half is not the writing, it is the
1344
+ // ORDERING and the transaction, which a second copy would eventually get wrong.
1345
+ //
1346
+ // ENQUEUE AND EMIT ARE INJECTED rather than imported. They are the two effects a
1347
+ // performer cannot own: sync enqueues onto its own BullMQ queues through the
1348
+ // telemetry wrapper and emits through its socket server, and the api has
1349
+ // neither. Everything else — the writes, the duplicate rule, the transaction —
1350
+ // is the same wherever the answer came from.
1351
+
1352
+ const perform = async ({ answer, controller, emit, enqueue }) => {
1353
+
1354
+ const { enqueues, events, transaction, writes } = answerOf( answer ) || {};
1355
+
1356
+ const write = async ( { collection, data, ignoreDuplicate, multiple, operation, options, query }, session ) => {
1357
+
1358
+ // NOTHING IS REBUILT HERE. This used to reconstruct `_id` from a created
1359
+ // document's `id`, because the injected `mintId` handed the manifests a bare
1360
+ // string — a published package has no mongodb dependency and cannot make an
1361
+ // ObjectId. So the pair was minted in one direction here and derived in the
1362
+ // other by drawbridge-mongodb's own `ids()`, which already accepts a supplied
1363
+ // `_id` and derives `id` from it. Two places minting one pair, opposite ways.
1364
+ //
1365
+ // `mintId` now returns BOTH halves and the hook places each, so a described
1366
+ // write arrives already shaped and this is a plain pass-through.
1367
+
1368
+ try {
1369
+
1370
+ await controller[ operation ]({
1371
+ collection,
1372
+ data,
1373
+ // The controller takes `multiple` beside options — an update that
1374
+ // means every matching document must say so explicitly.
1375
+ ...( multiple && { multiple : true }),
1376
+ ...( ( options || session ) && { options : { ...options, ...( session && { session }) } }),
1377
+ ...( query && { query })
1378
+ });
1379
+
1380
+ } catch ( error ){
1381
+
1382
+ // A DUPLICATE IS THE MECHANISM WORKING where the hook said so — the
1383
+ // team-notify damper is a partial unique index, and a collision means this
1384
+ // recipient has already been told inside the bucket. Everywhere else a
1385
+ // duplicate key is a real failure and must still throw.
1386
+ if( ! ( ignoreDuplicate && isDuplicateKey( error ) ) ) throw error;
1387
+
1388
+ }
1389
+
1390
+ };
1391
+
1392
+ // ALL OR NONE when the hook asked for it — the order/redemption pair, where a
1393
+ // half-written attribution is revenue counted twice or not at all. Enqueues
1394
+ // and events stay OUTSIDE and after: a job must not observe rows that later
1395
+ // roll back, and an emit is not transactional anywhere.
1396
+ if( transaction ){
1397
+
1398
+ await controller.transaction( async ( session ) => {
1399
+
1400
+ for( const descriptor of writes ) await write( descriptor, session );
1401
+
1402
+ });
1403
+
1404
+ } else {
1405
+
1406
+ for( const descriptor of writes ) await write( descriptor );
1407
+
1408
+ }
1409
+
1410
+ for( const { data, name, options, queue : target } of enqueues ){
1411
+
1412
+ // ENQUEUE AND EMIT ARE INJECTED, because they are the two things a
1413
+ // performer cannot own. drawbridge-sync enqueues through the telemetry
1414
+ // wrapper onto its own BullMQ queues and emits through its socket server;
1415
+ // drawbridge-api has neither. A hook that describes one where the caller
1416
+ // supplied no way to do it is a programming error, and it says so rather
1417
+ // than dropping the effect on the floor.
1418
+ if( ! enqueue ) throw new Error( 'A hook described an enqueue and this caller supplied no enqueuer' );
1419
+
1420
+ await enqueue({ data, name, options, queue : target });
1421
+
1422
+ }
1423
+
1424
+ for( const descriptor of events ){
1425
+
1426
+ if( ! emit ) throw new Error( 'A hook described a socket event and this caller supplied no emitter' );
1427
+
1428
+ emit( descriptor );
1429
+
1430
+ }
1431
+
1432
+ };
1433
+
1434
+ // WORK DONE ON A STORED CONNECTION ROW, FROM OUTSIDE A HOOK.
1435
+ //
1436
+ // Two things live here, and they are here for the same reason: each needs a
1437
+ // CONTROLLER, which a hook may never hold. A hook describes its effects and
1438
+ // perform() carries them out; these two are the cases where that split does not
1439
+ // fit, so they are the shell's work — but they were the shell's work in
1440
+ // drawbridge-sync, in a `lib/connection.js` that also imported the Shopify
1441
+ // client. That made them a second connections library beside this one.
1442
+ //
1443
+ // They take a controller as an ARGUMENT rather than importing anything, exactly
1444
+ // as perform() does. That is the established shape for a utils file that has to
1445
+ // touch the database.
1446
+
1447
+ // FETCH A CONNECTION AND DECRYPT ITS SETTINGS IN ONE CALL. Callers pass the
1448
+ // whole query — by id, by organization and slug, with $in arrays — and typically
1449
+ // include `status : 'active'`.
1450
+ //
1451
+ // Answers `{ connection, settings }`, both null when nothing matched, so the
1452
+ // call site keeps its terse guard:
1453
+ //
1454
+ // const { connection, settings } = await connectionSettings({ controller, query });
1455
+ // if( ! connection ) return;
1456
+ const connectionSettings = async ({ controller, query }) => {
1457
+
1458
+ const connection = await controller.get({ collection : 'connection', query });
1459
+
1460
+ if( ! connection ) return { connection : null, settings : null };
1461
+
1462
+ return { connection, settings : decrypt( connection.settings ) };
1463
+
1464
+ };
1465
+
1466
+ const SCOPES_ERROR_SOURCE = 'scopes';
1467
+
1468
+ // Merchant-facing text. It must match the entry drawbridge-api presents, so the
1469
+ // two writers converging on one row never change the message; the missing-scope
1470
+ // detail stays in the log.
1471
+ const SCOPES_ERROR_MESSAGE = 'Shopify permissions are out of date. Open the Drawbridge app in your Shopify admin to approve the updated permissions.';
1472
+
1473
+ // SCOPE-DRIFT ENFORCEMENT, SHOP-SCOPED — because every organization's connection
1474
+ // to the same store shares ONE grant.
1475
+ //
1476
+ // WHY THIS IS NOT A HOOK, which is the question it invites. A hook answers for
1477
+ // one connection and describes writes for that connection. This one reads a
1478
+ // grant held on a shared install record and then writes across EVERY
1479
+ // organization's connection to that store, plus their products and
1480
+ // advertisements. And `lifecycle.health` needs its RESULT to compose the message
1481
+ // it returns, which is why the health hook takes it as an injected
1482
+ // `reconcileScopes` rather than calling it.
1483
+ //
1484
+ // THE VENDOR STILL JUDGES ITS OWN GRANT. What is missing from a grant is
1485
+ // `auth.scopes`, asked here through runHook. What to DO about it is this
1486
+ // function. The judging half used to be a direct import of the Shopify client.
1487
+ //
1488
+ // Missing scopes: every connection for the shop goes `status : 'error'` with a
1489
+ // scopes-sourced entry, the store's products deactivate, and their
1490
+ // advertisements draft. Complete scopes: the entry is removed, status returns to
1491
+ // active when nothing else survives, and each product RE-SYNCS through its own
1492
+ // worker — the single reconciliation point — rather than a blanket reactivate,
1493
+ // which would wrongly revive products that are inactive because the merchant
1494
+ // unpublished them from the channel. Ads stay drafted for a deliberate
1495
+ // re-enable, matching the disconnect and unpublish cascades.
1496
+ //
1497
+ // `granted` is the grant to evaluate, a string or an array. Omitted, it is read
1498
+ // from the install record's stored session, which the embedded app rewrites on
1499
+ // every open. NO GRANT DATA AT ALL MEANS SKIP, never enforce: absence of evidence
1500
+ // must not error a healthy connection, and a legacy one-to-one install carries no
1501
+ // install record.
1502
+ const reconcileConnectionScopes = async ({
1503
+ controller,
1504
+ enqueue,
1505
+ granted = null,
1506
+ logger,
1507
+ shop,
1508
+ slug = 'shopify'
1509
+ }) => {
1510
+
1511
+ if( granted === null ){
1512
+
1513
+ const record = await controller.get({ collection : 'shop', query : { shop } });
1514
+
1515
+ const session = record?.settings ? decrypt( record.settings ) : null;
1516
+
1517
+ granted = session?.scope || null;
1518
+
1519
+ }
1520
+
1521
+ if( ! granted ) return null;
1522
+
1523
+ // NULL IS NOT AN EMPTY LIST. The hook answers null when the grant could not be
1524
+ // read at all, and reading that as "nothing missing" is how a connection
1525
+ // reports healthy while it has quietly stopped doing half its job.
1526
+ const answered = await runHook( slug, 'auth.scopes', { scope : granted } );
1527
+
1528
+ const missing = answered.outcome === 'answered' ? answered.answer.result?.missing : null;
1529
+
1530
+ if( ! Array.isArray( missing ) ){
1531
+
1532
+ logger?.warn?.( 'connection.scopes.unreadable', { shop, slug });
1533
+
1534
+ return null;
1535
+
1536
+ }
1537
+
1538
+ const connections = await controller.aggregate({
1539
+ collection : 'connection',
1540
+ pipeline : [ { $match : { shop, slug } } ]
1541
+ });
1542
+
1543
+ let hadScopesError = false;
1544
+
1545
+ for( const connection of connections ){
1546
+
1547
+ const others = ( connection.errors || [] ).filter( ( entry ) => entry.source !== SCOPES_ERROR_SOURCE );
1548
+
1549
+ const hasScopesError = others.length !== ( connection.errors || [] ).length;
1550
+
1551
+ hadScopesError = hadScopesError || hasScopesError;
1552
+
1553
+ if( missing.length ){
1554
+
1555
+ await controller.update({
1556
+ collection : 'connection',
1557
+ data : {
1558
+ $set : {
1559
+ errors : [ ...others, { message : SCOPES_ERROR_MESSAGE, source : SCOPES_ERROR_SOURCE } ],
1560
+ status : 'error'
1561
+ }
1562
+ },
1563
+ query : { id : connection.id }
1564
+ });
1565
+
1566
+ } else if( hasScopesError ){
1567
+
1568
+ await controller.update({
1569
+ collection : 'connection',
1570
+ data : {
1571
+ $set : {
1572
+ errors : others,
1573
+ ...( connection.status === 'error' && ! others.length && { status : 'active' })
1574
+ }
1575
+ },
1576
+ query : { id : connection.id }
1577
+ });
1578
+
1579
+ }
1580
+
1581
+ }
1582
+
1583
+ const products = connections.length
1584
+ ? await controller.aggregate({
1585
+ collection : 'product',
1586
+ pipeline : [ { $match : { 'provider.slug' : slug, 'source.domain' : shop } } ]
1587
+ })
1588
+ : [];
1589
+
1590
+ if( missing.length && products.length ){
1591
+
1592
+ const productIds = products.map( ( product ) => product.id );
1593
+
1594
+ await controller.update({
1595
+ collection : 'product',
1596
+ data : { $set : { status : 'inactive' } },
1597
+ multiple : true,
1598
+ query : { id : { $in : productIds } }
1599
+ });
1600
+
1601
+ await controller.update({
1602
+ collection : 'advertisement',
1603
+ data : { $set : { status : 'drafted' } },
1604
+ multiple : true,
1605
+ query : { product : { $in : productIds } }
1606
+ });
1607
+
1608
+ } else if( ! missing.length && hadScopesError && enqueue ){
1609
+
1610
+ // ENQUEUE IS INJECTED, for the same reason perform()'s is: this package
1611
+ // holds no BullMQ queues, and a caller that supplied no enqueuer gets the
1612
+ // writes without the re-sync rather than a crash. The health path supplies
1613
+ // one; a caller that only wants the status corrected need not.
1614
+ for( const product of products ){
1615
+
1616
+ await enqueue({
1617
+ data : { product : product.id },
1618
+ name : 'sync',
1619
+ options : { jobId : 'product.' + slug + '.' + product.id + '.' + Date.now() },
1620
+ queue : 'product.' + slug
1621
+ });
1622
+
1623
+ }
1624
+
1625
+ }
1626
+
1627
+ logger?.info?.( 'connection.scopes.reconciled', { connections : connections.length, missing, shop });
1628
+
1629
+ return missing;
1630
+
1631
+ };
1632
+
1226
1633
  // THE WORKFLOWS A CONNECTION OWNS, provisioned from its manifest.
1227
1634
  //
1228
1635
  // It lived in drawbridge-sync until it was needed twice: sync provisions lazily
@@ -1250,6 +1657,54 @@ const systemSteps = ( node, path = [] ) => Object.entries( node || {} ).flatMap(
1250
1657
  }
1251
1658
  );
1252
1659
 
1660
+ // THE WORKFLOWS A MANIFEST DECLARES, as data and without writing any of them.
1661
+ //
1662
+ // SHARED WITH THE MIGRATION THAT REPORTS WHAT IS MISSING. drawbridge-api's
1663
+ // provisionSystemWorkflows had its own copy of this walk so a dry run could say
1664
+ // what it WOULD create without creating it — and the copy fell behind the moment
1665
+ // a workflow could carry several steps: it keyed titles off each step's own
1666
+ // `key`, so a grouped pair looked like two workflows named after steps rather
1667
+ // than one named after the grouping. Two walks over the same manifests is
1668
+ // exactly the drift this package exists to remove, so there is one.
1669
+ //
1670
+ // ONE ROW PER TITLE, and several steps may share one. A step that declares
1671
+ // `workflow` takes its title, its trigger and its position from there; every
1672
+ // other step is a workflow of one, titled by its own key.
1673
+ //
1674
+ // NO TRIGGER IS A DECISION, and it is honoured here: a system step with neither
1675
+ // its own trigger nor a workflow carrying one is declared so a stray dispatch
1676
+ // lands on a real queue, and provisioning a row for it would put something in
1677
+ // the merchant's list that never runs.
1678
+ //
1679
+ // SORTED BY DECLARED ORDER, because a workflow of several runs them in sequence
1680
+ // and the tree walk is alphabetical, which is not meaningful.
1681
+ const systemWorkflows = ( manifest ) => {
1682
+
1683
+ const grouped = new Map();
1684
+
1685
+ for( const [ type, declared ] of systemSteps( manifest?.steps ) ){
1686
+
1687
+ const trigger = declared.workflow?.trigger || declared.trigger;
1688
+
1689
+ if( ! trigger?.event || ! trigger?.type ) continue;
1690
+
1691
+ const title = declared.workflow?.key || declared.key;
1692
+
1693
+ if( ! title ) continue;
1694
+
1695
+ if( ! grouped.has( title ) ) grouped.set( title, { steps : [], title, trigger });
1696
+
1697
+ grouped.get( title ).steps.push({ order : declared.workflow?.order ?? 0, type });
1698
+
1699
+ }
1700
+
1701
+ return [ ...grouped.values() ].map( ( workflow ) => ({
1702
+ ...workflow,
1703
+ steps : workflow.steps.slice().sort( ( a, b ) => a.order - b.order )
1704
+ }) );
1705
+
1706
+ };
1707
+
1253
1708
  // THE WORKFLOWS A CONNECTION OWNS, provisioned from the manifest rather than
1254
1709
  // from a branch per step type. A vendor that declares a new system step with a
1255
1710
  // trigger gets its workflow with no edit here, which is the whole point of the
@@ -1268,8 +1723,13 @@ const systemSteps = ( node, path = [] ) => Object.entries( node || {} ).flatMap(
1268
1723
  // { connection, system, title } where system is true refuses every create but
1269
1724
  // the first, and the catch below is what lets a loser carry on.
1270
1725
  //
1271
- // Shopify is excluded: its register job creates the same workflow with three
1272
- // siblings, and racing it here would write a duplicate.
1726
+ // SHOPIFY IS NO LONGER EXCLUDED. It was, because its register job hand-wrote the
1727
+ // same four workflows and racing it here would write duplicates — and the reason
1728
+ // that spec could not simply be deleted is that one of its four, "Shopify Token
1729
+ // Activity", carries TWO steps, which a declaration had no way to express. It
1730
+ // does now: a step may declare `workflow`, naming the title, the trigger and its
1731
+ // order, and the grouping below provisions one row for all the steps that share
1732
+ // a key. The hand-written spec is gone with it.
1273
1733
  //
1274
1734
  // `connections` is a parameter whose default is the real registry. The seam is
1275
1735
  // there so this module's behaviour can be tested against a manifest shape — a
@@ -1278,7 +1738,7 @@ const systemSteps = ( node, path = [] ) => Object.entries( node || {} ).flatMap(
1278
1738
  // to be installed. Production callers pass nothing and get the real thing.
1279
1739
  const ensureSystemWorkflows = async ({ connections : registry = connections, controller, doc }) => {
1280
1740
 
1281
- if( ! doc?.id || doc.slug === 'shopify' ) return [];
1741
+ if( ! doc?.id ) return [];
1282
1742
 
1283
1743
  const manifest = registry[ doc.slug ];
1284
1744
 
@@ -1286,17 +1746,7 @@ const ensureSystemWorkflows = async ({ connections : registry = connections, con
1286
1746
 
1287
1747
  const provisioned = [];
1288
1748
 
1289
- for( const [ type, declared ] of systemSteps( manifest.steps ) ){
1290
-
1291
- // NO TRIGGER IS A DECISION. Shopify's token-audit steps are declared so a
1292
- // stray dispatch lands on a real queue, and are never fired — provisioning
1293
- // a workflow for them would put a row in the merchant's list for something
1294
- // that never runs.
1295
- if( ! declared.trigger?.event || ! declared.trigger?.type ) continue;
1296
-
1297
- const title = declared.key;
1298
-
1299
- if( ! title ) continue;
1749
+ for( const { steps : members, title, trigger } of systemWorkflows( manifest ) ){
1300
1750
 
1301
1751
  const existing = await controller.count({
1302
1752
  collection : 'workflow',
@@ -1316,13 +1766,10 @@ const ensureSystemWorkflows = async ({ connections : registry = connections, con
1316
1766
  connection : doc.id,
1317
1767
  organization : doc.organization,
1318
1768
  status : 'active',
1319
- steps : [ {
1320
- settings : {},
1321
- type
1322
- } ],
1769
+ steps : members.map( ( { type } ) => ({ settings : {}, type }) ),
1323
1770
  system : true,
1324
1771
  title,
1325
- trigger : declared.trigger
1772
+ trigger
1326
1773
  };
1327
1774
 
1328
1775
  let created;
@@ -1361,6 +1808,213 @@ const ensureSystemWorkflows = async ({ connections : registry = connections, con
1361
1808
 
1362
1809
  };
1363
1810
 
1811
+ // THE FACTS OF THE REGISTRY, AS MARKDOWN.
1812
+ //
1813
+ // `drawbridge-docs/reference/connection-hooks.md` carried a support matrix under
1814
+ // the footnote "Generated from the manifests in drawbridge-utils — if this table
1815
+ // and the package disagree, the package is right."
1816
+ //
1817
+ // IT WAS NOT GENERATED, AND THEY DISAGREED. Measured 2026-09-12, before this
1818
+ // existed: four columns against six manifests, seventeen rows against
1819
+ // thirty-one hook slots, and THREE OF THE THIRTEEN checkable rows were factually
1820
+ // wrong — auth.connect documented `yes` for two vendors that decline it,
1821
+ // auth.disconnect `yes` for three that decline it, auth.scopes `no` for Klaviyo,
1822
+ // which implements it. Plus twenty-seven references to a `catalog.*` domain that
1823
+ // had been called `resources.*` for two renames.
1824
+ //
1825
+ // So the facts are emitted from the registry and the doc includes them. THE
1826
+ // PROSE AROUND THEM STAYS HAND-WRITTEN: the reasoning about why
1827
+ // `resources.prices` is declined everywhere is worth a person's words. Only the
1828
+ // facts rot, so only the facts are generated.
1829
+ //
1830
+ // A test asserts that regenerating produces no diff — which is the check that
1831
+ // footnote always claimed and nobody ever ran.
1832
+
1833
+ const MARKER = {
1834
+ end : '<!-- /generated -->',
1835
+ start : '<!-- generated: drawbridge-utils lib/connections/reference.js — do not edit by hand -->'
1836
+ };
1837
+
1838
+ const slugs = () => Object.keys( connections ).sort();
1839
+
1840
+ // Every hook slot, in the vocabulary's own order, as dotted names. HOOKS nests
1841
+ // arbitrarily deep — `notification.email.send`, `install.organizations.add` —
1842
+ // and the depth is part of the name.
1843
+ const slots = ( node = HOOKS, path = [] ) => Object.entries( node ).flatMap(
1844
+ ( [ key, value ] ) => (
1845
+ Array.isArray( value )
1846
+ ? value.map( ( verb ) => [ ...path, key, verb ].join( '.' ) )
1847
+ : slots( value, [ ...path, key ] )
1848
+ )
1849
+ );
1850
+
1851
+ const implemented$1 = ( hooks, name ) => {
1852
+
1853
+ const found = name.split( '.' ).reduce(
1854
+ ( node, key ) => ( node && typeof node === 'object' && Object.hasOwn( node, key ) ) ? node[ key ] : undefined,
1855
+ hooks
1856
+ );
1857
+
1858
+ if( typeof found === 'function' ) return 'yes';
1859
+
1860
+ // `{}` IS A THIRD ANSWER, not a yes: declared here, implemented in the repo
1861
+ // holding the dependencies. Collapsing it into `yes` is how a doc tells a
1862
+ // reader the package can do something it cannot.
1863
+ if( found && typeof found === 'object' ) return 'elsewhere';
1864
+
1865
+ return 'no';
1866
+
1867
+ };
1868
+
1869
+ const table = ( header, rows ) => [
1870
+ '| ' + header.join( ' | ' ) + ' |',
1871
+ '|' + header.map( () => '---' ).join( '|' ) + '|',
1872
+ ...rows.map( ( row ) => '| ' + row.join( ' | ' ) + ' |' )
1873
+ ].join( '\n' );
1874
+
1875
+ const matrix = () => {
1876
+
1877
+ const columns = slugs();
1878
+
1879
+ return table(
1880
+ [ 'Hook', ...columns ],
1881
+ slots().map( ( name ) => [
1882
+ '`' + name + '`',
1883
+ ...columns.map( ( slug ) => implemented$1( connections[ slug ].hooks, name ) )
1884
+ ] )
1885
+ );
1886
+
1887
+ };
1888
+
1889
+ const vocabularies = () => {
1890
+
1891
+ const list = ( values ) => values.map( ( value ) => '`' + value + '`' ).join( ', ' );
1892
+
1893
+ return table(
1894
+ [ 'Vocabulary', 'Values' ],
1895
+ [
1896
+ [ '`AUTH_KINDS`', list( AUTH_KINDS ) ],
1897
+ [ '`GROUPS`', list( GROUPS ) ],
1898
+ [ '`INPUTS`', list( INPUTS ) ],
1899
+ [ '`STATUSES`', list( STATUSES ) ],
1900
+ [ '`TRIGGERS`', list( Object.keys( TRIGGERS ).sort() ) ],
1901
+ [ '`TRIGGER_TYPES`', list( TRIGGER_TYPES ) ]
1902
+ ]
1903
+ );
1904
+
1905
+ };
1906
+
1907
+ // Every step in the registry, with the hook it calls and what fires it. A step
1908
+ // with NO hook is real and stays visible: Shopify's token-audit pair is declared
1909
+ // for routing and never dispatched.
1910
+ const steps = () => {
1911
+
1912
+ const rows = [];
1913
+
1914
+ const walk = ( node, path, slug ) => {
1915
+
1916
+ if( ! node || typeof node !== 'object' ) return;
1917
+
1918
+ for( const [ key, value ] of Object.entries( node ) ){
1919
+
1920
+ if( typeof value === 'function' ){
1921
+
1922
+ let declared;
1923
+
1924
+ try { declared = value({}); } catch { continue; }
1925
+
1926
+ if( ! declared?.type ) continue;
1927
+
1928
+ const fires = declared.workflow?.trigger || declared.trigger;
1929
+
1930
+ rows.push([
1931
+ '`' + declared.type + '`',
1932
+ slug,
1933
+ declared.hook ? '`' + declared.hook + '`' : '—',
1934
+ declared.queue,
1935
+ declared.system ? 'system' : declared.withdrawn ? 'withdrawn' : 'builder',
1936
+ fires ? '`' + fires.event + '` / `' + fires.type + '`' : ( declared.triggers || [] ).map( ( t ) => '`' + t + '`' ).join( ', ' ) || '—'
1937
+ ]);
1938
+
1939
+ continue;
1940
+
1941
+ }
1942
+
1943
+ walk( value, [ ...path, key ], slug );
1944
+
1945
+ }
1946
+
1947
+ };
1948
+
1949
+ for( const slug of slugs() ) walk( connections[ slug ].steps, [], slug );
1950
+
1951
+ return table( [ 'Step', 'Connection', 'Hook', 'Queue', 'Shape', 'Fired by' ], rows.sort( ( a, b ) => a[ 0 ].localeCompare( b[ 0 ] ) || a[ 1 ].localeCompare( b[ 1 ] ) ) );
1952
+
1953
+ };
1954
+
1955
+ // The workflows a connection is provisioned with, which is the other half of
1956
+ // "what runs on its own": a step is only reachable if something fires it.
1957
+ const workflows = () => table(
1958
+ [ 'Workflow', 'Connection', 'Steps', 'Trigger' ],
1959
+ slugs().flatMap( ( slug ) => systemWorkflows( connections[ slug ] ).map( ( workflow ) => [
1960
+ workflow.title,
1961
+ slug,
1962
+ workflow.steps.map( ( step ) => '`' + step.type + '`' ).join( ' → ' ),
1963
+ '`' + workflow.trigger.event + '` / `' + workflow.trigger.type + '`'
1964
+ ] ) )
1965
+ );
1966
+
1967
+ const reference = () => [
1968
+ MARKER.start,
1969
+ '',
1970
+ '### The support matrix',
1971
+ '',
1972
+ '`yes` is a body in this package. `elsewhere` is declared here and implemented in the repo holding the dependencies — a different fact from `no`, and collapsing the two is how a table tells a reader the package can do something it cannot.',
1973
+ '',
1974
+ matrix(),
1975
+ '',
1976
+ '### The closed vocabularies',
1977
+ '',
1978
+ vocabularies(),
1979
+ '',
1980
+ '### Every step',
1981
+ '',
1982
+ 'A step with no hook is real: Shopify\'s token-audit pair is declared so a stray dispatch lands on a real queue, and is never fired.',
1983
+ '',
1984
+ steps(),
1985
+ '',
1986
+ '### The system workflows a connection is provisioned with',
1987
+ '',
1988
+ 'Idempotent on (connection, system, title), so the TITLE is load-bearing — one that varied by account would provision a second workflow every time it changed.',
1989
+ '',
1990
+ workflows(),
1991
+ '',
1992
+ '### Counts',
1993
+ '',
1994
+ '- **' + slugs().length + '** connections',
1995
+ '- **' + slots().length + '** hook slots',
1996
+ '- **' + Object.keys( STEPS ).length + '** step types',
1997
+ '- **' + Object.keys( TRIGGERS ).length + '** triggers',
1998
+ '',
1999
+ MARKER.end
2000
+ ].join( '\n' );
2001
+
2002
+ // Splice the generated block into a document, replacing whatever is between the
2003
+ // markers. Returns the document unchanged when it carries no markers, so a
2004
+ // caller can tell a doc that opted in from one that did not.
2005
+ const spliced = ( document ) => {
2006
+
2007
+ const from = document.indexOf( MARKER.start );
2008
+ const to = document.indexOf( MARKER.end );
2009
+
2010
+ if( from < 0 || to < 0 ) return document;
2011
+
2012
+ return document.slice( 0, from ) + reference() + document.slice( to + MARKER.end.length );
2013
+
2014
+ };
2015
+
2016
+ const MARKERS = MARKER;
2017
+
1364
2018
  // A LIVE ACCESS TOKEN, for any vendor that issues an expiring one.
1365
2019
  //
1366
2020
  // Written once here rather than per vendor, because every OAuth connection ends
@@ -1895,9 +2549,18 @@ var scaffold = {
1895
2549
  commerce : {
1896
2550
  // NEW in phase 1 — the Checkout Kit cart path, which writes nothing and is
1897
2551
  // the last api surface Shopify owns outside a hook.
1898
- cart : async ( { connection, context }, { read } = {} ) => ANSWER,
2552
+ // NO `connection` AND NO `step`: this one runs for a SHOPPER on a public
2553
+ // page, not for an organization inside a workflow. It reads the storefront
2554
+ // with the store's storefront token and mints a checkout token from
2555
+ // DRAWBRIDGE'S app credentials, which is why the credential pair rides in
2556
+ // as props — the way auth.disconnect already carries it.
2557
+ cart : async ( { clientId, clientSecret, context, settings }, { fetcher, shopify } = {} ) => ANSWER,
1899
2558
  code : async ( { connection, context, step }, { adminToken, shopify } = {} ) => ANSWER,
1900
2559
  customer : async ( { connection, context }, { adminToken, read, shopify } = {} ) => ANSWER,
2560
+ // STOCK, READ AT RENDER TIME rather than stored. It is the one product
2561
+ // fact that is stale the instant it is written down, so the campaign
2562
+ // product list asks the vendor and caches the answer for a minute.
2563
+ inventory : async ( { connection, context }, { adminToken, shopify } = {} ) => ANSWER,
1901
2564
  order : async ( { connection, context }, { logger, mintId, read } = {} ) => ANSWER,
1902
2565
  // THIS IS WHERE `product.insert` LANDS.
1903
2566
  //
@@ -1919,7 +2582,7 @@ var scaffold = {
1919
2582
  // nothing and shows up only as `workflow.dispatch.candidates matched : 0` in
1920
2583
  // a log. Phase 1 closes it and build() checks every trigger against it.
1921
2584
  product : {
1922
- hook : async ( { connection, context, workflow }, { mintId, read } = {} ) => ANSWER,
2585
+ hook : async ( { connection, context, settings, workflow }, { adminToken, mintId, read, shopify } = {} ) => ANSWER,
1923
2586
  step : () => ({
1924
2587
  description : 'Syncs product data from the vendor when a product changes.',
1925
2588
  key : 'Vendor Product Sync',
@@ -2067,8 +2730,19 @@ var scaffold = {
2067
2730
  // Vendor billing, the only group that is not generic. Pairs with
2068
2731
  // auth.install.billing, which declares what the merchant is being charged.
2069
2732
  plan : {
2070
- cancel : async ( { context }, { dispatch, read } = {} ) => ANSWER,
2071
- reselect : async ( { context }, { dispatch, read } = {} ) => ANSWER
2733
+ cancel : async ( { context }, { read, shopify } = {} ) => ANSWER,
2734
+ // NOT A MERCHANT ACTION EITHER one attributed order's usage charge,
2735
+ // sent to the vendor's meter. The credential pair rides in as props
2736
+ // because the charge authenticates as the APP, not as the store.
2737
+ charge : async ( { clientId, clientSecret, context, manifest }, { shopify } = {} ) => ANSWER,
2738
+ // `mark` is the one write a description cannot carry: the marker must
2739
+ // be persisted BEFORE the approval is released, and a described write
2740
+ // lands after the hook returns.
2741
+ reselect : async ( { context }, { mark, read, shopify } = {} ) => ANSWER,
2742
+ // NOT A MERCHANT ACTION — the question "is this store's billing
2743
+ // actually metered right now", asked by the daily health check and by
2744
+ // the vendor's own approval webhook, which used to hand-copy it.
2745
+ verify : async ( { context }, { adminToken, read, shopify } = {} ) => ANSWER
2072
2746
  },
2073
2747
  // The embedded app's own session, stored by us on the vendor's behalf.
2074
2748
  session : {
@@ -2344,7 +3018,7 @@ var scaffold = {
2344
3018
  // A .js wrapper around otherwise untouched SVG so `node --test` can run against
2345
3019
  // lib/ directly. A bare .svg import would need a bundler loader and force the
2346
3020
  // tests through dist.
2347
- var icon$7 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
3021
+ var icon$8 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
2348
3022
  <rect width="500" height="500" fill="#FFD967"/>
2349
3023
  <path d="M239.369 127.404C260.863 122.013 284.964 132.569 295.576 152.039C321.796 201.286 347.928 250.567 374.131 299.825C380.149 311.989 381.244 326.564 376.733 339.41C371.446 355.097 358.039 367.69 342.107 372.089C326.274 376.67 308.351 372.871 295.741 362.255C288.865 356.892 284.161 349.359 280.354 341.629C261.869 306.95 243.478 272.217 225.071 237.501C214.409 218.614 187.221 212.656 169.622 225.315C168.104 227.309 164.159 226.857 164.72 223.796C176.911 202.569 189.361 181.497 201.662 160.331C205.183 154.428 208.214 148.129 212.967 143.091C219.844 135.272 229.263 129.782 239.374 127.404" fill="#1E1C1C"/>
2350
3024
  <path d="M166.04 261.805C180.228 259.107 195.528 261.893 207.581 269.971C218.512 277.079 226.908 288.136 230.604 300.657C234.835 314.103 233.614 329.124 227.485 341.788C220.097 356.875 205.782 368.543 189.317 372.089C173.957 375.652 157.089 372.386 144.304 363.103C132.971 355.295 124.912 342.989 121.891 329.581C118.943 316.636 120.725 302.656 127.002 290.938C134.699 275.951 149.503 264.933 166.046 261.811" fill="#1E1C1C"/>
@@ -2711,16 +3385,19 @@ var attentive = {
2711
3385
  'Anyone who has opted out in Drawbridge is sent to Attentive as an unsubscribe rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.',
2712
3386
  'Attentive accepts these updates and applies them in the background, so a contact appears in your segment shortly after the sync rather than the instant it runs.'
2713
3387
  ],
2714
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
2715
- // The dashboard already carries generic reasons for every way an
2716
- // authorization can fail — declined, expired state, a vendor we are not set
2717
- // up for — and a sentence here is an ADDITION to those, for a vendor whose
2718
- // failures need saying differently. This one's do not.
3388
+ // WHAT THE MERCHANT READS WHEN A CONNECT FAILS. Two states, because the
3389
+ // consent screen has two ways to end: they said no, or something between
3390
+ // here and the vendor went wrong.
2719
3391
  //
2720
- // Answered rather than omitted, because `errors` existed on two of six
2721
- // manifests and there was no way to tell "nothing to add" from "nobody
2722
- // wrote it".
2723
- errors : false,
3392
+ // THE VENDOR IS NAMED IN BOTH. The dashboard's generic copy says "the
3393
+ // authorization"; a merchant connecting two accounts in one sitting needs
3394
+ // to know which one this was.
3395
+ errors : {
3396
+ connect : {
3397
+ denied : 'The Attentive authorization was declined, so nothing was connected.',
3398
+ invalid : 'We couldn\'t complete the Attentive connection. Try connecting again.'
3399
+ }
3400
+ },
2724
3401
  excerpt : 'Sync your Drawbridge contacts into an Attentive segment.',
2725
3402
  // NAMES THE SEGMENT STEP, because `status` below gates on it: a grant with
2726
3403
  // no segment chosen sits at Pending, and a guide that stops at the consent
@@ -3174,7 +3851,7 @@ var attentive = {
3174
3851
  webhook : false
3175
3852
 
3176
3853
  },
3177
- icon: icon$7,
3854
+ icon: icon$8,
3178
3855
  // DERIVED from the vendors this connection spends — every `credential` their
3179
3856
  // files declare. It used to be typed out here as well, which made the same env
3180
3857
  // names a three-copy fact (here, auth.oauth.client, and the vendor field).
@@ -3793,7 +4470,7 @@ const contacts = {
3793
4470
  // A .js wrapper around otherwise untouched SVG so `node --test` can run against
3794
4471
  // lib/ directly. A bare .svg import would need a bundler loader and force the
3795
4472
  // tests onto dist/, which is a worse trade than one line of wrapper.
3796
- var icon$6 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
4473
+ var icon$7 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
3797
4474
  <rect width="500" height="500" fill="#0D1314"/>
3798
4475
  <g clip-path="url(#clip0_2115_2832)">
3799
4476
  <path d="M153.198 142.241L183.69 196.065V303.852L153 357.676L184.82 375.028L220.116 313.163V186.865L185.046 125L153.198 142.241Z" fill="#BAEC5F"/>
@@ -3814,7 +4491,7 @@ var icon$6 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
3814
4491
  // A .js wrapper around otherwise untouched SVG so `node --test` can run against
3815
4492
  // lib/ directly. A bare .svg import would need a bundler loader and force the
3816
4493
  // tests onto dist/, which is a worse trade than one line of wrapper.
3817
- var icon$5 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
4494
+ var icon$6 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
3818
4495
  <rect width="500" height="500" fill="#FF7A59"/>
3819
4496
  <circle cx="290" cy="290" r="78" stroke="white" stroke-width="30"/>
3820
4497
  <circle cx="290" cy="290" r="18" fill="white"/>
@@ -3832,7 +4509,7 @@ var icon$5 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
3832
4509
  // A .js wrapper around otherwise untouched SVG so `node --test` can run against
3833
4510
  // lib/ directly. A bare .svg import would need a bundler loader and force the
3834
4511
  // tests onto dist/, which is a worse trade than one line of wrapper.
3835
- var icon$4 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
4512
+ var icon$5 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
3836
4513
  <rect width="500" height="500" fill="#F1FAFF"/>
3837
4514
  <g clip-path="url(#clip0_1011_2743)">
3838
4515
  <path d="M366.676 134.534V289.172H289.203V366.487H134.258L134.258 289.171L134.256 289.172V211.851H211.729V134.534H366.676Z" fill="#9DD6E3"/>
@@ -3855,7 +4532,7 @@ var icon$4 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
3855
4532
  // A .js wrapper around otherwise untouched SVG so `node --test` can run against
3856
4533
  // lib/ directly. A bare .svg import would need a bundler loader and force the
3857
4534
  // tests onto dist/, which is a worse trade than one line of wrapper.
3858
- var icon$3 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
4535
+ var icon$4 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
3859
4536
  <rect width="500" height="500" fill="#F22F46"/>
3860
4537
  <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"/>
3861
4538
  </svg>`;
@@ -4255,15 +4932,12 @@ var drawbridge = {
4255
4932
  description : [
4256
4933
  'Drawbridge sends your notification emails and your entrants\' emails, and recalculates segment membership on a schedule. It is always on and needs nothing connected.'
4257
4934
  ],
4258
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
4259
- // The dashboard already carries generic reasons for every way an
4260
- // authorization can fail declined, expired state, a vendor we are not set
4261
- // up for — and a sentence here is an ADDITION to those, for a vendor whose
4262
- // failures need saying differently. This one's do not.
4935
+ // NOTHING IS EVER CONNECTED, so no connect can fail. This is the platform's
4936
+ // own manifest always on, private, never in the catalogue — and it exists
4937
+ // to give Drawbridge's own steps a hook to point at. `denied` and `invalid`
4938
+ // describe a consent screen this connection does not have.
4263
4939
  //
4264
- // Answered rather than omitted, because `errors` existed on two of six
4265
- // manifests and there was no way to tell "nothing to add" from "nobody
4266
- // wrote it".
4940
+ // `false` is the recorded decision, not silence.
4267
4941
  errors : false,
4268
4942
  excerpt : 'The steps Drawbridge runs itself.',
4269
4943
  guide : [
@@ -5003,7 +5677,7 @@ var drawbridge = {
5003
5677
  },
5004
5678
  webhook : false
5005
5679
  },
5006
- icon: icon$6,
5680
+ icon: icon$7,
5007
5681
 
5008
5682
  // WHERE TWILIO PUTS THINGS on an inbound request, and WHICH credential
5009
5683
  // verifies each channel. The route resolves the NAME to the stored value and
@@ -5260,7 +5934,7 @@ var drawbridge = {
5260
5934
  // A .js wrapper around otherwise untouched SVG so `node --test` can run against
5261
5935
  // lib/ directly. A bare .svg import would need a bundler loader and force the
5262
5936
  // tests onto dist/, which is a worse trade than one line of wrapper.
5263
- var icon$2 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
5937
+ var icon$3 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
5264
5938
  <rect width="500" height="500" fill="#FF4B32"/>
5265
5939
  <path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
5266
5940
  </svg>`;
@@ -6242,7 +6916,7 @@ var klaviyo = {
6242
6916
  webhook : false
6243
6917
 
6244
6918
  },
6245
- icon: icon$2,
6919
+ icon: icon$3,
6246
6920
  // DERIVED from the vendors this connection spends — every `credential` their
6247
6921
  // files declare. It used to be typed out here as well, which made the same env
6248
6922
  // names a three-copy fact (here, auth.oauth.client, and the vendor field).
@@ -6401,7 +7075,7 @@ var klaviyo = {
6401
7075
  // A .js wrapper around otherwise untouched SVG so `node --test` can run against
6402
7076
  // lib/ directly. A bare .svg import would need a bundler loader and force the
6403
7077
  // tests onto dist/, which is a worse trade than one line of wrapper.
6404
- var icon$1 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
7078
+ var icon$2 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
6405
7079
  <rect width="500" height="500" fill="#FFE01B"/>
6406
7080
  <path d="M310.633 243.561C312.49 243.336 314.249 243.308 315.882 243.561C316.81 241.394 316.979 237.665 316.149 233.598C314.882 227.561 313.18 223.917 309.662 224.508C306.13 225.071 305.989 229.433 307.269 235.47C307.973 238.847 309.24 241.746 310.633 243.561ZM280.392 248.332C282.925 249.429 284.473 250.147 285.05 249.542C285.458 249.148 285.345 248.36 284.74 247.375C283.112 245.042 280.844 243.229 278.211 242.154C275.397 240.982 272.328 240.556 269.301 240.916C266.274 241.275 263.391 242.41 260.93 244.209C259.256 245.447 257.666 247.15 257.863 248.191C257.961 248.515 258.186 248.782 258.777 248.895C260.17 249.049 265.025 246.587 270.64 246.249C274.608 245.968 277.859 247.206 280.392 248.332ZM275.312 251.216C272.047 251.737 270.232 252.807 269.078 253.848C268.079 254.72 267.488 255.649 267.488 256.339L267.741 256.93L268.262 257.127C269.008 257.127 270.668 256.479 270.668 256.479C275.228 254.861 278.253 255.044 281.25 255.382C282.897 255.579 283.671 255.663 284.037 255.1C284.135 254.931 284.29 254.608 283.938 254.059C283.15 252.778 279.843 250.682 275.312 251.216ZM300.416 261.855C302.654 262.953 305.102 262.502 305.904 260.884C306.735 259.266 305.567 257.056 303.329 255.973C301.106 254.861 298.643 255.283 297.841 256.902C297.039 258.52 298.207 260.757 300.416 261.855ZM314.742 249.317C312.94 249.289 311.449 251.259 311.393 253.764C311.35 256.254 312.8 258.281 314.615 258.295C316.43 258.323 317.922 256.339 317.964 253.862C318.006 251.385 316.571 249.359 314.742 249.317ZM193.103 294.108C192.653 293.545 191.907 293.7 191.189 293.897C190.683 293.995 190.12 294.136 189.515 294.122C188.909 294.134 188.308 293.998 187.767 293.726C187.225 293.454 186.757 293.054 186.405 292.56C185.575 291.294 185.617 289.394 186.546 287.241L186.968 286.27C188.431 283.019 190.838 277.559 188.122 272.367C187.234 270.534 185.908 268.948 184.261 267.75C182.614 266.553 180.697 265.78 178.679 265.5C176.772 265.256 174.835 265.469 173.026 266.123C171.218 266.776 169.591 267.85 168.28 269.257C164.284 273.661 163.679 279.684 164.439 281.823C164.734 282.611 165.184 282.822 165.494 282.864C166.169 282.963 167.169 282.456 167.802 280.754L167.999 280.219C168.28 279.318 168.801 277.63 169.659 276.307C170.717 274.689 172.375 273.558 174.267 273.162C176.159 272.766 178.131 273.138 179.749 274.196C182.563 276.039 183.619 279.473 182.423 282.752C181.803 284.455 180.804 287.691 181.015 290.351C181.466 295.74 184.815 297.907 187.77 298.175C190.669 298.273 192.695 296.655 193.216 295.459C193.511 294.713 193.258 294.277 193.103 294.108Z" fill="#231E15"/>
6407
7081
  <path d="M360.476 284.243C360.35 283.835 359.618 281.204 358.647 278.052L356.621 272.648C360.575 266.696 360.645 261.405 360.124 258.393C359.531 254.519 357.693 250.944 354.89 248.205C351.766 244.94 345.363 241.563 336.385 239.044L331.671 237.736C331.643 237.524 331.418 226.619 331.235 221.933C331.08 218.555 330.798 213.264 329.152 208.058C327.182 200.993 323.791 194.858 319.527 190.876C331.277 178.717 338.594 165.307 338.58 153.81C338.538 131.703 311.379 124.976 277.902 138.851L270.824 141.863C270.795 141.835 258.004 129.283 257.821 129.128C219.63 95.8334 100.327 228.476 138.49 260.687L146.835 267.737C144.581 273.775 143.781 280.259 144.499 286.664C145.414 295.543 149.973 304.029 157.375 310.6C164.411 316.82 173.684 320.788 182.648 320.774C197.494 354.998 231.408 375.965 271.175 377.161C313.842 378.427 349.641 358.403 364.67 322.435C365.641 319.916 369.806 308.546 369.806 298.513C369.792 288.409 364.093 284.229 360.476 284.243ZM185.913 311.149C184.613 311.381 183.293 311.48 181.973 311.445C169.083 311.079 155.166 299.483 153.787 285.735C152.253 270.537 160.02 258.829 173.783 256.071C175.415 255.72 177.414 255.537 179.552 255.635C187.264 256.085 198.606 261.996 201.209 278.784C203.517 293.63 199.858 308.785 185.913 311.149ZM171.545 246.953C163.179 248.499 155.744 253.244 150.817 260.18C148.045 257.873 142.909 253.426 142.008 251.681C134.635 237.693 150.043 210.478 160.823 195.111C187.405 157.145 229.086 128.424 248.393 133.603C251.517 134.503 261.902 146.563 261.902 146.563C261.902 146.563 242.623 157.244 224.724 172.16C200.646 190.735 182.423 217.697 171.545 246.953ZM306.792 305.464C306.937 305.403 307.057 305.295 307.134 305.157C307.211 305.019 307.239 304.86 307.214 304.704C307.205 304.61 307.178 304.519 307.133 304.436C307.088 304.353 307.027 304.28 306.954 304.221C306.88 304.162 306.796 304.118 306.705 304.092C306.614 304.066 306.519 304.059 306.426 304.071C306.426 304.071 286.246 307.054 267.179 300.089C269.247 293.348 274.792 295.754 283.137 296.444C296.108 297.209 309.116 295.802 321.623 292.279C330.25 289.788 341.592 284.905 350.401 277.953C353.384 284.497 354.425 291.674 354.425 291.674C354.425 291.674 356.719 291.265 358.647 292.447C360.476 293.573 361.799 295.895 360.898 301.89C359.027 313.119 354.271 322.224 346.235 330.611C341.236 336.036 335.277 340.492 328.659 343.754C324.983 345.691 321.151 347.32 317.205 348.623C286.964 358.487 256.006 347.638 246.029 324.321C245.224 322.535 244.556 320.691 244.03 318.804C239.781 303.438 243.383 285.032 254.655 273.408C255.372 272.676 256.09 271.804 256.09 270.706C256.09 269.806 255.499 268.835 255.007 268.131C251.066 262.418 237.374 252.666 240.132 233.795C242.088 220.23 253.951 210.689 265.012 211.252L267.826 211.421C272.611 211.702 276.79 212.307 280.73 212.49C287.344 212.758 293.268 211.801 300.304 205.947C302.683 203.949 304.582 202.246 307.791 201.711C308.128 201.627 308.973 201.359 310.647 201.416C312.365 201.485 314.032 202.015 315.474 202.949C321.103 206.693 321.905 215.783 322.214 222.439C322.383 226.225 322.848 235.414 322.988 238.031C323.354 244.054 324.944 244.912 328.125 245.954C329.94 246.573 331.615 246.995 334.077 247.713C341.521 249.781 345.968 251.934 348.754 254.65C350.198 256.049 351.13 257.893 351.4 259.885C352.315 266.316 346.432 274.252 330.925 281.457C313.954 289.324 293.367 291.322 279.154 289.732L274.173 289.169C262.774 287.649 256.315 302.34 263.14 312.402C267.545 318.889 279.52 323.11 291.523 323.11C319.006 323.139 340.142 311.402 348.023 301.242L348.642 300.356C349.008 299.765 348.712 299.469 348.22 299.779C341.817 304.169 313.279 321.619 282.771 316.384C282.771 316.384 279.056 315.765 275.678 314.442C273.005 313.429 267.362 310.811 266.686 305.042C291.27 312.683 306.792 305.478 306.792 305.464ZM220.671 194.971C230.127 184.051 241.765 174.538 252.206 169.219C252.558 169.022 252.938 169.43 252.741 169.754C251.46 172 250.476 174.403 249.814 176.902C249.73 177.282 250.138 177.592 250.461 177.353C256.963 172.934 268.248 168.192 278.155 167.601C278.251 167.584 278.351 167.601 278.436 167.65C278.521 167.698 278.586 167.775 278.621 167.866C278.656 167.957 278.658 168.058 278.627 168.151C278.596 168.244 278.533 168.323 278.451 168.375C276.809 169.634 275.342 171.105 274.088 172.751C273.891 173.032 274.074 173.44 274.426 173.44C281.378 173.483 291.186 175.903 297.56 179.491C297.982 179.745 297.673 180.575 297.209 180.462C287.527 178.253 271.724 176.564 255.288 180.575C240.597 184.149 229.396 189.666 221.248 195.618C220.826 195.899 220.333 195.351 220.671 194.971Z" fill="#231E15"/>
@@ -6607,16 +7281,19 @@ var mailchimp = {
6607
7281
  'Anyone who has opted out in Drawbridge is synced as unsubscribed rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.',
6608
7282
  'Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before.'
6609
7283
  ],
6610
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
6611
- // The dashboard already carries generic reasons for every way an
6612
- // authorization can fail — declined, expired state, a vendor we are not set
6613
- // up for — and a sentence here is an ADDITION to those, for a vendor whose
6614
- // failures need saying differently. This one's do not.
7284
+ // WHAT THE MERCHANT READS WHEN A CONNECT FAILS. Two states, because the
7285
+ // consent screen has two ways to end: they said no, or something between
7286
+ // here and the vendor went wrong.
6615
7287
  //
6616
- // Answered rather than omitted, because `errors` existed on two of six
6617
- // manifests and there was no way to tell "nothing to add" from "nobody
6618
- // wrote it".
6619
- errors : false,
7288
+ // THE VENDOR IS NAMED IN BOTH. The dashboard's generic copy says "the
7289
+ // authorization"; a merchant connecting two accounts in one sitting needs
7290
+ // to know which one this was.
7291
+ errors : {
7292
+ connect : {
7293
+ denied : 'The Mailchimp authorization was declined, so nothing was connected.',
7294
+ invalid : 'We couldn\'t complete the Mailchimp connection. Try connecting again.'
7295
+ }
7296
+ },
6620
7297
  excerpt : 'Sync your Drawbridge contacts into a Mailchimp audience.',
6621
7298
  // NAMES THE AUDIENCE STEP, because `status` below gates on it: a grant with
6622
7299
  // no audience chosen sits at Pending, and a guide that stops at the consent
@@ -7268,7 +7945,7 @@ var mailchimp = {
7268
7945
  // Drawbridge posts to a merchant's own endpoint, never through a vendor.
7269
7946
  webhook : false
7270
7947
  },
7271
- icon: icon$1,
7948
+ icon: icon$2,
7272
7949
  // The OAuth client this deployment registered. Without both, the vendor drops
7273
7950
  // out of availableConnections rather than offering a Connect button that
7274
7951
  // cannot complete.
@@ -7428,12 +8105,34 @@ var mailchimp = {
7428
8105
  // A .js wrapper around otherwise untouched SVG so `node --test` can run against
7429
8106
  // lib/ directly. A bare .svg import would need a bundler loader and force the
7430
8107
  // tests onto dist/, which is a worse trade than one line of wrapper.
7431
- var icon = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
8108
+ var icon$1 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
7432
8109
  <rect width="500" height="500" fill="#95C049"/>
7433
8110
  <path d="M292.997 147.633C292.997 147.633 289.98 148.495 285.023 150.004C284.161 147.202 282.868 143.969 281.144 140.521C275.54 129.744 267.134 123.925 257.22 123.925C256.574 123.925 255.927 123.925 255.065 124.141C254.849 123.71 254.418 123.494 254.203 123.063C249.892 118.322 244.289 116.166 237.607 116.382C224.676 116.813 211.744 126.081 201.399 142.676C194.071 154.314 188.468 168.97 186.959 180.393C172.088 184.919 161.743 188.152 161.527 188.367C153.984 190.738 153.768 190.954 152.906 198.066C151.613 203.454 132 355.4 132 355.4L294.937 383.633V147.202C294.075 147.418 293.429 147.418 292.997 147.633ZM255.281 159.271C246.66 161.858 237.176 164.875 227.909 167.677C230.495 157.547 235.668 147.418 241.702 140.736C244.073 138.365 247.306 135.564 250.97 133.839C254.634 141.598 255.496 152.159 255.281 159.271ZM237.823 125.003C240.84 125.003 243.427 125.649 245.582 126.943C242.133 128.667 238.685 131.469 235.452 134.702C227.262 143.538 221.012 157.332 218.426 170.479C210.667 172.85 202.908 175.22 195.796 177.376C200.322 156.901 217.779 125.649 237.823 125.003ZM212.607 243.542C213.469 257.335 249.892 260.353 252.048 292.897C253.556 318.545 238.47 336.002 216.701 337.295C190.407 339.02 175.967 323.502 175.967 323.502L181.571 299.794C181.571 299.794 196.011 310.786 207.649 309.924C215.193 309.493 217.995 303.242 217.779 298.932C216.701 280.828 186.959 281.905 185.019 252.163C183.295 227.377 199.675 202.161 235.883 199.79C249.892 198.928 257.005 202.377 257.005 202.377L248.815 233.412C248.815 233.412 239.547 229.102 228.555 229.964C212.607 231.041 212.391 241.171 212.607 243.542ZM263.902 156.685C263.902 150.219 263.039 140.952 260.022 133.193C269.936 135.133 274.678 146.124 276.833 152.806C272.954 153.883 268.643 155.176 263.902 156.685Z" fill="white"/>
7434
8111
  <path d="M300.325 382.771L368 365.96C368 365.96 338.904 169.185 338.689 167.892C338.473 166.599 337.396 165.737 336.318 165.737C335.24 165.737 316.274 165.306 316.274 165.306C316.274 165.306 304.636 154.099 300.325 149.788V382.771Z" fill="white"/>
7435
8112
  </svg>`;
7436
8113
 
8114
+ // THE CLIENT, IMPORTED AND DEFAULTED — the same shape as `fetcher = fetch`
8115
+ // everywhere else in this package.
8116
+ //
8117
+ // It used to be injected because @drawbridge/shopify depended on this package,
8118
+ // so this file could not import it. That cycle is gone: the client lives in
8119
+ // lib/shopify/ now. But the injection had a SECOND job nobody wrote down — it is
8120
+ // the test seam, and it is at the right level. The hook tests stub
8121
+ // getActiveAppSubscriptions and getUsageChargeEvents and assert the arguments
8122
+ // they were called with; stubbing raw HTTP instead would mean hand-building
8123
+ // Shopify GraphQL envelopes and would lose those assertions entirely.
8124
+ //
8125
+ // So the default is the real client and callers inject nothing. Production
8126
+ // passes no `shopify` option at all — drawbridge-api and drawbridge-sync have
8127
+ // stopped wiring one — and a test overrides exactly the namespace it needs.
8128
+ const client = Object.freeze({
8129
+ admin : shopifyAdmin,
8130
+ billing : shopifyBilling,
8131
+ oauth : shopifyOauth,
8132
+ partner : shopifyPartner,
8133
+ storefront : shopifyStorefront
8134
+ });
8135
+
7437
8136
  // ── ORDER ATTRIBUTION, the vendor's own arithmetic ───────────────────────────
7438
8137
  //
7439
8138
  // Orders are attributed via `_drwbrdg_*` line-item properties injected at
@@ -7553,7 +8252,7 @@ const discountWarning = ( discount ) => {
7553
8252
  // equal, case-sensitively, the meter HANDLE configured on the app's plan in
7554
8253
  // the Dev Dashboard (the plan editor's Handle field — display names prove
7555
8254
  // nothing) and the SHOPIFY_USAGE_ORDERS_EVENT_HANDLE default in
7556
- // @drawbridge/shopify, which this package cannot import. This is the single
8255
+ // the Shopify client, which lives in this package now. This is the single
7557
8256
  // mint point on the utils side: the transaction id is prefixed with it, the
7558
8257
  // billing enqueue carries it, and the manifest publishes it under
7559
8258
  // auth.install.billing.handle — so drawbridge-sync can refuse to send an event whose
@@ -7624,8 +8323,548 @@ const COMPLIANCE_TOPICS = new Set([
7624
8323
  'shop/redact'
7625
8324
  ]);
7626
8325
 
7627
- // Shopify installed from the App Store, never connected with keys.
7628
- var shopify = {
8326
+ // ── THE EMBEDDED-APP SURFACE ─────────────────────────────────────────────────
8327
+ //
8328
+ // Everything from here to the end of this block serves `hooks.install`, which
8329
+ // is what api/route/shopify-embedded.js became: 1,386 lines and thirteen routes
8330
+ // whose paths all began `/shopify/embedded/` — a vendor's name in a url, on a
8331
+ // surface no other connection could ever reach.
8332
+
8333
+ // A REFUSAL WITH A STATUS. runHook already carries `error.status` out to the
8334
+ // caller for exactly this — inbound.verify answers 401 on a forged signature the
8335
+ // same way — so the install slots say 402, 403, 409, 422 or 502 in the same
8336
+ // breath as the reason, and the route answers what they said rather than folding
8337
+ // every rejection into a 500.
8338
+ const refusal = ( message, status ) => Object.assign( new Error( message ), { status });
8339
+
8340
+ // THIRTY MINUTES for the one-shot link token. Long enough for a merchant to
8341
+ // finish a consent screen, short enough that a stranded token is not a standing
8342
+ // key to a store.
8343
+ const LINK_TOKEN_TTL_MS = 30 * 60 * 1000;
8344
+
8345
+ // The statuses the connection-write gate accepts, mirroring the dashboard's
8346
+ // own list on every organization-connection route. Read by BOTH `add` and
8347
+ // `list`, so the list cannot tell a merchant an org is connectable and then
8348
+ // have `add` refuse it.
8349
+ const CONNECTABLE_STATUSES = [ 'active', 'unsubscribed' ];
8350
+
8351
+ // THE MERCHANT'S OWN ORGANIZATIONS — owned, or joined with the `connections`
8352
+ // capability at `manage`. Shared by the list and the two org slots that gate on
8353
+ // access, and each org carries its Shopify connection ({ id, shop, status }) so
8354
+ // the one-store-per-organization rule is answered in the same round trip.
8355
+ const merchantOrganizations = ({ read, user }) => read.aggregate({
8356
+ collection : 'organization',
8357
+ pipeline : [
8358
+ // A member qualifies only at `manage`; an owner always qualifies.
8359
+ {
8360
+ $lookup : {
8361
+ as : 'membership',
8362
+ from : 'member',
8363
+ let : { organization : '$id' },
8364
+ pipeline : [
8365
+ {
8366
+ $match : {
8367
+ $expr : { $eq : [ '$organization', '$$organization' ] },
8368
+ 'capabilities.connections' : 'manage',
8369
+ status : 'accepted',
8370
+ user
8371
+ }
8372
+ },
8373
+ { $limit : 1 },
8374
+ { $project : { _id : 0, organization : 1 } }
8375
+ ]
8376
+ }
8377
+ },
8378
+ {
8379
+ $match : {
8380
+ status : { $ne : 'canceled' },
8381
+ $or : [ { owner : user }, { 'membership.0' : { $exists : true } } ]
8382
+ }
8383
+ },
8384
+ // `organization.subscription` is a subscription RECORD ID — resolved here so
8385
+ // the list can read the real plan and conversion rate. Without it every org
8386
+ // rendered as free tier.
8387
+ { $lookup : { as : 'subscription', foreignField : 'id', from : 'subscription', localField : 'subscription' } },
8388
+ { $unwind : { path : '$subscription', preserveNullAndEmptyArrays : true } },
8389
+ {
8390
+ $lookup : {
8391
+ as : 'connection',
8392
+ foreignField : 'organization',
8393
+ from : 'connection',
8394
+ localField : 'id',
8395
+ pipeline : [
8396
+ { $match : { slug : 'shopify' } },
8397
+ { $limit : 1 },
8398
+ { $project : { _id : 0, id : 1, shop : 1, status : 1 } }
8399
+ ]
8400
+ }
8401
+ },
8402
+ { $unwind : { path : '$connection', preserveNullAndEmptyArrays : true } },
8403
+ { $project : { _id : 0, billingStatus : '$status', connection : 1, id : 1, name : '$title', subscription : 1 } },
8404
+ { $sort : { name : 1 } }
8405
+ ]
8406
+ });
8407
+
8408
+ // THE STORE'S LIVE APP PRICING STATUS. getActiveAppSubscriptions returns only
8409
+ // ACTIVE subscriptions, so a non-empty list means the merchant approved a plan.
8410
+ //
8411
+ // BEST-EFFORT, AND THE FAILURE IS NAMED. "No plan" and "couldn't check" are
8412
+ // different answers — `add` returns a retryable 502 for the latter rather than
8413
+ // telling a paying merchant to approve a plan.
8414
+ const storePlan = async ({ record, shopify }) => {
8415
+
8416
+ const settings = record?.settings ? decrypt( record.settings ) : null;
8417
+
8418
+ if( ! settings?.accessToken ) return { active : false, name : null };
8419
+
8420
+ try {
8421
+
8422
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
8423
+ adminAccessToken : settings.accessToken,
8424
+ domain : record.shop
8425
+ });
8426
+
8427
+ const active = ( subscriptions || [] ).length > 0;
8428
+
8429
+ // A USAGE LINE ON THE APPROVAL IS POSITIVE EVIDENCE (metered true); its
8430
+ // ABSENCE IS NO VERDICT (null). Measured 2026-09-04 on both 2026-04 and
8431
+ // unstable: managed-pricing App-Events meters never materialize as
8432
+ // subscription line items, so absence must not drive the App Home banner.
8433
+ // `false` is reserved for a signal that can actually observe the meter —
8434
+ // Partner-ledger accrual, which sync stamps onto the connection.
8435
+ const metered = active && subscriptions.some( ( subscription ) => subscription.metered === true ) ? true : null;
8436
+
8437
+ return { active, metered, name : active ? subscriptions[ 0 ]?.name || null : null };
8438
+
8439
+ } catch ( _ ) {
8440
+
8441
+ return { active : false, name : null, unavailable : true };
8442
+
8443
+ }
8444
+
8445
+ };
8446
+
8447
+ // RELEASE EVERY ACTIVE APPROVAL for this app on this store. Shared by `cancel`
8448
+ // and `reselect`, which differ only in what happens before and what it means —
8449
+ // the mechanics are identical, and two copies of a billing mutation is how they
8450
+ // come to disagree.
8451
+ const releaseApprovals = async ({ context, read, shopify }) => {
8452
+
8453
+ const shop = sanitizeDomain( context?.shop );
8454
+
8455
+ if( ! shop ) throw refusal( 'shop is required', 400 );
8456
+
8457
+ const record = await read.get({ collection : 'shop', query : { shop } });
8458
+
8459
+ const settings = record?.settings ? decrypt( record.settings ) : null;
8460
+
8461
+ if( ! settings?.accessToken ) throw refusal( 'Store is not installed', 409 );
8462
+
8463
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
8464
+ adminAccessToken : settings.accessToken,
8465
+ domain : shop
8466
+ });
8467
+
8468
+ for( const subscription of ( subscriptions || [] ) ){
8469
+
8470
+ const data = await shopify.admin.adminFetch({
8471
+ adminAccessToken : settings.accessToken,
8472
+ domain : shop,
8473
+ query : 'mutation appSubscriptionCancel( $id : ID! ) { appSubscriptionCancel( id : $id ) { appSubscription { id status } userErrors { field message } } }',
8474
+ variables : { id : subscription.id }
8475
+ });
8476
+
8477
+ const [ userError ] = data?.appSubscriptionCancel?.userErrors || [];
8478
+
8479
+ // SHOPIFY'S OWN WORDS, not ours. A cancel Shopify refused is a merchant-
8480
+ // actionable fact and 422 is the honest code for it.
8481
+ if( userError ) throw refusal( userError.message, 422 );
8482
+
8483
+ }
8484
+
8485
+ return {
8486
+ message : 'Released ' + ( subscriptions || [] ).length + ' approval(s).',
8487
+ request : { shop },
8488
+ result : { ok : true }
8489
+ };
8490
+
8491
+ };
8492
+
8493
+ // ── THE METER VERDICT, ASKED ONCE ────────────────────────────────────────────
8494
+ //
8495
+ // IS THIS STORE'S ORDER BILLING ACTUALLY WORKING? Two signals, and the rule for
8496
+ // reading them is the subtle part — subtle enough that drawbridge-sync's
8497
+ // approval webhook hand-copied it, said so in its own comment twice, and copied
8498
+ // it slightly wrong. One copy now, here, beside the vendor it is about.
8499
+ //
8500
+ // A USAGE LINE on the approval is POSITIVE evidence and gets stamped. Its
8501
+ // ABSENCE PROVES NOTHING: measured 2026-09-04, on both 2026-04 and unstable
8502
+ // and against a plan whose meter exists, managed-pricing App-Events meters
8503
+ // never materialize as subscription line items — AppUsagePricing is the
8504
+ // legacy usage-charge model. Reading absence as "unmetered" flagged healthy
8505
+ // stores, so absence is NO VERDICT: the stamp, the Issue, the card and the
8506
+ // meter-missing pager all stay silent.
8507
+ //
8508
+ // PARTNER-LEDGER ACCRUAL is the real signal. A CHARGE_USAGE event for this
8509
+ // shop is proof billing works, and its charge id becomes the stamp — it
8510
+ // overrides the line-item probe, which managed pricing keeps blind. A usage
8511
+ // ROW exists per subscription even before anything accrues (measured: a
8512
+ // pre-meter approval shows qty 0), so the row is not the proof; a POSITIVE
8513
+ // usageQuantity is, and the LATEST positive row is the stamp.
8514
+ //
8515
+ // `undefined` means no verdict this run — never `null`, which is the health
8516
+ // check's word for "probed, and nothing is accruing".
8517
+ //
8518
+ // The boundaries come back with it because each charge row is also a billing
8519
+ // BOUNDARY the health check reconciles the books against, and re-reading the
8520
+ // ledger to get them would be a second call for data this one already holds.
8521
+ const LEDGER_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
8522
+
8523
+ const meterVerdict = async ({ adminAccessToken, ledgerFloor, partner, shop, shopId, shopify }) => {
8524
+
8525
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({ adminAccessToken, domain : shop });
8526
+
8527
+ let metered = subscriptions.find( ( subscription ) => subscription.usageLineItemId )?.usageLineItemId;
8528
+
8529
+ // Off entirely until the provider row carries the Partner credentials.
8530
+ // `ledger` says whether it was consulted at all, because "read it and found
8531
+ // nothing accruing" and "could not read it" are different answers and only
8532
+ // the first is evidence about the store.
8533
+ if( ! ( partner?.partnerToken && partner?.partnerOrgId && partner?.partnerAppId && shopId ) ){
8534
+
8535
+ return { boundaries : [], ledger : false, metered };
8536
+
8537
+ }
8538
+
8539
+ const accruals = await shopify.partner.getUsageChargeEvents({
8540
+ appId : 'gid://shopify/App/' + partner.partnerAppId,
8541
+ first : 10,
8542
+ occurredAtMin : ledgerFloor.toISOString(),
8543
+ organizationId : partner.partnerOrgId,
8544
+ partnerToken : partner.partnerToken,
8545
+ shopId : 'gid://shopify/Shop/' + shopId
8546
+ });
8547
+
8548
+ // Oldest first: each charge row closes the window since the row before it.
8549
+ const boundaries = ( accruals || [] )
8550
+ .filter( ( event ) => event.occurredAt )
8551
+ .sort( ( a, b ) => new Date( a.occurredAt ) - new Date( b.occurredAt ) );
8552
+
8553
+ const accrued = [ ...boundaries ].reverse().find( ( event ) => Number( event.usageQuantity ) > 0 );
8554
+
8555
+ if( accrued ) metered = String( accrued.chargeId || accrued.id );
8556
+
8557
+ return { boundaries, ledger : true, metered };
8558
+
8559
+ };
8560
+
8561
+ // ── PRODUCT SYNC, the pull half ──────────────────────────────────────────────
8562
+ //
8563
+ // Everything below served drawbridge-sync's queue/product.js — 443 lines and two
8564
+ // BullMQ workers that imported three of this vendor's client modules directly.
8565
+ // It was a shadow implementation of the `commerce.product` step this manifest
8566
+ // already declared, and the step had no trigger, so the declaration and the
8567
+ // thing doing the work had never met.
8568
+
8569
+ // A Storefront variant edge as the product row stores it.
8570
+ const toVariant = ( edge ) => ({
8571
+ availableForSale : edge.node.availableForSale || false,
8572
+ currency : edge.node.price?.currencyCode || null,
8573
+ id : edge.node.id,
8574
+ image : edge.node.image?.url || null,
8575
+ price : parseFloat( edge.node.price?.amount ) || null,
8576
+ requiresShipping : edge.node.requiresShipping || false,
8577
+ sale : parseFloat( edge.node.compareAtPrice?.amount ) || null,
8578
+ title : edge.node.title !== 'Default Title' ? edge.node.title : null
8579
+ });
8580
+
8581
+ // REAL PURCHASABILITY, OVERLAID ON THE STOREFRONT'S CLAIM. availableForSale can
8582
+ // report true for stock checkout cannot actually fulfil — inventory at a
8583
+ // fulfillment-service location that cannot fulfil an online order — so a
8584
+ // throwaway cart is probed for Shopify's MERCHANDISE_OUT_OF_STOCK warnings.
8585
+ //
8586
+ // NEVER THROWS. A failed probe leaves the vendor's own answer standing, which is
8587
+ // the honest fallback: the overlay only ever removes availability it can prove
8588
+ // is wrong.
8589
+ const applyOutOfStock = async ({ domain, shopify, storefrontAccessToken, variants }) => {
8590
+
8591
+ try {
8592
+
8593
+ const outOfStock = new Set( await shopify.storefront.probeVariantsOutOfStock({
8594
+ domain,
8595
+ storefrontAccessToken,
8596
+ variantIds : variants.map( ( variant ) => variant.id )
8597
+ }) );
8598
+
8599
+ return variants.map( ( variant ) => ({
8600
+ ...variant,
8601
+ availableForSale : variant.availableForSale && ! outOfStock.has( variant.id )
8602
+ }) );
8603
+
8604
+ } catch ( _ ){
8605
+
8606
+ return variants;
8607
+
8608
+ }
8609
+
8610
+ };
8611
+
8612
+ // BEST-EFFORT PRODUCT ResourceFeedback — the required-action message on the
8613
+ // product page in the Shopify admin, which the sales-channel requirements say is
8614
+ // where a channel communicates a product's problems.
8615
+ //
8616
+ // Feedback must never fail the sync, and a failure must be LOUD. It is reported
8617
+ // through the caller's error channel rather than a warning: a warn only reaches
8618
+ // Sentry Logs, which is how a feedback path failing on every send since the
8619
+ // channel shipped stayed invisible through two rounds of QA.
8620
+ // sendProductResourceFeedback answers null rather than throwing for the expected
8621
+ // "nothing to attach it to" case, so anything that lands here is a real fault.
8622
+ const sendFeedback = async ({ adminToken, connection, messages, productId, shopify, state }) => {
8623
+
8624
+ try {
8625
+
8626
+ await shopify.admin.sendProductResourceFeedback({
8627
+ adminAccessToken : await adminToken(),
8628
+ domain : connection.shop,
8629
+ messages,
8630
+ productId,
8631
+ state
8632
+ });
8633
+
8634
+ return null;
8635
+
8636
+ } catch ( error ){
8637
+
8638
+ return error;
8639
+
8640
+ }
8641
+
8642
+ };
8643
+
8644
+ // BY PROVIDER IDENTITY WHEN THE DOC ID MISSES. The webhook branch upserts the row
8645
+ // and queues this pull in one described answer, so under a concurrent redelivery
8646
+ // the id it minted may be the one that LOST the upsert race — the row exists,
8647
+ // under the winner's id. The provider id and the shop are stable either way.
8648
+ const resolveProduct = async ({ connection, context, read }) => {
8649
+
8650
+ const byId = context?.product ? await read.get({ collection : 'product', query : { id : context.product } }) : null;
8651
+
8652
+ const row = byId || ( context?.providerId && ( context?.shop || connection?.shop )
8653
+ ? await read.get({
8654
+ collection : 'product',
8655
+ query : {
8656
+ 'provider.id' : context.providerId,
8657
+ 'provider.slug' : 'shopify',
8658
+ 'source.domain' : context.shop || connection.shop
8659
+ }
8660
+ })
8661
+ : null );
8662
+
8663
+ return row;
8664
+
8665
+ };
8666
+
8667
+ // THE MESSAGE, and its format is enforced by Shopify rather than chosen: one
8668
+ // message, at most 100 characters, leading capital, trailing period, not all
8669
+ // caps — a breach fails the whole send.
8670
+ //
8671
+ // Shopify only carries this for a product STILL published to our channel, so
8672
+ // "publish this product" is advice the reader has already followed by the time
8673
+ // they can read it. The one state where it renders is a product the channel
8674
+ // holds but the Storefront will not return: excluded from the channel's US
8675
+ // market. Drafting does not qualify — drafting removes the product from the
8676
+ // channel, Shopify refuses the send, and the embedded app's publishing section
8677
+ // is that product's surface instead. Both causes stay named, because the
8678
+ // merchant reading it cannot tell which applies.
8679
+ const UNAVAILABLE_FEEDBACK = 'Drawbridge can\'t display this product. Check it is active and available in the United States.';
8680
+
8681
+ // The Storefront's contract for a product it cannot see — not published to the
8682
+ // Drawbridge channel, or deleted. True deletions arrive on products/delete.
8683
+ const NOT_FOUND = 'Shopify product not found';
8684
+
8685
+ const syncProduct = async ({ adminToken, connection, context, read, settings, shopify }) => {
8686
+
8687
+ const row = await resolveProduct({ connection, context, read });
8688
+
8689
+ if( ! row ) return { message : 'No product row to sync.', request : { product : context?.product || null }, skipped : true };
8690
+
8691
+ const domain = connection.shop;
8692
+ const providerId = row.provider?.id;
8693
+ const request = { product : row.id, providerId : providerId || null, shop : domain };
8694
+
8695
+ let product;
8696
+
8697
+ try {
8698
+
8699
+ product = await shopify.storefront.getProduct({
8700
+ domain,
8701
+ productId : providerId,
8702
+ storefrontAccessToken : settings?.storefrontAccessToken
8703
+ });
8704
+
8705
+ } catch ( error ){
8706
+
8707
+ // SOFT STATE, NOT TEARDOWN. The row goes inactive but keeps its campaign
8708
+ // references, its ads are drafted — they cannot sell an invisible product —
8709
+ // and the merchant gets product-page feedback. A later successful sync
8710
+ // reverses all of it except the drafting, which stays for a deliberate
8711
+ // re-enable, matching the disconnect and unpublish cascades.
8712
+ if( error.message !== NOT_FOUND ) throw error;
8713
+
8714
+ const failed = await sendFeedback({
8715
+ adminToken,
8716
+ connection,
8717
+ messages : [ UNAVAILABLE_FEEDBACK ],
8718
+ productId : providerId,
8719
+ shopify,
8720
+ state : 'REQUIRES_ACTION'
8721
+ });
8722
+
8723
+ return {
8724
+ failed : failed ? [ failed.message ] : undefined,
8725
+ message : 'Storefront cannot see this product — deactivated.',
8726
+ request,
8727
+ result : { status : 'inactive' },
8728
+ writes : [
8729
+ {
8730
+ collection : 'product',
8731
+ data : { $set : { status : 'inactive' } },
8732
+ operation : 'update',
8733
+ query : { id : row.id }
8734
+ },
8735
+ {
8736
+ collection : 'advertisement',
8737
+ data : { $set : { status : 'drafted' } },
8738
+ multiple : true,
8739
+ operation : 'update',
8740
+ query : { product : row.id }
8741
+ }
8742
+ ]
8743
+ };
8744
+
8745
+ }
8746
+
8747
+ const variants = await applyOutOfStock({
8748
+ domain,
8749
+ shopify,
8750
+ storefrontAccessToken : settings?.storefrontAccessToken,
8751
+ variants : ( product?.variants?.edges || [] ).map( toVariant )
8752
+ });
8753
+
8754
+ // The product is visible to the channel again, so any prior required-action
8755
+ // feedback clears — ResourceFeedback replaces per send.
8756
+ const failed = await sendFeedback({
8757
+ adminToken,
8758
+ connection,
8759
+ messages : [],
8760
+ productId : providerId,
8761
+ shopify,
8762
+ state : 'ACCEPTED'
8763
+ });
8764
+
8765
+ return {
8766
+ // EVERYTHING PAST THE FIRST PAGE, filled in silently. The first page is
8767
+ // written above; a product with thousands of variants becomes N short
8768
+ // sequential jobs rather than one long loop that risks a BullMQ stall.
8769
+ enqueues : [ {
8770
+ data : { operation : 'variants', product : row.id },
8771
+ name : 'sync',
8772
+ options : { jobId : 'product.shopify.variants.' + row.id + '.' + Date.now() },
8773
+ queue : 'product.shopify.variants'
8774
+ } ],
8775
+ failed : failed ? [ failed.message ] : undefined,
8776
+ message : 'Synced ' + ( product.title || 'product' ) + '.',
8777
+ request,
8778
+ result : { status : 'active', variants : variants.length },
8779
+ writes : [ {
8780
+ collection : 'product',
8781
+ data : {
8782
+ $set : {
8783
+ description : product.description || null,
8784
+ image : product.featuredImage?.url || null,
8785
+ // A successful sync PROVES channel visibility, which reverses the
8786
+ // deactivation above. Ads stay drafted.
8787
+ status : 'active',
8788
+ title : product.title || null,
8789
+ type : product.productType || null,
8790
+ url : ( domain && product.handle ) ? 'https://' + domain + '/products/' + product.handle : null,
8791
+ variants
8792
+ }
8793
+ },
8794
+ operation : 'update',
8795
+ query : { id : row.id }
8796
+ } ]
8797
+ };
8798
+
8799
+ };
8800
+
8801
+ // ONE SHOPIFY PAGE PER RUN, self-chaining on the cursor.
8802
+ //
8803
+ // The first page (no cursor) $sets the array, clearing anything stale from a
8804
+ // prior sync; later pages $push, and Shopify's cursor pagination guarantees no
8805
+ // overlap within a session, so no dedup is needed. A restart begins again at
8806
+ // page one with $set, so a partial-state window is always recoverable.
8807
+ const syncVariants = async ({ connection, context, read, settings, shopify }) => {
8808
+
8809
+ const row = await resolveProduct({ connection, context, read });
8810
+
8811
+ if( ! row ) return { message : 'No product row to paginate.', request : { product : context?.product || null }, skipped : true };
8812
+
8813
+ const cursor = context?.cursor || null;
8814
+ const request = { cursor, product : row.id };
8815
+
8816
+ let page;
8817
+
8818
+ try {
8819
+
8820
+ page = await shopify.storefront.getProductVariantsPage({
8821
+ cursor,
8822
+ domain : connection.shop,
8823
+ productId : row.provider?.id,
8824
+ storefrontAccessToken : settings?.storefrontAccessToken
8825
+ });
8826
+
8827
+ } catch ( error ){
8828
+
8829
+ // Unpublished or deleted mid-pagination, the same contract getProduct
8830
+ // carries. The sync branch's next run owns the feedback; just stop.
8831
+ if( error.message !== NOT_FOUND ) throw error;
8832
+
8833
+ return { message : 'Storefront lost this product mid-pagination — stopping.', request, skipped : true };
8834
+
8835
+ }
8836
+
8837
+ const variants = await applyOutOfStock({
8838
+ domain : connection.shop,
8839
+ shopify,
8840
+ storefrontAccessToken : settings?.storefrontAccessToken,
8841
+ variants : ( page.edges || [] ).map( toVariant )
8842
+ });
8843
+
8844
+ return {
8845
+ ...( page.pageInfo?.hasNextPage && { enqueues : [ {
8846
+ data : { cursor : page.pageInfo.endCursor, operation : 'variants', product : row.id },
8847
+ name : 'page',
8848
+ options : { jobId : 'product.shopify.variants.' + row.id + '.' + Date.now() },
8849
+ queue : 'product.shopify.variants'
8850
+ } ] }),
8851
+ message : variants.length + ' variant(s) on this page.',
8852
+ request,
8853
+ result : { hasNextPage : Boolean( page.pageInfo?.hasNextPage ), variants : variants.length },
8854
+ writes : [ {
8855
+ collection : 'product',
8856
+ data : cursor
8857
+ ? { $push : { variants : { $each : variants } } }
8858
+ : { $set : { variants } },
8859
+ operation : 'update',
8860
+ query : { id : row.id }
8861
+ } ]
8862
+ };
8863
+
8864
+ };
8865
+
8866
+ // Shopify — installed from the App Store, never connected with keys.
8867
+ var shopify = {
7629
8868
 
7630
8869
  // THE WORDS ON THE BUTTONS. `listing` was `content.redirect.title`, which put
7631
8870
  // a BUTTON LABEL inside the copy object next to a url — the title and the
@@ -7825,8 +9064,8 @@ var shopify = {
7825
9064
  // string; this answers what is missing from it.
7826
9065
  //
7827
9066
  // `shopify` is injected for the same reason it is everywhere else — this
7828
- // package cannot import @drawbridge/shopify, which depends on it.
7829
- scopes : ( { scope }, { shopify } = {} ) => ({ result : { missing : scope ? shopify.oauth.missingScopes( scope ) : null } }),
9067
+ // client is imported here and defaulted, never wired in by a caller.
9068
+ scopes : ( { scope }, { shopify = client } = {} ) => ({ result : { missing : scope ? shopify.oauth.missingScopes( scope ) : null } }),
7830
9069
  // Shopify's install grant is exchanged inside its own app flow, not
7831
9070
  // through the shared OAuth runner.
7832
9071
  token : false
@@ -7841,18 +9080,74 @@ var shopify = {
7841
9080
  // for the hooks that are dispatches).
7842
9081
  commerce : {
7843
9082
 
7844
- // NOT YET. The Checkout Kit cart path is `api/route/subdomain.js`, six direct
7845
- // calls into `@drawbridge/shopify/storefront` that write nothing — which is
7846
- // exactly why it never became a hook: a hook is reached to have its effects
7847
- // performed, and this one has none to perform. It moves here when the vendor
7848
- // client moves into the manifest and `subdomain.js` stops importing the
7849
- // package directly.
7850
- cart : false,
9083
+ // THE CHECKOUT KIT CART. Six operations that were six direct calls into
9084
+ // the storefront client from api/route/subdomain.js.
9085
+ //
9086
+ // IT WRITES NOTHING, which is exactly why it was the last surface to
9087
+ // become a hook a hook is usually reached to have its effects performed
9088
+ // and this one has none. That reasoning was wrong: a hook is also the one
9089
+ // place a vendor's client is allowed to be called from, and leaving the
9090
+ // cart out meant a public route importing a vendor SDK directly.
9091
+ //
9092
+ // ONE HOOK, OPERATION IN THE CONTEXT, the same shape `inbound.process`
9093
+ // uses for its topic table. Six slots would be six names in the closed
9094
+ // vocabulary for one question — what does this shopper's cart look like
9095
+ // now — asked six ways.
9096
+ //
9097
+ // A USER ERROR IS A 400 AND THE SHOPPER'S OWN WORDS. Shopify's cart
9098
+ // mutations answer "that variant is sold out" as a userError rather than a
9099
+ // transport failure, and the route it replaced already told the two apart.
9100
+ // runHook carries `status` off a thrown error, so it survives to the
9101
+ // caller; the rest of a thrown error's shape does not.
9102
+ cart : async ( { clientId, clientSecret, context, settings }, { fetcher, shopify = client } = {} ) => {
9103
+
9104
+ const { cartId, lineIds, lines, operation } = context || {};
9105
+ const { domain, storefrontAccessToken } = settings || {};
9106
+
9107
+ // MINTED FROM DRAWBRIDGE'S OWN APP CREDENTIALS rather than the store's
9108
+ // token — the Checkout Kit authenticates as the app. The only operation
9109
+ // here that does not touch the merchant's storefront.
9110
+ if( operation === 'checkout' ){
9111
+
9112
+ const token = await shopify.storefront.createCheckoutToken({ clientId, clientSecret, fetcher });
9113
+
9114
+ return { message : 'Checkout token minted.', result : { token } };
9115
+
9116
+ }
9117
+
9118
+ const storefront = { domain, fetcher, storefrontAccessToken };
9119
+
9120
+ try {
9121
+
9122
+ const cart = await ({
9123
+ create : () => shopify.storefront.cartCreate({ ...storefront, lines }),
9124
+ get : () => shopify.storefront.cartGet({ ...storefront, cartId }),
9125
+ linesAdd : () => shopify.storefront.cartLinesAdd({ ...storefront, cartId, lines }),
9126
+ linesRemove : () => shopify.storefront.cartLinesRemove({ ...storefront, cartId, lineIds }),
9127
+ linesUpdate : () => shopify.storefront.cartLinesUpdate({ ...storefront, cartId, lines })
9128
+ }[ operation ] || ( () => {
9129
+
9130
+ throw Object.assign( new Error( 'Unknown cart operation: ' + operation ), { status : 400 });
9131
+
9132
+ }) )();
9133
+
9134
+ return { message : 'Cart ' + operation + '.', request : { cartId : cartId || null, operation }, result : cart };
9135
+
9136
+ } catch ( error ){
9137
+
9138
+ // The shopper can act on this one; everything else is ours.
9139
+ if( error?.userError ) throw Object.assign( error, { status : 400 });
9140
+
9141
+ throw error;
9142
+
9143
+ }
9144
+
9145
+ },
7851
9146
 
7852
9147
  // MINT A DISCOUNT CODE against the merchant's chosen discount, mapped to
7853
9148
  // one lead — which is what lets an order that redeems it be attributed
7854
9149
  // back.
7855
- code : async ( { connection, context, step }, { adminToken, shopify } = {} ) => {
9150
+ code : async ( { connection, context, step }, { adminToken, shopify = client } = {} ) => {
7856
9151
 
7857
9152
  const discount = step.settings?.discount;
7858
9153
 
@@ -7927,7 +9222,7 @@ var shopify = {
7927
9222
  // customer at the store is a support ticket: the context may already
7928
9223
  // carry the id from an earlier step, the lead may already be linked from
7929
9224
  // an earlier run, and Shopify's own get-or-create settles the rest.
7930
- customer : async ( { connection, context }, { adminToken, read, shopify } = {} ) => {
9225
+ customer : async ( { connection, context }, { adminToken, read, shopify = client } = {} ) => {
7931
9226
 
7932
9227
  const request = { email : context?.email || null, lead : context?.lead || null, shop : connection.shop };
7933
9228
 
@@ -7999,6 +9294,38 @@ var shopify = {
7999
9294
 
8000
9295
  },
8001
9296
 
9297
+ // HOW MANY ARE LEFT. Read at the moment a campaign's product list is
9298
+ // rendered rather than stored on the product, because stock is the one
9299
+ // product fact that is stale the instant it is written down.
9300
+ //
9301
+ // SUMMED HERE, not by the caller. Shopify answers per variant and every
9302
+ // caller wanted the total; the one that existed summed it inline, and
9303
+ // summing `Object.values()` over the raw map is how a cache wrapper's own
9304
+ // bookkeeping key got counted as a quantity.
9305
+ inventory : async ( { connection, context }, { adminToken, shopify = client } = {} ) => {
9306
+
9307
+ const productId = context?.product;
9308
+
9309
+ const request = { product : productId || null, shop : connection.shop };
9310
+
9311
+ if( ! productId ) return { message : 'No product id — nothing to count.', request, result : { total : null }, skipped : true };
9312
+
9313
+ const adminAccessToken = await adminToken();
9314
+
9315
+ const byVariant = await shopify.admin.getProductInventory({
9316
+ adminAccessToken,
9317
+ domain : connection.shop,
9318
+ productId
9319
+ });
9320
+
9321
+ return {
9322
+ message : 'Inventory read.',
9323
+ request,
9324
+ result : { total : Object.values( byVariant || {} ).reduce( ( sum, quantity ) => sum + quantity, 0 ) }
9325
+ };
9326
+
9327
+ },
9328
+
8002
9329
  // AN ORDER ARRIVED AT THE STORE. The largest hook in the family, because
8003
9330
  // attribution genuinely is: an order can reach Drawbridge two ways and
8004
9331
  // they bill differently.
@@ -8468,188 +9795,1043 @@ var shopify = {
8468
9795
  // roll back.
8469
9796
  const billable = org?.billingProvider === 'shopify' && fee > 0 && ! backfill;
8470
9797
 
8471
- // THE EFFECTIVE METER HANDLE. The provider row's orderEventHandle
8472
- // (admin-editable, for the day the plan config's meter changes)
8473
- // overrides the built-in default; slugified again on read as belt
8474
- // and braces with the save-side formatter, since a drifted stored
8475
- // value would mint transactions no meter classifies. Read directly
8476
- // (not via providerSettings — that module imports this one) and
8477
- // only for a billable order, which keeps the cost off every
8478
- // ordinary redemption.
8479
- const providerRow = billable
8480
- ? await read.get({ collection : 'provider', query : { slug : 'shopify' } })
8481
- : null;
9798
+ // THE EFFECTIVE METER HANDLE. The provider row's orderEventHandle
9799
+ // (admin-editable, for the day the plan config's meter changes)
9800
+ // overrides the built-in default; slugified again on read as belt
9801
+ // and braces with the save-side formatter, since a drifted stored
9802
+ // value would mint transactions no meter classifies. Read directly
9803
+ // (not via providerSettings — that module imports this one) and
9804
+ // only for a billable order, which keeps the cost off every
9805
+ // ordinary redemption.
9806
+ const providerRow = billable
9807
+ ? await read.get({ collection : 'provider', query : { slug : 'shopify' } })
9808
+ : null;
9809
+
9810
+ const { orderEventHandle } = providerRow?.settings ? decrypt( providerRow.settings ) : {};
9811
+
9812
+ const handle = ( typeof orderEventHandle === 'string' && slugify( orderEventHandle ) ) || ORDER_EVENT_HANDLE;
9813
+
9814
+ // A billable order with no shop id is revenue lost SILENTLY — the
9815
+ // gate below just yields no job, the order records normally, and the
9816
+ // connection looks healthy. An Error (not a warn) because this is
9817
+ // money: the order id goes in the MESSAGE, since Sentry matches
9818
+ // titles, and the health check's source self-heal is what makes the
9819
+ // state rare enough to page on.
9820
+ if( billable && ! connection?.source?.id ){
9821
+
9822
+ logger?.error?.( new Error(
9823
+ 'shopify.usage.billing.skipped on order ' + orderId + ': '
9824
+ + Math.round( fee * 100 ) + ' cents not billed — connection '
9825
+ + connection?.id + ' has no source.id'
9826
+ ), {
9827
+ extra : {
9828
+ connectionId : connection?.id,
9829
+ fee,
9830
+ orderDocId,
9831
+ orderId : String( orderId )
9832
+ }
9833
+ });
9834
+
9835
+ }
9836
+
9837
+ const enqueues = ( billable && connection?.source?.id )
9838
+ ? [ {
9839
+ data : {
9840
+ // The meter handle travels WITH the job so the sender can
9841
+ // verify it against the transaction prefix and refuse a
9842
+ // drifted pair — idempotency keys are permanent, so a
9843
+ // mistraceable event can never be resent under its own key.
9844
+ handle,
9845
+ idempotencyKey : String( orderId ),
9846
+ orderDocId,
9847
+ orderId : String( orderId ),
9848
+ rate,
9849
+ shopId : connection.source.id,
9850
+ // WHICH VENDOR IS BEING CHARGED, so the sender does not have
9851
+ // to assume. queue/usage.js named the slug itself and
9852
+ // defaults to it for anything already queued when this
9853
+ // shipped — an app-store vendor billing through its own meter
9854
+ // enqueues the same job with its own slug.
9855
+ slug : connection.slug,
9856
+ // The App Events API returns no event id, so one is generated
9857
+ // here — the event handle plus the order id — and sent as the
9858
+ // event's `reference`. queue/usage.js stamps the same id onto
9859
+ // the order as billed.transaction.
9860
+ transaction : handle + '.' + orderId,
9861
+ value : Math.round( fee * 100 )
9862
+ },
9863
+ name : 'billing',
9864
+ options : { jobId : 'shopify.usage.' + orderId },
9865
+ queue : 'usage'
9866
+ } ]
9867
+ : [];
9868
+
9869
+ return {
9870
+ enqueues,
9871
+ message : backfill
9872
+ ? 'Redemption backfilled for an already-recorded order.'
9873
+ : isConversion ? 'Order recorded.' : 'Discount redemption recorded (fee-free).',
9874
+ request,
9875
+ response : {
9876
+ campaign : orderCampaign,
9877
+ currency : currencyCode,
9878
+ discount,
9879
+ fee,
9880
+ gross,
9881
+ lead : leadId,
9882
+ lines : lines.length,
9883
+ net,
9884
+ orderId : String( orderId )
9885
+ },
9886
+ // ONE TRANSACTION. The order, the redemption and both totals
9887
+ // counters land together or not at all — a half-written attribution
9888
+ // is revenue counted twice or not at all, and neither is
9889
+ // recoverable by hand.
9890
+ transaction : writes.length > 0,
9891
+ writes
9892
+ };
9893
+
9894
+ },
9895
+
9896
+ // A PRODUCT CHANGED AT THE STORE. Upserts the product row and hands it to
9897
+ // the product pipeline; the actual field sync happens there.
9898
+ //
9899
+ // The shell has already refused a missing or inactive Shopify connection,
9900
+ // so what is left is the two things only this hook can know are wrong.
9901
+ // A PRODUCT, IN THREE MOVES. Nested by operation rather than by three
9902
+ // slots in the closed vocabulary, the same shape inbound.process uses
9903
+ // for its topic table — they are one subject, and a vendor that syncs
9904
+ // products has to answer all three or none.
9905
+ //
9906
+ // record a webhook said a product changed: upsert the row and
9907
+ // queue the pull
9908
+ // sync pull the product from the storefront and write what it
9909
+ // says, including what it says by NOT answering
9910
+ // variants one page of variants, self-chaining
9911
+ //
9912
+ // `sync` and `variants` were 443 lines of drawbridge-sync's
9913
+ // queue/product.js — a shadow implementation of the step this manifest
9914
+ // already declared, importing three of the vendor's client modules from
9915
+ // a worker file.
9916
+ product : async ( { connection, context, settings, workflow }, { adminToken, mintId, read, shopify = client } = {} ) => {
9917
+
9918
+ const operation = context?.operation || 'record';
9919
+
9920
+ if( operation === 'sync' ) return syncProduct({ adminToken, connection, context, read, settings, shopify });
9921
+
9922
+ if( operation === 'variants' ) return syncVariants({ connection, context, read, settings, shopify });
9923
+
9924
+
9925
+ const request = {
9926
+ numericId : context?.id || null,
9927
+ organizationId : workflow?.organization || null,
9928
+ title : context?.title || null
9929
+ };
9930
+
9931
+ if( ! context?.id ) return { message : 'Skipped — product webhook payload had no id.', request, response : { skipped : true }, skipped : true };
9932
+
9933
+ // The shop domain is half the identity below. Without it the upsert
9934
+ // would match on provider id alone and could collide across stores.
9935
+ if( ! connection.shop ) return { message : 'Skipped — Shopify connection is missing shop domain.', request, response : { skipped : true }, skipped : true };
9936
+
9937
+ const providerId = 'gid://shopify/Product/' + context.id;
9938
+
9939
+ // THE ROW'S OWN ID WHEN IT ALREADY EXISTS, a fresh one only when it
9940
+ // does not.
9941
+ //
9942
+ // The write below is an upsert whose `id` sits in $setOnInsert, so a
9943
+ // product we have seen before keeps the id it was first given and a
9944
+ // newly minted one is silently discarded. Minting unconditionally
9945
+ // therefore published an id belonging to no row: the enqueue named a
9946
+ // product the worker then had to fall back off, and the step's
9947
+ // response showed QA a value that matched nothing they could look up
9948
+ // — which is exactly how it was reported.
9949
+ //
9950
+ // Reading first costs one indexed lookup on the same key the upsert
9951
+ // matches. A concurrent redelivery can still have both runs miss and
9952
+ // mint, which is why the job carries the PROVIDER identity too and the
9953
+ // worker falls back to provider + shop — stable either way.
9954
+ const query = {
9955
+ 'provider.id' : providerId,
9956
+ 'provider.slug' : 'shopify',
9957
+ 'source.domain' : connection.shop
9958
+ };
9959
+
9960
+ const existing = read?.get ? await read.get({ collection : 'product', query }) : null;
9961
+
9962
+ const mintedProduct = existing?.id ? null : mintId();
9963
+
9964
+ const productId = existing?.id || mintedProduct.id;
9965
+
9966
+ return {
9967
+ enqueues : [ {
9968
+ data : { product : productId, providerId, shop : connection.shop },
9969
+ name : 'workflow',
9970
+ // The numeric id, not the gid: BullMQ refuses a custom id with a
9971
+ // colon in it, and every gid carries two.
9972
+ options : { jobId : 'product.workflow.shopify.' + context.id + '.' + Date.now() },
9973
+ queue : 'product.shopify'
9974
+ } ],
9975
+ message : 'Product sync queued from Shopify webhook.',
9976
+ request,
9977
+ response : { productId, providerId, title : context?.title || null },
9978
+ // KEYED ON PROVIDER + SHOP, so the same product in two stores stays
9979
+ // two rows. `connections` accumulates rather than replaces: one
9980
+ // store can be linked to several organizations, and each keeps its
9981
+ // own claim on the row.
9982
+ writes : [ {
9983
+ collection : 'product',
9984
+ data : {
9985
+ $addToSet : { connections : connection.id },
9986
+ $setOnInsert : {
9987
+ ...( mintedProduct && { _id : mintedProduct._id }),
9988
+ id : productId,
9989
+ provider : { id : providerId, slug : 'shopify' },
9990
+ 'source.id' : connection.id,
9991
+ status : 'active'
9992
+ }
9993
+ },
9994
+ operation : 'update',
9995
+ options : { upsert : true },
9996
+ query
9997
+ } ]
9998
+ };
9999
+
10000
+ }
10001
+
10002
+ },
10003
+ contacts : { remove : false, sync : false },
10004
+
10005
+ // THE EMBEDDED-APP SURFACE. Only a vendor whose merchants arrive from its
10006
+ // own app store has one, and these thirteen slots are the thirteen routes
10007
+ // that used to live in api/route/shopify-embedded.js.
10008
+ //
10009
+ // A HOOK DESCRIBES ITS WRITES and the shell performs them, so none of these
10010
+ // holds a controller — `read` is the controller's read methods and nothing
10011
+ // else. Where one REFUSES it throws with a status, and the route answers
10012
+ // what it said. The helpers they share are above the manifest.
10013
+ install : {
10014
+
10015
+ account : {
10016
+
10017
+ // ESTABLISH THE DURABLE MERCHANT-TO-STORE LINK. The breakout
10018
+ // authenticates the merchant once, top-level, and stashes their user
10019
+ // token in `install.token.save`; App Bridge has already proven shop
10020
+ // control by the time this runs.
10021
+ //
10022
+ // THE STASHED TOKEN IS THE TRUST ANCHOR, not merely any valid user
10023
+ // token: the presented one must BE this shop's one-shot handoff token,
10024
+ // unexpired, and an access or pat record — a refresh or oauth-state
10025
+ // token that happens to resolve to a user must not qualify, which is
10026
+ // the rule a session is held to in drawbridge-api's middleware.
10027
+ link : async ( { context }, { read } = {} ) => {
10028
+
10029
+ const shop = sanitizeDomain( context?.shop );
10030
+ const token = context?.token;
10031
+
10032
+ if( ! shop || ! token ) throw refusal( 'shop and token are required', 400 );
10033
+
10034
+ const now = new Date();
10035
+
10036
+ const record = await read.get({ collection : 'shop', query : { shop } });
10037
+
10038
+ const stashed = record?.linkToken ? decrypt( record.linkToken )?.token : null;
10039
+ const expired = ! record?.linkTokenExpiresAt || new Date( record.linkTokenExpiresAt ) <= now;
10040
+
10041
+ if( ! stashed || expired || stashed !== token ) throw refusal( 'Invalid or expired link token', 401 );
10042
+
10043
+ const granted = await read.get({
10044
+ collection : 'token',
10045
+ query : {
10046
+ revoked : false,
10047
+ tokenHash : hashToken( token ),
10048
+ type : { $in : [ tokenTypes.access, tokenTypes.pat ] }
10049
+ }
10050
+ });
10051
+
10052
+ if( ! granted?.user || ( granted.expiresAt && new Date( granted.expiresAt ) <= now ) ){
10053
+
10054
+ throw refusal( 'Invalid or expired link token', 401 );
10055
+
10056
+ }
10057
+
10058
+ // RE-LINKING TO A DIFFERENT ACCOUNT TEARS THE OLD LINK DOWN. The
10059
+ // store's existing org connections belong to the outgoing user, who
10060
+ // can no longer see or manage them from the embedded app. Left in
10061
+ // place they keep serving that user's campaigns off this store while
10062
+ // the merchant is told they are connected as somebody else. Dependent
10063
+ // cleanup fires from drawbridge-sync's connection change stream, the
10064
+ // single home for that cascade.
10065
+ const switching = Boolean( record?.user ) && record.user !== granted.user;
10066
+
10067
+ const connections = switching
10068
+ ? await read.aggregate({
10069
+ collection : 'connection',
10070
+ pipeline : [
10071
+ { $match : { shop, slug : 'shopify' } },
10072
+ { $project : { _id : 0, id : 1 } }
10073
+ ]
10074
+ })
10075
+ : [];
10076
+
10077
+ return {
10078
+ message : switching ? 'Linked to a different Drawbridge account.' : 'Linked.',
10079
+ request : { shop },
10080
+ result : { connected : true, switched : switching },
10081
+ // ALL OR NONE. A half-done switch is a store linked to the new
10082
+ // account while the old account's connections still serve from it.
10083
+ transaction : true,
10084
+ writes : [
10085
+ ...( connections || [] ).map( ( connection ) => ({
10086
+ collection : 'connection',
10087
+ operation : 'delete',
10088
+ query : { id : connection.id }
10089
+ }) ),
10090
+ {
10091
+ collection : 'shop',
10092
+ data : { $set : { linkToken : null, linkTokenExpiresAt : null, user : granted.user } },
10093
+ operation : 'update',
10094
+ query : { shop }
10095
+ }
10096
+ ]
10097
+ };
10098
+
10099
+ },
10100
+
10101
+ // DISCONNECT THE ACCOUNT FROM THE STORE. Every org connection goes and
10102
+ // the durable link clears; the offline token and the shop record stay,
10103
+ // because the app is still installed and the merchant may link a
10104
+ // different account next.
10105
+ unlink : async ( { context }, { read } = {} ) => {
10106
+
10107
+ const shop = sanitizeDomain( context?.shop );
10108
+
10109
+ if( ! shop ) throw refusal( 'shop is required', 400 );
10110
+
10111
+ const connections = await read.aggregate({
10112
+ collection : 'connection',
10113
+ pipeline : [
10114
+ { $match : { shop, slug : 'shopify' } },
10115
+ { $project : { _id : 0, id : 1 } }
10116
+ ]
10117
+ });
10118
+
10119
+ return {
10120
+ message : 'Unlinked ' + ( connections || [] ).length + ' connection(s).',
10121
+ request : { shop },
10122
+ result : { ok : true },
10123
+ transaction : true,
10124
+ writes : [
10125
+ ...( connections || [] ).map( ( connection ) => ({
10126
+ collection : 'connection',
10127
+ operation : 'delete',
10128
+ query : { id : connection.id }
10129
+ }) ),
10130
+ {
10131
+ collection : 'shop',
10132
+ data : { $set : { linkToken : null, linkTokenExpiresAt : null, user : null } },
10133
+ operation : 'update',
10134
+ query : { shop }
10135
+ }
10136
+ ]
10137
+ };
10138
+
10139
+ }
10140
+
10141
+ },
10142
+
10143
+ organizations : {
10144
+
10145
+ // BIND ONE ORGANIZATION TO THIS STORE. The longest slot here, and every
10146
+ // gate in it came from a line in the route it replaces.
10147
+ add : async ( { context }, { currencies, mintId, read, shopify = client } = {} ) => {
10148
+
10149
+ const organization = context?.organization;
10150
+ const shop = sanitizeDomain( context?.shop );
10151
+
10152
+ if( ! shop ) throw refusal( 'shop is required', 400 );
10153
+ if( ! organization ) throw refusal( 'organization is required', 400 );
10154
+
10155
+ const record = await read.get({ collection : 'shop', query : { shop } });
10156
+
10157
+ if( ! record?.user ) throw refusal( 'Account is not connected for this store', 409 );
10158
+
10159
+ // The linked merchant may only add orgs they own or hold the
10160
+ // connections capability on.
10161
+ const owned = await merchantOrganizations({ read, user : record.user });
10162
+ const org = ( owned || [] ).find( ( entry ) => entry.id === organization );
10163
+
10164
+ if( ! org ) throw refusal( 'No access to this organization', 403 );
10165
+
10166
+ // The dashboard's connection-write gate, applied to the embedded path
10167
+ // too: a locked, suspended, past-due, unpaid or paused org cannot take
10168
+ // a new revenue connection either way in.
10169
+ if( ! CONNECTABLE_STATUSES.includes( org.billingStatus ) ){
10170
+
10171
+ throw refusal( 'This organization can\'t accept connections right now — check its billing status in the dashboard', 403 );
10172
+
10173
+ }
10174
+
10175
+ // ONE STORE PER ORGANIZATION. The write below keys on
10176
+ // { organization, slug }, so without this a second store would
10177
+ // silently re-point the org's existing connection at itself.
10178
+ // Re-adding from the SAME store stays allowed. A connection carrying
10179
+ // no `shop` is a legacy dashboard link and counts as another store.
10180
+ if( org.connection && org.connection.shop !== shop ){
10181
+
10182
+ throw refusal( 'This organization is already connected to another Shopify store', 409 );
10183
+
10184
+ }
10185
+
10186
+ const settings = record?.settings ? decrypt( record.settings ) : null;
10187
+
10188
+ if( ! settings?.accessToken ){
10189
+
10190
+ throw refusal( 'Store is not installed — open the app in the Shopify admin first', 409 );
10191
+
10192
+ }
10193
+
10194
+ const plan = await storePlan({ record, shopify });
10195
+
10196
+ if( plan.unavailable ) throw refusal( 'Couldn\'t verify the store\'s plan — try again shortly', 502 );
10197
+
10198
+ if( ! plan.active ) throw refusal( 'Approve a plan before connecting organizations', 402 );
10199
+
10200
+ // THE STOREFRONT TOKEN IS MINTED ONCE PER STORE. Product sync and
10201
+ // storefront reads need it and token exchange only yields the admin
10202
+ // token. It lives in its own field because a session refresh
10203
+ // overwrites `settings` wholesale. BEST-EFFORT: a miss must not block
10204
+ // the link, and the next add re-mints it.
10205
+ let storefront = null;
10206
+
10207
+ if( ! record.storefront ){
10208
+
10209
+ try {
10210
+
10211
+ const minted = await shopify.oauth.createStorefrontToken({
10212
+ adminAccessToken : settings.accessToken,
10213
+ shop
10214
+ });
10215
+
10216
+ if( minted ) storefront = encrypt({ token : minted });
10217
+
10218
+ } catch ( _ ) {
10219
+
10220
+ // re-minted on a later add
10221
+
10222
+ }
10223
+
10224
+ }
10225
+
10226
+ // LIVE SHOP METADATA. `source.id` is required for App Pricing
10227
+ // commission billing. Best-effort again — a fetch miss links with
10228
+ // whatever the shop record already held.
10229
+ let currency = record?.currency || null;
10230
+ let source = record?.source || null;
10231
+
10232
+ try {
10233
+
10234
+ const shopData = await shopify.oauth.getShop({ adminAccessToken : settings.accessToken, shop });
10235
+
10236
+ if( shopData ){
10237
+
10238
+ currency = String( shopData.currency || '' ).toLowerCase() || null;
10239
+ source = { domain : shopData.myshopifyDomain, id : String( shopData.id ), label : shopData.name };
10240
+
10241
+ }
10242
+
10243
+ } catch ( _ ) {
10244
+
10245
+ // keep what the shop record had
10246
+
10247
+ }
10248
+
10249
+ // A STORE SETTLING OUTSIDE THE BILLING-FX SET would fail the
10250
+ // connection validator INSIDE the transaction below, which surfaces
10251
+ // as an opaque 500. Said plainly here instead. `currencies` is
10252
+ // injected rather than imported: which currencies Drawbridge can bill
10253
+ // is not a fact about Shopify.
10254
+ if( currency && currencies && ! currencies.includes( currency ) ){
10255
+
10256
+ throw refusal( 'This store settles in a currency we can\'t bill yet. Connect a store with a supported settlement currency.', 422 );
10257
+
10258
+ }
10259
+
10260
+ // THE CONNECTION'S ID, KNOWN BEFORE THE WRITE THAT CREATES IT,
10261
+ // because the organization's own refs have to name it inside the same
10262
+ // transaction. An existing connection already has one.
10263
+ const minted = org.connection?.id ? null : mintId();
10264
+ const connectionId = org.connection?.id || minted.id;
10265
+
10266
+ return {
10267
+ message : 'Connected ' + org.name + ' to ' + shop + '.',
10268
+ request : { organization, shop },
10269
+ result : { ok : true },
10270
+ transaction : true,
10271
+ writes : [
10272
+ ...( storefront ? [ {
10273
+ collection : 'shop',
10274
+ data : { $set : { storefront } },
10275
+ operation : 'update',
10276
+ query : { shop }
10277
+ } ] : [] ),
10278
+ {
10279
+ collection : 'connection',
10280
+ data : {
10281
+ $set : {
10282
+ currency,
10283
+ errors : [],
10284
+ // The token of record lives on the shared `shop` row; this
10285
+ // is the pointer that satisfies the required `settings`
10286
+ // string, and `auth.install.shared` declares how to read it.
10287
+ settings : encrypt({ ref : 'shop', shop }),
10288
+ shop,
10289
+ status : 'pending',
10290
+ ...( source && { source })
10291
+ },
10292
+ $setOnInsert : {
10293
+ ...( minted && { _id : minted._id, id : minted.id }),
10294
+ feature : 'organization:connection:shopify',
10295
+ organization,
10296
+ slug : 'shopify'
10297
+ }
10298
+ },
10299
+ operation : 'update',
10300
+ options : { upsert : true },
10301
+ query : { organization, slug : 'shopify' }
10302
+ },
10303
+ {
10304
+ collection : 'organization',
10305
+ data : {
10306
+ // $addToSet UNCONDITIONALLY. The route this replaces only
10307
+ // registered the refs when the upsert INSERTED, which meant an
10308
+ // org whose refs had drifted stayed drifted through every
10309
+ // re-link. Adding a member that is already there is a no-op,
10310
+ // so doing it every time costs nothing and repairs the drift.
10311
+ $addToSet : {
10312
+ 'connections.groups' : 'ecommerce',
10313
+ 'connections.ids' : connectionId,
10314
+ 'connections.keys' : 'shopify'
10315
+ },
10316
+ $set : { billingProvider : 'shopify' }
10317
+ },
10318
+ operation : 'update',
10319
+ query : { id : organization }
10320
+ }
10321
+ ]
10322
+ };
10323
+
10324
+ },
10325
+
10326
+ // WHAT THIS STORE CAN SEE: the linked merchant's organizations, the
10327
+ // store's live plan, and the products the merchant has to act on.
10328
+ //
10329
+ // AN ORG CONNECTED TO A DIFFERENT STORE IS OMITTED ENTIRELY, because
10330
+ // `add` would refuse it — a list that offers what the next call rejects
10331
+ // is worse than a shorter one.
10332
+ list : async ( { context }, { conversionRate, planTitle, read, shopify = client } = {} ) => {
10333
+
10334
+ const shop = sanitizeDomain( context?.shop );
10335
+
10336
+ if( ! shop ) throw refusal( 'shop is required', 400 );
10337
+
10338
+ const record = await read.get({ collection : 'shop', query : { shop } });
10339
+
10340
+ if( ! record?.user ){
10341
+
10342
+ return {
10343
+ message : 'No account linked to this store.',
10344
+ request : { shop },
10345
+ result : {
10346
+ account : null,
10347
+ connected : false,
10348
+ organizations : [],
10349
+ plan : { active : false, name : null },
10350
+ unavailableProducts : [],
10351
+ unavailableProductsTotal : 0
10352
+ }
10353
+ };
10354
+
10355
+ }
10356
+
10357
+ const owned = await merchantOrganizations({ read, user : record.user });
10358
+
10359
+ const organizations = ( owned || [] )
10360
+ .filter( ( org ) => ! org.connection || org.connection.shop === shop )
10361
+ .map( ( org ) => ({
10362
+ // The org's OWN billing status decides whether `add` accepts it.
10363
+ // Sent up so the app can disable Connect and name the reason
10364
+ // rather than letting the merchant click into a 403 —
10365
+ // listed-but-blocked beats hidden, because an org vanishing from
10366
+ // the list is indistinguishable from a bug.
10367
+ billingStatus : org.billingStatus || null,
10368
+ connectable : CONNECTABLE_STATUSES.includes( org.billingStatus ),
10369
+ connected : Boolean( org.connection ),
10370
+ conversion : conversionRate( org.subscription ),
10371
+ id : org.id,
10372
+ name : org.name,
10373
+ plan : planTitle( org.subscription ),
10374
+ status : org.connection?.status || null
10375
+ }) );
10376
+
10377
+ // PRODUCTS THE MERCHANT MUST ACT ON, for the app's publishing section
10378
+ // — App Store 5.7.8 and 5.7.11: a sales channel reports its own
10379
+ // publishing problems rather than pushing them to the product page.
10380
+ // `status : 'inactive'` is exactly the set drawbridge-sync deactivated
10381
+ // because the Storefront API could not see them. Scoped to THIS store,
10382
+ // so one shop never sees another org's titles.
10383
+ const connections = ( owned || [] )
10384
+ .filter( ( org ) => org.connection?.shop === shop && org.connection.id )
10385
+ .map( ( org ) => org.connection.id );
10386
+
10387
+ const [ account, plan, unavailable ] = await Promise.all([
10388
+ read.get({ collection : 'user', query : { id : record.user } }),
10389
+ storePlan({ record, shopify }),
10390
+ connections.length
10391
+ ? read.aggregate({
10392
+ collection : 'product',
10393
+ pipeline : [
10394
+ { $match : { connections : { $in : connections }, 'source.domain' : shop, status : 'inactive' } },
10395
+ // $facet so the app can name the first 25 AND report a
10396
+ // truthful total: listing 25 of 40 under "25 products need
10397
+ // attention" would understate the problem.
10398
+ {
10399
+ $facet : {
10400
+ items : [ { $sort : { title : 1 } }, { $limit : 25 }, { $project : { _id : 0, id : 1, title : 1 } } ],
10401
+ total : [ { $count : 'count' } ]
10402
+ }
10403
+ }
10404
+ ]
10405
+ })
10406
+ : []
10407
+ ]);
10408
+
10409
+ const facet = ( unavailable || [] )[ 0 ] || {};
10410
+
10411
+ // THE STAMP OVERLAYS THE BLIND PROBE. storePlan can only see line
10412
+ // items, which managed pricing never populates — the truth-based
10413
+ // verdict lives on the connections, written from Partner-ledger
10414
+ // accrual by sync. A charge id is proof; an explicit null is billed
10415
+ // orders with nothing accrued, which is what makes the banner render.
10416
+ if( plan.active && plan.metered !== true ){
10417
+
10418
+ const stamped = await read.aggregate({
10419
+ collection : 'connection',
10420
+ pipeline : [
10421
+ { $match : { shop, slug : 'shopify' } },
10422
+ { $project : { _id : 0, 'source.metered' : 1 } }
10423
+ ]
10424
+ });
10425
+
10426
+ const verdicts = ( stamped || [] ).map( ( row ) => row?.source?.metered );
10427
+
10428
+ if( verdicts.some( ( verdict ) => typeof verdict === 'string' ) ) plan.metered = true;
10429
+ else if( verdicts.some( ( verdict ) => verdict === null ) ) plan.metered = false;
10430
+
10431
+ }
10432
+
10433
+ return {
10434
+ message : organizations.length + ' organization(s) for this store.',
10435
+ request : { shop },
10436
+ result : {
10437
+ account : { email : account?.email || null },
10438
+ connected : true,
10439
+ organizations,
10440
+ plan,
10441
+ unavailableProducts : ( facet.items || [] ).map( ( product ) => ({
10442
+ id : product.id,
10443
+ title : product.title || 'Untitled product'
10444
+ }) ),
10445
+ // The total can exceed the 25 listed above, and has to.
10446
+ unavailableProductsTotal : ( facet.total || [] )[ 0 ]?.count || 0
10447
+ }
10448
+ };
10449
+
10450
+ },
10451
+
10452
+ // DISCONNECT ONE ORGANIZATION. The connection is deleted and nothing
10453
+ // else: every dependent — products, ads, steps, workflows, and the org's
10454
+ // own connection refs — is torn down by drawbridge-sync's connection
10455
+ // change stream. The shared token and the other orgs are untouched.
10456
+ remove : async ( { context }, { read } = {} ) => {
10457
+
10458
+ const organization = context?.organization;
10459
+ const shop = sanitizeDomain( context?.shop );
10460
+
10461
+ if( ! shop ) throw refusal( 'shop is required', 400 );
10462
+ if( ! organization ) throw refusal( 'organization is required', 400 );
10463
+
10464
+ const record = await read.get({ collection : 'shop', query : { shop } });
10465
+
10466
+ if( ! record?.user ) throw refusal( 'Account is not connected for this store', 409 );
10467
+
10468
+ // The same gate as `add`: whoever drives the Shopify admin acts as
10469
+ // the linked account, and removes only orgs that account can manage.
10470
+ const owned = await merchantOrganizations({ read, user : record.user });
10471
+
10472
+ if( ! ( owned || [] ).some( ( entry ) => entry.id === organization ) ){
10473
+
10474
+ throw refusal( 'No access to this organization', 403 );
10475
+
10476
+ }
10477
+
10478
+ const connection = await read.get({
10479
+ collection : 'connection',
10480
+ query : { organization, shop, slug : 'shopify' }
10481
+ });
10482
+
10483
+ if( ! connection ) throw refusal( 'No connection to remove', 404 );
10484
+
10485
+ return {
10486
+ message : 'Disconnected.',
10487
+ request : { organization, shop },
10488
+ result : { ok : true },
10489
+ writes : [ { collection : 'connection', operation : 'delete', query : { id : connection.id } } ]
10490
+ };
10491
+
10492
+ }
10493
+
10494
+ },
10495
+
10496
+ plan : {
10497
+
10498
+ // RELEASE EVERY ACTIVE APPROVAL. The App Home then re-checks the plan
10499
+ // and falls back to "Choose a plan".
10500
+ cancel : ( { context }, { read, shopify = client } = {} ) => releaseApprovals({ context, read, shopify }),
10501
+
10502
+ // ONE ATTRIBUTED ORDER'S USAGE CHARGE. Enqueued per conversion by the
10503
+ // order step and run on a retrying queue, so this may be called
10504
+ // several times for one order — which is safe because the idempotency
10505
+ // key is PERMANENT for billing events and Shopify settles the repeat.
10506
+ //
10507
+ // THE HANDLE IS CHECKED AGAINST THE MANIFEST'S OWN DECLARATION, which
10508
+ // is a real strengthening of what it replaced: drawbridge-sync compared
10509
+ // the job's `handle` against the job's `transaction` — two fields from
10510
+ // the same enqueuer, so a drifted enqueuer agreed with itself. The
10511
+ // handle is the one string that decides whether the event bills at all,
10512
+ // and this is the file that publishes it.
10513
+ //
10514
+ // REFUSE BEFORE SENDING, because sending burns the order's PERMANENT
10515
+ // idempotency key on an event that misclassifies or mistraces. The
10516
+ // throw rides the queue's normal retry path and the abandoned-job alert
10517
+ // pages on exhaustion.
10518
+ //
10519
+ // DELIBERATELY NO TIMESTAMP. sendAppEvent defaults to the SEND time,
10520
+ // which is always inside the merchant's current billing cycle at the
10521
+ // moment of sending and so can never hit PERIOD_CLOSED. Pinning the
10522
+ // purchase time reads more correct and is strictly worse: a retry
10523
+ // backoff crossing a cycle boundary would carry a closed-cycle
10524
+ // timestamp, Shopify would 202-accept, silently drop it, and the
10525
+ // commission would be lost. Send-time's worst case is benign — the
10526
+ // charge lands in the next cycle, but it lands.
10527
+ charge : async ( { clientId, clientSecret, context, manifest }, { shopify = client } = {} ) => {
10528
+
10529
+ const { idempotencyKey, order, orderId, rate, revision, shopId, timestamp, transaction, value } = context || {};
10530
+
10531
+ // THE MANIFEST'S HANDLE, unless an operator overrode it on the
10532
+ // provider row — which is the same precedence the automatic path
10533
+ // mints against, and the recovery migration is the only caller that
10534
+ // can supply one. The GUARD holds either way: whichever handle is
10535
+ // in force, the transaction has to carry it, which is what keeps
10536
+ // the event's classifier and its trace id telling one story.
10537
+ const handle = context?.handle || manifest.auth.install.billing.handle;
10538
+
10539
+ if( ! transaction || ! String( transaction ).startsWith( handle + '.' ) ){
10540
+
10541
+ throw new Error( 'shopify.usage.billing.handle.mismatch on order ' + orderId + ': transaction '
10542
+ + ( transaction || 'null' ) + ' does not carry handle ' + handle + ' — refusing to send' );
10543
+
10544
+ }
10545
+
10546
+ await shopify.billing.sendAppEvent({
10547
+ clientId,
10548
+ clientSecret,
10549
+ eventHandle : handle,
10550
+ idempotencyKey,
10551
+ // The Shopify-side event record names the order that produced it.
10552
+ reference : transaction,
10553
+ // OPERATOR-ONLY, BOTH OF THEM, and absent on every automatic
10554
+ // send. `revision` suffixes the idempotency key, which is what
10555
+ // makes a second attempt possible once a key has been consumed;
10556
+ // the encoding is owned by sendAppEvent so no caller builds keys
10557
+ // itself. `timestamp` pins the PURCHASE time, which the automatic
10558
+ // path must never do — see the note above — and the recovery
10559
+ // migration must, because it is resending orders from a closed
10560
+ // window and bounds its own run to the merchant's current cycle.
10561
+ ...( revision && { revision }),
10562
+ ...( timestamp && { timestamp }),
10563
+ shopId,
10564
+ value
10565
+ });
10566
+
10567
+ return {
10568
+ message : 'Usage charge sent for order ' + orderId + '.',
10569
+ request : { order : order || null, orderId : orderId || null, shopId : shopId || null, value },
10570
+ result : { transaction : String( transaction ) },
10571
+ // TRACEABILITY. The App Events API returns no event id, so the
10572
+ // trace key into Shopify's event logs is the transaction id the
10573
+ // commerce.order hook minted and this sent as `reference`.
10574
+ // `amount` is the per-order usage value in cents, mirroring the
10575
+ // Stripe path's marker.
10576
+ ...( order && { writes : [ {
10577
+ collection : 'order',
10578
+ data : {
10579
+ $set : {
10580
+ billed : {
10581
+ amount : value,
10582
+ date : new Date(),
10583
+ rate : rate || 0,
10584
+ transaction : String( transaction )
10585
+ }
10586
+ }
10587
+ },
10588
+ operation : 'update',
10589
+ query : { id : order }
10590
+ } ] })
10591
+ };
10592
+
10593
+ },
10594
+
10595
+ // RESELECT, NOT CANCEL. The same mechanics — the current approval has to
10596
+ // be released before the pricing page will offer the plan again — but
10597
+ // the intent is a thirty-second round trip rather than a departure.
10598
+ //
10599
+ // `mark` IS A DECLARED CAPABILITY, and this is the only slot here that
10600
+ // needs one. The marker has to be persisted BEFORE the approval is
10601
+ // released: it is what tells sync's CANCELLED handler to hold the pause
10602
+ // — no connection error, no owner email — and start a timed recheck that
10603
+ // pauses honestly if the merchant abandons. A described write lands
10604
+ // after the hook returns, which is after the cancel, and a marker
10605
+ // written then can race the very webhook it exists to soften. Same
10606
+ // precedent as `resolveContact`: the one write a description cannot
10607
+ // carry.
10608
+ reselect : async ( { context }, { mark, read, shopify = client } = {} ) => {
10609
+
10610
+ const shop = sanitizeDomain( context?.shop );
10611
+
10612
+ if( ! shop ) throw refusal( 'shop is required', 400 );
10613
+
10614
+ await mark({ shop });
10615
+
10616
+ return releaseApprovals({ context, read, shopify });
10617
+
10618
+ },
10619
+
10620
+ // IS THIS STORE'S ORDER BILLING ACTUALLY METERED? Not a merchant
10621
+ // action — the other two slots here are — but the same subject, and
10622
+ // the reason it is a declared hook rather than a helper is that the
10623
+ // answer was being derived in two places: `lifecycle.health` below,
10624
+ // and drawbridge-sync's app_subscriptions/update handler, whose copy
10625
+ // stated in its own comment that it matched this one and did not.
10626
+ //
10627
+ // THREE ANSWERS, and the caller must keep them apart:
10628
+ //
10629
+ // a string PROVEN. The charge id or usage line id, stamped on
10630
+ // the connection as the evidence.
10631
+ // null PROBED, and nothing is accruing.
10632
+ // undefined NO VERDICT — the probe could not run. `probed` says
10633
+ // which, because undefined does not survive JSON.
10634
+ verify : async ( { context }, { adminToken, read, shopify = client } = {} ) => {
10635
+
10636
+ const shop = sanitizeDomain( context?.shop );
10637
+
10638
+ if( ! shop ) throw refusal( 'shop is required', 400 );
10639
+
10640
+ const adminAccessToken = await adminToken();
10641
+
10642
+ if( ! adminAccessToken ){
10643
+
10644
+ return { message : 'No admin token for this store — no verdict.', request : { shop }, result : { metered : null, probed : false }, skipped : true };
10645
+
10646
+ }
10647
+
10648
+ const partnerRow = read?.get ? await read.get({ collection : 'provider', query : { slug : 'shopify' } }) : null;
10649
+ const partner = partnerRow?.settings ? decrypt( partnerRow.settings ) : {};
10650
+
10651
+ const { ledger, metered } = await meterVerdict({
10652
+ adminAccessToken,
10653
+ ledgerFloor : new Date( Date.now() - LEDGER_WINDOW_MS ),
10654
+ partner,
10655
+ shop,
10656
+ shopId : context?.shopId || null,
10657
+ shopify
10658
+ });
10659
+
10660
+ return {
10661
+ message : typeof metered === 'string' ? 'Billing is metered.' : 'No meter evidence for this store.',
10662
+ request : { shop },
10663
+ // `probed` is the LEDGER, not the call: a store whose Partner
10664
+ // credentials are absent was not asked, and reporting that as
10665
+ // "probed, nothing accruing" would stamp an unmetered verdict on
10666
+ // a store nobody looked at.
10667
+ result : { metered : metered ?? null, probed : ledger || typeof metered === 'string' }
10668
+ };
10669
+
10670
+ }
10671
+
10672
+ },
10673
+
10674
+ // THE EMBEDDED APP'S SESSION STORE. The @shopify/shopify-app library
10675
+ // persists the offline session on token exchange and refresh; it lives
10676
+ // encrypted on the shared `shop` record, which is the same row every
10677
+ // organization's connection to this store points at.
10678
+ session : {
10679
+
10680
+ // SOFT-CLEAR, NEVER A DELETE. The library calls this on session
10681
+ // invalidation and not only on uninstall, so hard-deleting the record
10682
+ // would orphan every org connection pointing at it — token resolution
10683
+ // silently nulls — and destroy the durable account link and the
10684
+ // storefront token with it. The next embedded load re-runs token
10685
+ // exchange. The full record delete belongs to the uninstall webhook.
10686
+ clear : async ({ context }) => {
10687
+
10688
+ const shop = sanitizeDomain( context?.shop );
10689
+
10690
+ if( ! shop ) throw refusal( 'shop is required', 400 );
10691
+
10692
+ return {
10693
+ message : 'Session cleared.',
10694
+ request : { shop },
10695
+ result : { ok : true },
10696
+ writes : [ {
10697
+ collection : 'shop',
10698
+ data : { $set : { settings : null } },
10699
+ operation : 'update',
10700
+ query : { shop }
10701
+ } ]
10702
+ };
10703
+
10704
+ },
10705
+
10706
+ read : async ( { context }, { read } = {} ) => {
10707
+
10708
+ const shop = sanitizeDomain( context?.shop );
10709
+
10710
+ if( ! shop ) throw refusal( 'shop is required', 400 );
10711
+
10712
+ const record = await read.get({ collection : 'shop', query : { shop } });
10713
+
10714
+ if( ! record?.settings ) throw refusal( 'No session for this store', 404 );
10715
+
10716
+ return {
10717
+ message : 'Session read.',
10718
+ request : { shop },
10719
+ result : { session : decrypt( record.settings ) }
10720
+ };
10721
+
10722
+ },
10723
+
10724
+ save : async ({ context }) => {
10725
+
10726
+ const session = context?.session;
10727
+ const shop = sanitizeDomain( session?.shop );
10728
+
10729
+ if( ! shop || ! session?.accessToken ) throw refusal( 'session.shop and session.accessToken are required', 400 );
10730
+
10731
+ return {
10732
+ message : 'Session saved.',
10733
+ request : { shop },
10734
+ result : { ok : true },
10735
+ writes : [ {
10736
+ collection : 'shop',
10737
+ data : {
10738
+ $set : { settings : encrypt( session ), shop },
10739
+ $setOnInsert : { status : 'pending' }
10740
+ },
10741
+ operation : 'update',
10742
+ options : { upsert : true },
10743
+ query : { shop }
10744
+ } ]
10745
+ };
10746
+
10747
+ }
10748
+
10749
+ },
10750
+
10751
+ // THE LINK-TOKEN HANDOFF. The breakout callback runs top-level and can set
10752
+ // a first-party cookie; the embedded App Home cannot read that cookie from
10753
+ // inside the Shopify admin iframe. So the callback stashes the merchant's
10754
+ // user token here keyed by shop, and App Home reads it once and clears it.
10755
+ //
10756
+ // DURABLE RATHER THAN IN REDIS: a restart must not strand a merchant
10757
+ // mid-handoff. Short-lived and encrypted.
10758
+ token : {
10759
+
10760
+ clear : async ({ context }) => {
10761
+
10762
+ const shop = sanitizeDomain( context?.shop );
8482
10763
 
8483
- const { orderEventHandle } = providerRow?.settings ? decrypt( providerRow.settings ) : {};
10764
+ if( ! shop ) throw refusal( 'shop is required', 400 );
8484
10765
 
8485
- const handle = ( typeof orderEventHandle === 'string' && slugify( orderEventHandle ) ) || ORDER_EVENT_HANDLE;
10766
+ return {
10767
+ message : 'Link token cleared.',
10768
+ request : { shop },
10769
+ result : { ok : true },
10770
+ writes : [ {
10771
+ collection : 'shop',
10772
+ data : { $set : { linkToken : null, linkTokenExpiresAt : null } },
10773
+ operation : 'update',
10774
+ query : { shop }
10775
+ } ]
10776
+ };
8486
10777
 
8487
- // A billable order with no shop id is revenue lost SILENTLY — the
8488
- // gate below just yields no job, the order records normally, and the
8489
- // connection looks healthy. An Error (not a warn) because this is
8490
- // money: the order id goes in the MESSAGE, since Sentry matches
8491
- // titles, and the health check's source self-heal is what makes the
8492
- // state rare enough to page on.
8493
- if( billable && ! connection?.source?.id ){
10778
+ },
8494
10779
 
8495
- logger?.error?.( new Error(
8496
- 'shopify.usage.billing.skipped on order ' + orderId + ': '
8497
- + Math.round( fee * 100 ) + ' cents not billed — connection '
8498
- + connection?.id + ' has no source.id'
8499
- ), {
8500
- extra : {
8501
- connectionId : connection?.id,
8502
- fee,
8503
- orderDocId,
8504
- orderId : String( orderId )
8505
- }
8506
- });
10780
+ read : async ( { context }, { read } = {} ) => {
8507
10781
 
8508
- }
10782
+ const shop = sanitizeDomain( context?.shop );
8509
10783
 
8510
- const enqueues = ( billable && connection?.source?.id )
8511
- ? [ {
8512
- data : {
8513
- // The meter handle travels WITH the job so the sender can
8514
- // verify it against the transaction prefix and refuse a
8515
- // drifted pair — idempotency keys are permanent, so a
8516
- // mistraceable event can never be resent under its own key.
8517
- handle,
8518
- idempotencyKey : String( orderId ),
8519
- orderDocId,
8520
- orderId : String( orderId ),
8521
- rate,
8522
- shopId : connection.source.id,
8523
- // The App Events API returns no event id, so one is generated
8524
- // here — the event handle plus the order id — and sent as the
8525
- // event's `reference`. queue/usage.js stamps the same id onto
8526
- // the order as billed.transaction.
8527
- transaction : handle + '.' + orderId,
8528
- value : Math.round( fee * 100 )
8529
- },
8530
- name : 'billing',
8531
- options : { jobId : 'shopify.usage.' + orderId },
8532
- queue : 'usage'
8533
- } ]
8534
- : [];
10784
+ if( ! shop ) throw refusal( 'shop is required', 400 );
8535
10785
 
8536
- return {
8537
- enqueues,
8538
- message : backfill
8539
- ? 'Redemption backfilled for an already-recorded order.'
8540
- : isConversion ? 'Order recorded.' : 'Discount redemption recorded (fee-free).',
8541
- request,
8542
- response : {
8543
- campaign : orderCampaign,
8544
- currency : currencyCode,
8545
- discount,
8546
- fee,
8547
- gross,
8548
- lead : leadId,
8549
- lines : lines.length,
8550
- net,
8551
- orderId : String( orderId )
8552
- },
8553
- // ONE TRANSACTION. The order, the redemption and both totals
8554
- // counters land together or not at all — a half-written attribution
8555
- // is revenue counted twice or not at all, and neither is
8556
- // recoverable by hand.
8557
- transaction : writes.length > 0,
8558
- writes
8559
- };
10786
+ const record = await read.get({ collection : 'shop', query : { shop } });
8560
10787
 
8561
- },
10788
+ const expiresAt = record?.linkTokenExpiresAt ? new Date( record.linkTokenExpiresAt ) : null;
8562
10789
 
8563
- // A PRODUCT CHANGED AT THE STORE. Upserts the product row and hands it to
8564
- // the product pipeline; the actual field sync happens there.
8565
- //
8566
- // The shell has already refused a missing or inactive Shopify connection,
8567
- // so what is left is the two things only this hook can know are wrong.
8568
- product : async ( { connection, context, workflow }, { mintId, read } = {} ) => {
10790
+ // EXPIRY IS CHECKED ON READ, not by a sweep. The token is one-shot
10791
+ // and short-lived, so the read is the only moment anyone cares.
10792
+ if( ! record?.linkToken || ! expiresAt || expiresAt <= new Date() ){
8569
10793
 
8570
- const request = {
8571
- numericId : context?.id || null,
8572
- organizationId : workflow.organization,
8573
- title : context?.title || null
8574
- };
10794
+ throw refusal( 'No link token for this store', 404 );
8575
10795
 
8576
- if( ! context?.id ) return { message : 'Skipped — product webhook payload had no id.', request, response : { skipped : true }, skipped : true };
10796
+ }
8577
10797
 
8578
- // The shop domain is half the identity below. Without it the upsert
8579
- // would match on provider id alone and could collide across stores.
8580
- if( ! connection.shop ) return { message : 'Skipped — Shopify connection is missing shop domain.', request, response : { skipped : true }, skipped : true };
10798
+ return {
10799
+ message : 'Link token read.',
10800
+ request : { shop },
10801
+ result : { token : decrypt( record.linkToken )?.token ?? null }
10802
+ };
8581
10803
 
8582
- const providerId = 'gid://shopify/Product/' + context.id;
10804
+ },
8583
10805
 
8584
- // THE ROW'S OWN ID WHEN IT ALREADY EXISTS, a fresh one only when it
8585
- // does not.
8586
- //
8587
- // The write below is an upsert whose `id` sits in $setOnInsert, so a
8588
- // product we have seen before keeps the id it was first given and a
8589
- // newly minted one is silently discarded. Minting unconditionally
8590
- // therefore published an id belonging to no row: the enqueue named a
8591
- // product the worker then had to fall back off, and the step's
8592
- // response showed QA a value that matched nothing they could look up
8593
- // — which is exactly how it was reported.
8594
- //
8595
- // Reading first costs one indexed lookup on the same key the upsert
8596
- // matches. A concurrent redelivery can still have both runs miss and
8597
- // mint, which is why the job carries the PROVIDER identity too and the
8598
- // worker falls back to provider + shop — stable either way.
8599
- const query = {
8600
- 'provider.id' : providerId,
8601
- 'provider.slug' : 'shopify',
8602
- 'source.domain' : connection.shop
8603
- };
10806
+ save : async ({ context }) => {
8604
10807
 
8605
- const existing = read?.get ? await read.get({ collection : 'product', query }) : null;
10808
+ const shop = sanitizeDomain( context?.shop );
10809
+ const token = context?.token;
8606
10810
 
8607
- const mintedProduct = existing?.id ? null : mintId();
10811
+ if( ! shop || ! token ) throw refusal( 'shop and token are required', 400 );
8608
10812
 
8609
- const productId = existing?.id || mintedProduct.id;
10813
+ return {
10814
+ message : 'Link token saved.',
10815
+ request : { shop },
10816
+ result : { ok : true },
10817
+ writes : [ {
10818
+ collection : 'shop',
10819
+ data : {
10820
+ $set : {
10821
+ linkToken : encrypt({ token }),
10822
+ linkTokenExpiresAt : new Date( Date.now() + LINK_TOKEN_TTL_MS )
10823
+ }
10824
+ },
10825
+ operation : 'update',
10826
+ query : { shop }
10827
+ } ]
10828
+ };
8610
10829
 
8611
- return {
8612
- enqueues : [ {
8613
- data : { product : productId, providerId, shop : connection.shop },
8614
- name : 'workflow',
8615
- // The numeric id, not the gid: BullMQ refuses a custom id with a
8616
- // colon in it, and every gid carries two.
8617
- options : { jobId : 'product.workflow.shopify.' + context.id + '.' + Date.now() },
8618
- queue : 'product.shopify'
8619
- } ],
8620
- message : 'Product sync queued from Shopify webhook.',
8621
- request,
8622
- response : { productId, providerId, title : context?.title || null },
8623
- // KEYED ON PROVIDER + SHOP, so the same product in two stores stays
8624
- // two rows. `connections` accumulates rather than replaces: one
8625
- // store can be linked to several organizations, and each keeps its
8626
- // own claim on the row.
8627
- writes : [ {
8628
- collection : 'product',
8629
- data : {
8630
- $addToSet : { connections : connection.id },
8631
- $setOnInsert : {
8632
- ...( mintedProduct && { _id : mintedProduct._id }),
8633
- id : productId,
8634
- provider : { id : providerId, slug : 'shopify' },
8635
- 'source.id' : connection.id,
8636
- status : 'active'
8637
- }
8638
- },
8639
- operation : 'update',
8640
- options : { upsert : true },
8641
- query
8642
- } ]
8643
- };
10830
+ }
8644
10831
 
8645
10832
  }
8646
10833
 
8647
10834
  },
8648
- contacts : { remove : false, sync : false },
8649
-
8650
- // The embedded-app surface. Only a vendor whose merchants arrive from its
8651
- // own app store has one.
8652
- install : false,
8653
10835
 
8654
10836
  // Drawbridge sends its own notification email and SMS — see the private
8655
10837
  // `drawbridge` manifest. A vendor answering these would be a second sender,
@@ -8731,7 +10913,7 @@ var shopify = {
8731
10913
  // access token still works, reconciles the scopes the store granted
8732
10914
  // against the ones the app now needs, and queues a webhook
8733
10915
  // reconciliation.
8734
- health : async ( { connection, workflow }, { adminToken, logger, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {} ) => {
10916
+ health : async ( { connection, workflow }, { adminToken, logger, read, reconcileScopes, resolveSettings, rotateToken, shopify = client } = {} ) => {
8735
10917
 
8736
10918
  const request = {
8737
10919
  connectionId : workflow.connection,
@@ -8833,62 +11015,27 @@ var shopify = {
8833
11015
 
8834
11016
  }
8835
11017
 
8836
- const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
8837
- adminAccessToken,
8838
- domain : connection.shop
8839
- });
8840
-
8841
- // A usage line on the approval is POSITIVE evidence and gets
8842
- // stamped. Its ABSENCE proves nothing: measured 2026-09-04
8843
- // (both 2026-04 and unstable, against a plan whose meter
8844
- // exists), managed-pricing App-Events meters never
8845
- // materialize as subscription line items — AppUsagePricing is
8846
- // the legacy usage-charge model. Reading absence as
8847
- // "unmetered" flagged healthy stores, so absence is NO
8848
- // VERDICT: the stamp, the Issue, the card and the
8849
- // meter-missing pager all stay silent until a signal that can
8850
- // actually observe the meter exists (Partner API
8851
- // USAGE_CHARGE_APPLIED accrual events are that signal).
8852
- const usageLine = subscriptions.find( ( subscription ) => subscription.usageLineItemId )?.usageLineItemId;
8853
-
8854
- if( usageLine ) metered = usageLine;
8855
-
8856
- // THE REAL METER SIGNAL — Partner API accrual. A CHARGE_USAGE
8857
- // event for this shop is proof billing works: its charge id
8858
- // becomes the stamp (overriding the line-item probe, which
8859
- // managed pricing keeps blind), and each charge row is a
8860
- // billing BOUNDARY the reconciler below balances the books
8861
- // against. Off entirely until the provider row carries the
8862
- // credentials.
11018
+ // ONE READING OF THE TWO SIGNALS, shared with the approval
11019
+ // webhook in drawbridge-sync, which used to hand-copy it.
8863
11020
  const partnerRow = read?.get ? await read.get({ collection : 'provider', query : { slug : 'shopify' } }) : null;
8864
11021
  const partner = partnerRow?.settings ? decrypt( partnerRow.settings ) : {};
8865
11022
 
8866
- if( partner.partnerToken && partner.partnerOrgId && partner.partnerAppId && source?.id && shopify.partner?.getUsageChargeEvents ){
8867
-
8868
- const ledgerFloor = new Date( Date.now() - 30 * 24 * 60 * 60 * 1000 );
11023
+ const ledgerFloor = new Date( Date.now() - LEDGER_WINDOW_MS );
8869
11024
 
8870
- const accruals = await shopify.partner.getUsageChargeEvents({
8871
- appId : 'gid://shopify/App/' + partner.partnerAppId,
8872
- first : 10,
8873
- occurredAtMin : ledgerFloor.toISOString(),
8874
- organizationId : partner.partnerOrgId,
8875
- partnerToken : partner.partnerToken,
8876
- shopId : 'gid://shopify/Shop/' + source.id
8877
- });
11025
+ const verdict = await meterVerdict({
11026
+ adminAccessToken,
11027
+ ledgerFloor,
11028
+ partner,
11029
+ shop : connection.shop,
11030
+ shopId : source?.id,
11031
+ shopify
11032
+ });
8878
11033
 
8879
- // Oldest first: each charge row is a boundary that closes
8880
- // the window since the row before it.
8881
- const boundaries = accruals
8882
- .filter( ( event ) => event.occurredAt )
8883
- .sort( ( a, b ) => new Date( a.occurredAt ) - new Date( b.occurredAt ) );
11034
+ if( verdict.metered !== undefined ) metered = verdict.metered;
8884
11035
 
8885
- // A usage ROW exists per subscription even before anything
8886
- // accrues (measured: a pre-meter approval shows qty 0), so
8887
- // the row is not the proof — a POSITIVE usageQuantity is.
8888
- // The LATEST positive row is the stamp.
8889
- const accrued = [ ...boundaries ].reverse().find( ( event ) => Number( event.usageQuantity ) > 0 );
11036
+ const { boundaries } = verdict;
8890
11037
 
8891
- if( accrued ) metered = String( accrued.chargeId || accrued.id );
11038
+ if( boundaries.length ){
8892
11039
 
8893
11040
  // THE BOUNDARY RECONCILER. Money truth lives at boundaries:
8894
11041
  // each charge row states what Shopify actually charged for
@@ -9227,13 +11374,13 @@ var shopify = {
9227
11374
  // They are the same two questions every other vendor answers through
9228
11375
  // resources.*, so they answer them the same way now.
9229
11376
  //
9230
- // `shopify` is INJECTED: this package cannot import @drawbridge/shopify,
11377
+ // `shopify` DEFAULTS to the client this file imports; nothing injects it,
9231
11378
  // which depends on it. What arrives is the SDK's pure HTTP namespaces
9232
11379
  // and nothing else — no controller, no collection access. Resolving the
9233
11380
  // credential is the caller's job because it is Drawbridge's job: the
9234
11381
  // admin token refreshes and writes itself back, which is service work,
9235
11382
  // not vendor work.
9236
- products : async ( { cursor, limit = 100, search, settings, sort }, { shopify } = {} ) => {
11383
+ products : async ( { cursor, limit = 100, search, settings, sort }, { shopify = client } = {} ) => {
9237
11384
 
9238
11385
  const products = await shopify.storefront.getProducts({
9239
11386
  cursor,
@@ -9258,7 +11405,7 @@ var shopify = {
9258
11405
 
9259
11406
  },
9260
11407
 
9261
- promotions : async ( { cursor, limit = 100, search, settings }, { shopify } = {} ) => {
11408
+ promotions : async ( { cursor, limit = 100, search, settings }, { shopify = client } = {} ) => {
9262
11409
 
9263
11410
  const discounts = await shopify.admin.getDiscounts({
9264
11411
  adminAccessToken : settings?.adminAccessToken,
@@ -9305,7 +11452,7 @@ var shopify = {
9305
11452
  // Drawbridge posts to a merchant's own endpoint, never through a vendor.
9306
11453
  webhook : false
9307
11454
  },
9308
- icon,
11455
+ icon: icon$1,
9309
11456
  // A pre-launch integration: it only surfaces once the App Store listing
9310
11457
  // exists and the app is fully configured. Requiring all four means it can
9311
11458
  // never render half-configured — and absence of any one excludes the
@@ -9420,6 +11567,13 @@ var shopify = {
9420
11567
  key : 'Shopify Order Tracking',
9421
11568
  queue : 'connection',
9422
11569
  system : true,
11570
+ // THE TRIGGER THE REGISTER JOB HAND-WROTE. It is byte-identical to
11571
+ // the one on every stored workflow, because these documents exist on
11572
+ // prod with thousands of runs behind them and provisioning is
11573
+ // idempotent on (connection, system, title) — a different trigger
11574
+ // here would not be picked up by the existing rows anyway, and a
11575
+ // different TITLE would provision a second workflow for every store.
11576
+ trigger : { event : 'shopify.order.create', type : 'webhook' },
9423
11577
  type : 'step.commerce.order.record'
9424
11578
  })
9425
11579
  },
@@ -9431,6 +11585,15 @@ var shopify = {
9431
11585
  key : 'Shopify Product Sync',
9432
11586
  queue : 'connection',
9433
11587
  system : true,
11588
+ // `shopify.product.update`, NOT `product.insert`. The plan named the
11589
+ // latter, and it is the right long-term answer — a Drawbridge product
11590
+ // row appearing should pull the vendor's copy, rather than
11591
+ // stream/product.js branching on the slug and enqueueing a
11592
+ // vendor-named queue. But a workflow carries ONE trigger, this one is
11593
+ // stored on prod against the webhook, and changing a live workflow's
11594
+ // trigger is a data migration. So the declaration matches what exists
11595
+ // and `product.insert` stays a separate, migration-gated change.
11596
+ trigger : { event : 'shopify.product.update', type : 'webhook' },
9434
11597
  type : 'step.commerce.product.sync'
9435
11598
  })
9436
11599
  }
@@ -9456,26 +11619,37 @@ var shopify = {
9456
11619
  })
9457
11620
  },
9458
11621
 
9459
- // Audit-only. The "Shopify Token Activity" system workflow lists these
9460
- // for descriptive grouping, but its audit step docs are written manually
9461
- // at OAuth time — the workflow is never dispatched. Routing is declared
9462
- // defensively so that if it ever IS dispatched, the job lands on a real
9463
- // queue and the handler lookup misses cleanly instead of throwing
9464
- // "Unknown step type".
11622
+ // THE ONE WORKFLOW THAT CARRIES TWO STEPS, and the shape the plan called
11623
+ // the single genuinely new thing in it.
11624
+ //
11625
+ // Every other system step is its own workflow, titled by its own `key`.
11626
+ // These two are "Shopify Token Activity", one row in the merchant's list
11627
+ // with both steps in it — so they declare `workflow`, which names the
11628
+ // title, the trigger and the ORDER, and the provisioner groups on it.
11629
+ // Without that key the pair could only be provisioned by the hand-written
11630
+ // spec in drawbridge-sync's register job, which is exactly why that spec
11631
+ // outlived every other one.
11632
+ //
11633
+ // AUDIT-ONLY. The step documents are written manually at OAuth time and
11634
+ // the workflow is never dispatched; `queue` is declared defensively so
11635
+ // that if it ever IS, the job lands on a real queue and the handler lookup
11636
+ // misses cleanly rather than throwing "Unknown step type".
9465
11637
  token : {
9466
11638
  exchange : () => ({
9467
11639
  description : 'Records the token exchange that completed an install. Audit only — never dispatched.',
9468
11640
  key : 'Shopify Token Exchange',
9469
11641
  queue : 'connection',
9470
11642
  system : true,
9471
- type : 'step.connection.token.exchange'
11643
+ type : 'step.connection.token.exchange',
11644
+ workflow : { key : 'Shopify Token Activity', order : 0, trigger : { event : 'shopify.token', type : 'event' } }
9472
11645
  }),
9473
11646
  refresh : () => ({
9474
11647
  description : 'Records a token rotation. Audit only — never dispatched.',
9475
11648
  key : 'Shopify Token Refresh',
9476
11649
  queue : 'connection',
9477
11650
  system : true,
9478
- type : 'step.connection.token.refresh'
11651
+ type : 'step.connection.token.refresh',
11652
+ workflow : { key : 'Shopify Token Activity', order : 1, trigger : { event : 'shopify.token', type : 'event' } }
9479
11653
  })
9480
11654
  }
9481
11655
 
@@ -9683,14 +11857,15 @@ var webhook = {
9683
11857
  'Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react to it.',
9684
11858
  'Generate a signing secret and Drawbridge signs every request with it. Your endpoint recomputes the signature to confirm each payload genuinely came from Drawbridge before acting on it.'
9685
11859
  ],
9686
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
9687
- // The dashboard already carries generic reasons for every way an
9688
- // authorization can fail declined, expired state, a vendor we are not set
9689
- // up for and a sentence here is an ADDITION to those, for a vendor whose
9690
- // failures need saying differently. This one's do not.
11860
+ // THERE IS NO CONSENT SCREEN TO FAIL. Connecting mints a signing secret;
11861
+ // nothing is authorized against a third party, so neither `denied` nor
11862
+ // `invalid` has a state it could describe. The failures this connection
11863
+ // really has are at DELIVERY a merchant's endpoint refusing a POST and
11864
+ // those reach them through the connection's own errors rather than through
11865
+ // connect copy.
9691
11866
  //
9692
- // Answered rather than omitted, because `errors` existed on two of six
9693
- // manifests and there was no way to tell "nothing to add" from "nobody
11867
+ // `false` is the recorded decision, not silence: `errors` existed on two of
11868
+ // six manifests and there was no way to tell "nothing to add" from "nobody
9694
11869
  // wrote it".
9695
11870
  errors : false,
9696
11871
  excerpt : 'Sign outgoing webhook payloads with an HMAC secret to verify authenticity.',
@@ -9812,7 +11987,7 @@ var webhook = {
9812
11987
  // Borrowed: this is the Drawbridge mark, because Webhooks has none of its own.
9813
11988
  // It is the one card that reads wrong — our logo among vendor logos — and it
9814
11989
  // wants a mark of its own when there is one.
9815
- icon: icon$6,
11990
+ icon: icon$7,
9816
11991
  // EXPLICIT, not derived, because the name is not a vendor's. There is no third
9817
11992
  // party behind a webhook — connecting MINTS a secret — so this is one of
9818
11993
  // Drawbridge's own keys, listed in PLATFORM_CREDENTIALS. Without it the signing
@@ -9861,7 +12036,7 @@ var webhook = {
9861
12036
  // The connect prompt only where connecting is not already the card's whole
9862
12037
  // story: a disconnected or errored document renders a Connect action itself,
9863
12038
  // and a task repeating it talks over the button.
9864
- tasks : ( { settings, status } = {} ) => ( [ 'disconnected', 'error' ].includes( status )
12039
+ tasks : ( { settings, status } = {} ) => ( status === 'error'
9865
12040
  ? []
9866
12041
  : settings?.secret
9867
12042
  ? [
@@ -9950,7 +12125,7 @@ var attentiveVendor = {
9950
12125
 
9951
12126
  // The same mark the connection carries. One vendor, one icon — mergeVendors
9952
12127
  // used to throw when two manifests disagreed about this.
9953
- icon: icon$7,
12128
+ icon: icon$8,
9954
12129
 
9955
12130
  name : 'Attentive',
9956
12131
 
@@ -9969,6 +12144,112 @@ var attentiveVendor = {
9969
12144
 
9970
12145
  };
9971
12146
 
12147
+ // Bright Data's mark, drawn by hand rather than exported from a brand kit —
12148
+ // there was no export to hand. The eight-spoke burst on their blue is the
12149
+ // recognisable part; replace with the kit's own mark when one is available.
12150
+ //
12151
+ // A .js wrapper around otherwise untouched SVG so `node --test` can run against
12152
+ // lib/ directly. A bare .svg import would need a bundler loader and force the
12153
+ // tests onto dist/, which is a worse trade than one line of wrapper.
12154
+ var icon = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
12155
+ <rect width="500" height="500" fill="#0F58FF"/>
12156
+ <circle cx="250" cy="250" r="52" fill="white"/>
12157
+ <rect x="234" y="70" width="32" height="96" rx="16" fill="white"/>
12158
+ <rect x="234" y="334" width="32" height="96" rx="16" fill="white"/>
12159
+ <rect x="70" y="234" width="96" height="32" rx="16" fill="white"/>
12160
+ <rect x="334" y="234" width="96" height="32" rx="16" fill="white"/>
12161
+ <rect x="117" y="140" width="32" height="96" rx="16" transform="rotate(-45 117 140)" fill="white"/>
12162
+ <rect x="304" y="327" width="32" height="96" rx="16" transform="rotate(-45 304 327)" fill="white"/>
12163
+ <rect x="360" y="117" width="32" height="96" rx="16" transform="rotate(45 360 117)" fill="white"/>
12164
+ <rect x="173" y="304" width="32" height="96" rx="16" transform="rotate(45 173 304)" fill="white"/>
12165
+ </svg>`;
12166
+
12167
+ // DRAWBRIDGE'S OWN CREDENTIALS FOR BRIGHT DATA — the scrape path's rendering and
12168
+ // unblocking, spent on our account and never on a merchant's.
12169
+ //
12170
+ // A VENDOR WITH NO CONNECTION, and the second one after Stripe. Nobody links a
12171
+ // Bright Data account to Drawbridge; scraping is infrastructure that happens to
12172
+ // cost money, so there is no manifest, no hooks and nothing merchant-facing. It
12173
+ // is here because a vendor is the row in the `provider` collection holding our
12174
+ // keys for it — and until vendors became their own files, a vendor with no
12175
+ // connection could not hold credentials at all, because the admin screen's
12176
+ // vocabulary was derived from the connection registry.
12177
+ //
12178
+ // WHY IT MOVED OFF ENV, 2026-09-16. The unlocker key had an expiry nobody knew
12179
+ // about and Bright Data mailed a three-day warning to one person's inbox. Their
12180
+ // API offers no programmatic rotation — the docs are explicit that refreshing is
12181
+ // a human in the control panel and that "any request you will send with the old
12182
+ // key will fail to authenticate" the moment you do — so renewal is always going
12183
+ // to be a paste. The question was only where it gets pasted: three env vars
12184
+ // across two DigitalOcean app specs, each needing a redeploy, or one admin
12185
+ // screen with a 60-second memo. Darren, 2026-09-16, choosing the latter and an
12186
+ // unlimited-expiry key.
12187
+ //
12188
+ // `credential` NAMES THE ENV VAR EACH FIELD FALLS BACK TO, which is what makes
12189
+ // the move safe to ship before the row exists: old deployments keep reading env,
12190
+ // and the row takes over the moment it is filled.
12191
+ var brightdataVendor = {
12192
+
12193
+ fields : [
12194
+ {
12195
+ credential : 'BRIGHTDATA_BROWSER_URI',
12196
+ input : 'password',
12197
+ key : 'browserUri',
12198
+ // A wss:// endpoint with the account's user and password in its
12199
+ // authority, which is why it is a password field rather than a url one:
12200
+ // the whole string is the credential.
12201
+ label : 'Scraping Browser endpoint',
12202
+ message : 'The wss:// Scraping Browser endpoint, carrying its own credentials. Used to render pages that need a real browser.',
12203
+ redact : true,
12204
+ required : true
12205
+ },
12206
+ {
12207
+ credential : 'BRIGHTDATA_UNLOCKER_API_KEY',
12208
+ input : 'password',
12209
+ key : 'unlockerApiKey',
12210
+ label : 'Web Unlocker API key',
12211
+ // NO EXPIRY FIELD, and that is a decision rather than an omission. Bright
12212
+ // Data offers an `Unlimited` expiry and recommends against it; Drawbridge
12213
+ // takes the unlimited key deliberately, because there is no way to rotate
12214
+ // this programmatically and a date nothing can act on is a date that only
12215
+ // surfaces after it has already broken scraping. If a fixed expiry is ever
12216
+ // taken instead, the field to add is a stored date and a warning ahead of
12217
+ // it — not a rotation, which the vendor cannot support.
12218
+ message : 'Bright Data account settings → API keys. Taken with an unlimited expiry: their API offers no way to rotate a key, so a dated one can only ever break scraping silently.',
12219
+ redact : true,
12220
+ required : true
12221
+ },
12222
+ {
12223
+ credential : 'BRIGHTDATA_UNLOCKER_ZONE',
12224
+ input : 'text',
12225
+ key : 'unlockerZone',
12226
+ label : 'Web Unlocker zone',
12227
+ // Not secret — a zone is a label on the account, and it is shown rather
12228
+ // than redacted so an operator can confirm which one is in use without
12229
+ // clearing the field to read it.
12230
+ message : 'The zone name the Unlocker requests run against.',
12231
+ required : true
12232
+ }
12233
+ ],
12234
+
12235
+ icon,
12236
+
12237
+ name : 'Bright Data',
12238
+
12239
+ slug : 'brightdata',
12240
+
12241
+ // WHERE THE FACTS CAME FROM, read 2026-09-16.
12242
+ urls : {
12243
+ api : 'https://docs.brightdata.com/api-reference/authentication',
12244
+ dashboard : 'https://brightdata.com/cp/setting/users',
12245
+ // Bright Data has no scope model — a key carries the account's own access —
12246
+ // so there is nothing to link. `false` is the recorded answer rather than a
12247
+ // missing key.
12248
+ scopes : false
12249
+ }
12250
+
12251
+ };
12252
+
9972
12253
  // DRAWBRIDGE'S OWN CREDENTIALS FOR HUBSPOT, and where the facts about it came
9973
12254
  // from. One file per vendor, because a vendor is NOT a connection: it is the row
9974
12255
  // in the `provider` collection holding our keys for it, and a connection names
@@ -9999,7 +12280,7 @@ var hubspotVendor = {
9999
12280
 
10000
12281
  // The vendor's own mark rather than the connection's: drawbridge fronts three
10001
12282
  // vendors and none of them is Drawbridge.
10002
- icon: icon$5,
12283
+ icon: icon$6,
10003
12284
 
10004
12285
  name : 'HubSpot',
10005
12286
 
@@ -10056,7 +12337,7 @@ var klaviyoVendor = {
10056
12337
 
10057
12338
  // The same mark the connection carries. One vendor, one icon — mergeVendors
10058
12339
  // used to throw when two manifests disagreed about this.
10059
- icon: icon$2,
12340
+ icon: icon$3,
10060
12341
 
10061
12342
  name : 'Klaviyo',
10062
12343
 
@@ -10113,7 +12394,7 @@ var mailchimpVendor = {
10113
12394
 
10114
12395
  // The same mark the connection carries. One vendor, one icon — mergeVendors
10115
12396
  // used to throw when two manifests disagreed about this.
10116
- icon: icon$1,
12397
+ icon: icon$2,
10117
12398
 
10118
12399
  name : 'Mailchimp',
10119
12400
 
@@ -10184,7 +12465,7 @@ var sendgridVendor = {
10184
12465
 
10185
12466
  // The vendor's own mark rather than the connection's: drawbridge fronts three
10186
12467
  // vendors and none of them is Drawbridge.
10187
- icon: icon$4,
12468
+ icon: icon$5,
10188
12469
 
10189
12470
  name : 'SendGrid',
10190
12471
 
@@ -10291,7 +12572,7 @@ var shopifyVendor = {
10291
12572
 
10292
12573
  // The same mark the connection carries. One vendor, one icon — mergeVendors
10293
12574
  // used to throw when two manifests disagreed about this.
10294
- icon,
12575
+ icon: icon$1,
10295
12576
 
10296
12577
  name : 'Shopify',
10297
12578
 
@@ -10357,7 +12638,7 @@ var twilioVendor = {
10357
12638
 
10358
12639
  // The vendor's own mark rather than the connection's: drawbridge fronts three
10359
12640
  // vendors and none of them is Drawbridge.
10360
- icon: icon$3,
12641
+ icon: icon$4,
10361
12642
 
10362
12643
  name : 'Twilio',
10363
12644
 
@@ -10537,7 +12818,7 @@ const checkIcon = ( owner, icon ) => {
10537
12818
  // its icon, declaring one of its fields two ways, or claiming the same credential.
10538
12819
  // None of those can happen when a vendor is declared once, so all of it is gone.
10539
12820
  const vendors = Object.freeze( Object.fromEntries(
10540
- [ attentiveVendor, hubspotVendor, klaviyoVendor, mailchimpVendor, sendgridVendor, shopifyVendor, twilioVendor ]
12821
+ [ attentiveVendor, brightdataVendor, hubspotVendor, klaviyoVendor, mailchimpVendor, sendgridVendor, shopifyVendor, twilioVendor ]
10541
12822
  .map( ( vendor ) => [ vendor.slug, vendor ] )
10542
12823
  ) );
10543
12824
 
@@ -11420,17 +13701,42 @@ const build = ( manifest ) => {
11420
13701
 
11421
13702
  }
11422
13703
 
11423
- if( declared.trigger ){
13704
+ // A WORKFLOW GROUPING several steps into one row. Its trigger is checked
13705
+ // against the same table as a step's own, because a typo here is the same
13706
+ // silent failure: the workflow saves, reads active, and matches nothing.
13707
+ if( declared.workflow ){
13708
+
13709
+ if( ! declared.workflow.key ){
13710
+
13711
+ throw new Error( manifest.slug + ' step ' + type + ' declares a workflow with no key — the title several steps share' );
13712
+
13713
+ }
13714
+
13715
+ if( typeof declared.workflow.order !== 'number' ){
13716
+
13717
+ throw new Error( manifest.slug + ' step ' + type + ' declares a workflow with no order — two steps in one row need a sequence' );
13718
+
13719
+ }
13720
+
13721
+ if( declared.trigger ){
13722
+
13723
+ throw new Error( manifest.slug + ' step ' + type + ' declares BOTH a trigger and a workflow — the workflow carries the trigger for every step in it' );
13724
+
13725
+ }
13726
+
13727
+ }
13728
+
13729
+ for( const candidate of [ declared.trigger, declared.workflow?.trigger ].filter( Boolean ) ){
11424
13730
 
11425
13731
  const known = TRIGGERS[
11426
- Object.keys( TRIGGERS ).find( ( key ) => TRIGGERS[ key ].event === declared.trigger.event && TRIGGERS[ key ].type === declared.trigger.type )
13732
+ Object.keys( TRIGGERS ).find( ( key ) => TRIGGERS[ key ].event === candidate.event && TRIGGERS[ key ].type === candidate.type )
11427
13733
  ];
11428
13734
 
11429
13735
  if( ! known ){
11430
13736
 
11431
13737
  throw new Error(
11432
- manifest.slug + ' step ' + type + ' declares trigger { event : ' + declared.trigger.event
11433
- + ', type : ' + declared.trigger.type + ' }, which nothing dispatches'
13738
+ manifest.slug + ' step ' + type + ' declares trigger { event : ' + candidate.event
13739
+ + ', type : ' + candidate.type + ' }, which nothing dispatches'
11434
13740
  );
11435
13741
 
11436
13742
  }
@@ -11582,6 +13888,16 @@ const connectionSteps = ( env = {} ) => Object.entries( availableConnections( en
11582
13888
 
11583
13889
  // Which vendors implement a given hook. The uniform surface makes this total —
11584
13890
  // every vendor appears in exactly one of the two lists, never neither.
13891
+ // DOES THIS VENDOR IMPLEMENT THIS HOOK? The same walk `hookSupport` and
13892
+ // `build()` use, exported because callers outside this package were asking it by
13893
+ // naming a slug — drawbridge-sync dispatched the post-connect register job on
13894
+ // `slug === 'shopify'`, with a comment explaining that no other vendor has one,
13895
+ // which is this function's answer written out by hand.
13896
+ //
13897
+ // TAKES THE HOOKS, not the manifest, so a caller holding a built manifest and a
13898
+ // caller holding a fixture ask the same way.
13899
+ const implementsHook = ( hooks, name ) => implemented( hooks, name );
13900
+
11585
13901
  const hookSupport = ( name ) => ({
11586
13902
  no : Object.keys( connections ).filter( ( slug ) => ! implemented( connections[ slug ].hooks, name ) ),
11587
13903
  yes : Object.keys( connections ).filter( ( slug ) => implemented( connections[ slug ].hooks, name ) )
@@ -11925,4 +14241,4 @@ const resolveConnection = ( item, data, env = {} ) => {
11925
14241
 
11926
14242
  };
11927
14243
 
11928
- export { tokenSettings as $, ANSWER_KEYS as A, hookSupport as B, isStale as C, isStatus as D, ERROR_SOURCES as E, mergeSettings as F, GROUPS as G, HOOKS as H, INPUTS as I, projectConnection as J, publicConnectionKeys as K, publicSettingsBySlug as L, redactSettings as M, resolveConnection as N, OAUTH_ENDPOINTS as O, PLATFORM_CREDENTIALS as P, runHook as Q, RETIRED as R, STATUSES as S, TRIGGERS as T, scopesMessage as U, stepLabels as V, WRITE_OPERATIONS as W, stepQueues as X, stepRoutes as Y, surviving as Z, systemSteps as _, AUTH_KINDS as a, vendors as a0, AUTH_TYPES as b, HOOK_CONFIG as c, HOOK_EFFECTS as d, HOOK_NAMES as e, HOOK_OPTIONS as f, HOOK_PROPS as g, HOOK_SLOT_PROPS as h, OAUTH_FIELDS as i, OUTCOMES as j, STEPS as k, STEP_TYPES as l, TRIGGER_TYPES as m, accessToken as n, answerOf as o, assess as p, availableConnections as q, build as r, buildVendor as s, catalogConnections as t, channels as u, connectFields as v, connectionSteps as w, connections as x, effectsOf as y, ensureSystemWorkflows as z };
14244
+ export { scopesMessage as $, ANSWER_KEYS as A, ensureSystemWorkflows as B, hookSupport as C, implementsHook as D, ERROR_SOURCES as E, isStale as F, GROUPS as G, HOOKS as H, INPUTS as I, isStatus as J, mergeSettings as K, perform as L, MARKERS as M, projectConnection as N, OAUTH_ENDPOINTS as O, PLATFORM_CREDENTIALS as P, publicConnectionKeys as Q, RETIRED as R, STATUSES as S, TRIGGERS as T, publicSettingsBySlug as U, reconcileConnectionScopes as V, WRITE_OPERATIONS as W, redactSettings as X, reference as Y, resolveConnection as Z, runHook as _, AUTH_KINDS as a, spliced as a0, stepLabels as a1, stepQueues as a2, stepRoutes as a3, surviving as a4, systemSteps as a5, systemWorkflows as a6, tokenSettings as a7, vendors as a8, AUTH_TYPES as b, HOOK_CONFIG as c, HOOK_EFFECTS as d, HOOK_NAMES as e, HOOK_OPTIONS as f, HOOK_PROPS as g, HOOK_SLOT_PROPS as h, OAUTH_FIELDS as i, OUTCOMES as j, STEPS as k, STEP_TYPES as l, TRIGGER_TYPES as m, accessToken as n, answerOf as o, assess as p, availableConnections as q, build as r, buildVendor as s, catalogConnections as t, channels as u, connectFields as v, connectionSettings as w, connectionSteps as x, connections as y, effectsOf as z };