@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.
- package/dist/admin-B9ZaLvan.d.cts +697 -0
- package/dist/admin-C3HtEM6h.d.ts +697 -0
- package/dist/billing-Bc4yo9XG.d.cts +175 -0
- package/dist/billing-mNsKflmQ.d.ts +175 -0
- package/dist/billing.d.cts +1 -1
- package/dist/billing.d.ts +1 -1
- package/dist/connections/index.cjs +3391 -271
- package/dist/connections/index.d.cts +13 -4
- package/dist/connections/index.d.ts +13 -4
- package/dist/connections/index.js +3388 -270
- package/dist/features.cjs +3080 -241
- package/dist/features.d.cts +13 -4
- package/dist/features.d.ts +13 -4
- package/dist/features.js +3120 -275
- package/dist/http.cjs +10 -1
- package/dist/http.d.cts +10 -1
- package/dist/http.d.ts +10 -1
- package/dist/http.js +10 -1
- package/dist/{index-qY18QITf.d.cts → index-C2rxasGZ.d.cts} +2670 -354
- package/dist/{index-B8JhYfvU.d.ts → index-DOYCXtd7.d.ts} +2670 -354
- package/dist/oauth/index.d.cts +1 -1
- package/dist/oauth/index.d.ts +1 -1
- package/dist/oauth-BJDh0sdM.d.cts +527 -0
- package/dist/oauth-DveZMLHx.d.ts +527 -0
- package/dist/partner-BOZltuh2.d.ts +94 -0
- package/dist/partner-ed2OfW1J.d.cts +94 -0
- package/dist/plans.cjs +3079 -240
- package/dist/plans.d.cts +13 -4
- package/dist/plans.d.ts +13 -4
- package/dist/plans.js +3120 -275
- package/dist/pricing.cjs +3112 -273
- package/dist/pricing.d.cts +13 -4
- package/dist/pricing.d.ts +13 -4
- package/dist/pricing.js +3117 -272
- package/dist/providers.cjs +3082 -262
- package/dist/providers.d.cts +12 -3
- package/dist/providers.d.ts +12 -3
- package/dist/providers.js +3086 -260
- package/dist/sendgrid.cjs +10 -1
- package/dist/sendgrid.js +10 -1
- package/dist/shopify/admin.cjs +562 -0
- package/dist/shopify/admin.d.cts +3 -0
- package/dist/shopify/admin.d.ts +3 -0
- package/dist/shopify/admin.js +528 -0
- package/dist/shopify/billing.cjs +166 -0
- package/dist/shopify/billing.d.cts +3 -0
- package/dist/shopify/billing.d.ts +3 -0
- package/dist/shopify/billing.js +140 -0
- package/dist/shopify/constants.cjs +63 -0
- package/dist/shopify/constants.d.cts +58 -0
- package/dist/shopify/constants.d.ts +58 -0
- package/dist/shopify/constants.js +32 -0
- package/dist/shopify/oauth.cjs +509 -0
- package/dist/shopify/oauth.d.cts +7 -0
- package/dist/shopify/oauth.d.ts +7 -0
- package/dist/shopify/oauth.js +466 -0
- package/dist/shopify/partner.cjs +156 -0
- package/dist/shopify/partner.d.cts +3 -0
- package/dist/shopify/partner.d.ts +3 -0
- package/dist/shopify/partner.js +130 -0
- package/dist/shopify/storefront.cjs +611 -0
- package/dist/shopify/storefront.d.cts +3 -0
- package/dist/shopify/storefront.d.ts +3 -0
- package/dist/shopify/storefront.js +576 -0
- package/dist/storefront-C8FKOGeD.d.cts +659 -0
- package/dist/storefront-DJFGLqPl.d.ts +659 -0
- package/dist/twilio.cjs +10 -1
- package/dist/twilio.js +10 -1
- package/package.json +98 -68
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { isDuplicateKey } from './usage.js';
|
|
2
|
+
import { decrypt, encrypt } from './encrypt.js';
|
|
2
3
|
import { authToken } from './connections/oauth.js';
|
|
3
4
|
import { toE164, detectCountry } from './phone.js';
|
|
4
5
|
import crypto, { randomUUID, createHmac, timingSafeEqual, createVerify, createHash } from 'node:crypto';
|
|
5
6
|
import { request } from './http.js';
|
|
6
7
|
import { customAlphabet } from 'nanoid';
|
|
8
|
+
import { i as shopifyAdmin } from './admin-C3HtEM6h.js';
|
|
9
|
+
import { a as shopifyBilling } from './billing-mNsKflmQ.js';
|
|
10
|
+
import { s as shopifyOauth } from './oauth-DveZMLHx.js';
|
|
11
|
+
import { s as shopifyPartner } from './partner-BOZltuh2.js';
|
|
12
|
+
import { s as shopifyStorefront } from './storefront-DJFGLqPl.js';
|
|
7
13
|
import { toCanonicalEmail } from './email.js';
|
|
8
|
-
import {
|
|
14
|
+
import { tokenTypes, hashToken } from './oauth/index.js';
|
|
15
|
+
import { sanitizeDomain } from './sanitize.js';
|
|
9
16
|
import { slugify } from './slugify.js';
|
|
10
17
|
import { safeRequest } from './safe-http.js';
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
|
|
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
|
-
|
|
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,
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
//
|
|
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' :
|
|
653
|
-
|
|
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' :
|
|
656
|
-
'
|
|
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
|
|
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
|
-
|
|
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
|
|
1202
|
-
//
|
|
1203
|
-
//
|
|
1204
|
-
//
|
|
1205
|
-
// on both clusters
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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
|
-
//
|
|
1272
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 }, {
|
|
2071
|
-
|
|
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$
|
|
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
|
-
//
|
|
2715
|
-
//
|
|
2716
|
-
//
|
|
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
|
-
//
|
|
2721
|
-
//
|
|
2722
|
-
//
|
|
2723
|
-
errors :
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
-
//
|
|
4259
|
-
//
|
|
4260
|
-
//
|
|
4261
|
-
//
|
|
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
|
-
//
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
-
//
|
|
6611
|
-
//
|
|
6612
|
-
//
|
|
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
|
-
//
|
|
6617
|
-
//
|
|
6618
|
-
//
|
|
6619
|
-
errors :
|
|
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$
|
|
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
|
-
//
|
|
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
|
-
//
|
|
7628
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
7845
|
-
//
|
|
7846
|
-
//
|
|
7847
|
-
//
|
|
7848
|
-
//
|
|
7849
|
-
//
|
|
7850
|
-
|
|
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
|
-
|
|
10764
|
+
if( ! shop ) throw refusal( 'shop is required', 400 );
|
|
8484
10765
|
|
|
8485
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8564
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10796
|
+
}
|
|
8577
10797
|
|
|
8578
|
-
|
|
8579
|
-
|
|
8580
|
-
|
|
10798
|
+
return {
|
|
10799
|
+
message : 'Link token read.',
|
|
10800
|
+
request : { shop },
|
|
10801
|
+
result : { token : decrypt( record.linkToken )?.token ?? null }
|
|
10802
|
+
};
|
|
8581
10803
|
|
|
8582
|
-
|
|
10804
|
+
},
|
|
8583
10805
|
|
|
8584
|
-
|
|
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
|
-
|
|
10808
|
+
const shop = sanitizeDomain( context?.shop );
|
|
10809
|
+
const token = context?.token;
|
|
8606
10810
|
|
|
8607
|
-
|
|
10811
|
+
if( ! shop || ! token ) throw refusal( 'shop and token are required', 400 );
|
|
8608
10812
|
|
|
8609
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8837
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8871
|
-
|
|
8872
|
-
|
|
8873
|
-
|
|
8874
|
-
|
|
8875
|
-
|
|
8876
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
-
//
|
|
9460
|
-
//
|
|
9461
|
-
//
|
|
9462
|
-
//
|
|
9463
|
-
//
|
|
9464
|
-
//
|
|
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
|
|
9687
|
-
//
|
|
9688
|
-
//
|
|
9689
|
-
//
|
|
9690
|
-
//
|
|
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
|
-
//
|
|
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$
|
|
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 } = {} ) => (
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
-
|
|
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 ===
|
|
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 : ' +
|
|
11433
|
-
+ ', type : ' +
|
|
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 {
|
|
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 };
|