@drawbridge/drawbridge-utils 0.0.106 → 0.0.108

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.
@@ -0,0 +1,1369 @@
1
+ export { consentUrl, exchange, pkcePair, refresh } from './oauth.cjs';
2
+ import { createHmac, timingSafeEqual } from 'node:crypto';
3
+
4
+ // THE HOOK VOCABULARY. Closed, and every connection answers all of it.
5
+ //
6
+ // A vendor does not get to be silent about a hook. It declares `true` where it
7
+ // implements one and `false` where it does not, and `build()` rejects a manifest
8
+ // that leaves any entry out. That is what makes the connections COMPARABLE: the
9
+ // table of vendors against hooks has no empty cells, so "does Shopify do
10
+ // catalog.products" is answered by the manifest rather than by reading sync.
11
+ //
12
+ // The alternative — carry only the hooks you implement, and let the caller
13
+ // filter — is what drawbridge-growth does, and it drifts: 26 of its 35 hooks are
14
+ // implemented by exactly one vendor, and nothing tells you whether the other
15
+ // twelve considered that hook and declined or never knew it existed.
16
+ //
17
+ // `false` is a DECISION, recorded. It is not the same as a hook that is
18
+ // supported and returned nothing, and it is not the same as a connection that
19
+ // is not currently connected. All three are different answers to "why is there
20
+ // no data", and an operator debugging an empty result needs to tell them apart.
21
+ const HOOKS = Object.freeze({
22
+
23
+ // Proving and holding the credential.
24
+ auth : Object.freeze([
25
+ // Accept what the merchant supplied — a form submission or an OAuth
26
+ // callback — and store what is needed to call the vendor later.
27
+ 'connect',
28
+ // Is the stored credential still good? Answered by the cheapest real call
29
+ // the vendor offers, never by inspecting what we stored: a key that was
30
+ // revoked at the vendor still looks perfect in our database.
31
+ 'probe',
32
+ // Which permissions we asked for and no longer hold. Distinct from probe:
33
+ // the credential can be valid and the grant still be too narrow.
34
+ 'scopes',
35
+ // Revoke at the vendor and drop what we hold.
36
+ 'disconnect'
37
+ ]),
38
+
39
+ // What happens around connecting and disconnecting, beyond the credential.
40
+ lifecycle : Object.freeze([
41
+ // Post-connect setup: register the vendor's webhooks, create the system
42
+ // workflows that describe them.
43
+ 'register',
44
+ // Re-pull vendor state we mirror, after a reconnect or on a schedule.
45
+ 'rehydrate',
46
+ // Undo `register` — deregister webhooks, release anything reserved.
47
+ 'cleanup'
48
+ ]),
49
+
50
+ // Receiving from the vendor.
51
+ //
52
+ // Split across TWO SERVICES, and the split is the point. `verify` and `event`
53
+ // and `receive` run in drawbridge-webhooks against the vendor's open
54
+ // connection, where the budget is whatever that vendor's timeout is —
55
+ // Shopify's is about five seconds, and missing it means they retry and the
56
+ // delivery is buffered twice. `process` runs in drawbridge-sync off the
57
+ // buffer, where it can take as long as it needs and retry without the vendor
58
+ // ever knowing.
59
+ //
60
+ // One hook spanning that seam would hide it, and the thing it hides is the
61
+ // one most likely to bite: slow work written on the receiving side turns
62
+ // into duplicate deliveries.
63
+ inbound : Object.freeze([
64
+ // Prove the request came from the vendor, AND return the payload it
65
+ // carries. One hook rather than two because for some vendors they are
66
+ // genuinely one operation: a JWT-bodied webhook (Kinde) cannot be decoded
67
+ // into anything trustworthy without verifying it first, and a decode that
68
+ // runs before the signature check is exactly the bug this shape prevents.
69
+ //
70
+ // So the raw bytes stop here. Everything downstream receives the payload
71
+ // this returned, which means nothing downstream can act on unverified
72
+ // data even by mistake.
73
+ //
74
+ // It is also where a request gets refused for any other reason — an event
75
+ // outside the allowlist, a connection in the wrong state. Anything that
76
+ // can reject belongs here, so the route holds no rules of its own.
77
+ 'verify',
78
+ // Name the event, from wherever this vendor puts it. A header for
79
+ // Shopify, the route itself for Twilio, a claim in the payload for a
80
+ // JWT-bodied vendor.
81
+ 'event',
82
+ // What to buffer and what to answer RIGHT NOW, while the vendor waits.
83
+ // Thin by obligation. Most vendors only shape a buffer doc here; one that
84
+ // must reply with content (Twilio answers HELP inline with TwiML, which
85
+ // carriers require) returns that too.
86
+ 'receive',
87
+ // Do the work the event implies. Runs in drawbridge-sync, off the buffer.
88
+ //
89
+ // DECLARED here, IMPLEMENTED there — the same split as the step handlers,
90
+ // and for the same reason: this half needs controllers, queues and vendor
91
+ // SDKs, and putting those behind a published package makes every consumer
92
+ // carry them. The declaration is what proves the implementation exists.
93
+ 'process'
94
+ ]),
95
+
96
+ // Vendor data a campaign draws on. Named for what every store platform has,
97
+ // not for what Shopify calls it: Shopify says discounts, Stripe says coupons
98
+ // and promotion codes, BigCommerce says coupons and promotions.
99
+ catalog : Object.freeze([
100
+ 'products',
101
+ // Shopify folds price into the product variant; Stripe makes Price a
102
+ // first-class object beside Product. Declared so a vendor that separates
103
+ // them has somewhere to answer.
104
+ 'prices',
105
+ 'promotions'
106
+ ])
107
+
108
+ });
109
+
110
+ // Flat 'domain.verb' names, which is how a manifest declares support and how a
111
+ // caller asks for one.
112
+ const HOOK_NAMES = Object.freeze(
113
+ Object.entries( HOOKS ).flatMap( ( [ domain, verbs ] ) => verbs.map( ( verb ) => domain + '.' + verb ) )
114
+ );
115
+
116
+ // Why a hook produced nothing. Returned by the call surface rather than an empty
117
+ // array, because "not supported", "not connected" and "supported, found nothing"
118
+ // lead to three different next actions and collapsing them loses the one that
119
+ // matters.
120
+ const OUTCOMES = Object.freeze({
121
+ answered : 'answered',
122
+ disconnected : 'disconnected',
123
+ failed : 'failed',
124
+ // Declared supported, implemented in a consumer rather than in this package —
125
+ // sync owns the step handlers and lifecycle jobs. Different from unsupported,
126
+ // which means the vendor cannot do it at all.
127
+ unimplemented : 'unimplemented',
128
+ unsupported : 'unsupported'
129
+ });
130
+
131
+ // HOW A VENDOR IS CONNECTED. Four kinds, because the merchant's experience of
132
+ // each is genuinely different and the dashboard renders from this:
133
+ //
134
+ // keys they paste a credential in (Mailchimp today)
135
+ // oauth they consent at the vendor and come back (Mailchimp next, Klaviyo)
136
+ // install they install an app at the vendor (Shopify)
137
+ // generated Drawbridge mints it, nobody types anything (Webhooks)
138
+ //
139
+ // `install` is separate from `oauth` on purpose. Both end in a token, but the
140
+ // merchant never sees a consent screen we sent them to — they start at the
141
+ // vendor's app store — and a dashboard that offers a Connect button for one is
142
+ // lying about the other.
143
+ const AUTH_TYPES = Object.freeze([ 'generated', 'install', 'keys', 'oauth' ]);
144
+
145
+ // WHAT KIND OF VENDOR THIS IS. Closed, because a card list that works at three
146
+ // connections stops working the day every CMS joins it, and a category nobody
147
+ // spelled right is a filter chip that silently shows nothing.
148
+ const CATEGORIES = Object.freeze([ 'commerce', 'contacts', 'developer', 'messaging' ]);
149
+
150
+ // WHAT AN OAUTH VENDOR MUST DECLARE, so one implementation serves all of them
151
+ // rather than one module per vendor.
152
+ //
153
+ // authorize the consent url
154
+ // token the exchange/refresh url
155
+ // client the ENV VAR NAMES holding our own id and secret, never the values
156
+ // redirect our callback path — DECLARED, never derived from the slug. It is
157
+ // registered in the vendor's console and they refuse anything that
158
+ // does not byte-match, so it is a fact about someone else's records
159
+ // rather than a string this code gets to compute.
160
+ // scopes what we ask for
161
+ // params vendor quirks (Google wants access_type=offline)
162
+ // pkce Klaviyo's OAuth 2.1 REQUIRES a code_verifier/code_challenge pair;
163
+ // most vendors do not. A flag rather than a Klaviyo module.
164
+ const OAUTH_FIELDS = Object.freeze([ 'authorize', 'client', 'redirect', 'token' ]);
165
+
166
+ // HOW A FIELD IS EDITED. Closed, because the dashboard renders one form for
167
+ // every connection and it can only render what it has a component for. An open
168
+ // list means a vendor declares `kind : "toggle"`, the form falls through to a
169
+ // text box, and a merchant types the word true into it.
170
+ //
171
+ // text a line of text
172
+ // password a secret — masked, and never returned once stored
173
+ // email text, validated as an address
174
+ // url text, validated as a url
175
+ // number a numeric field
176
+ // textarea multi-line, for things like an allow-list or a template
177
+ // select a fixed choice; the field must also declare `options`
178
+ // checkbox on or off
179
+ //
180
+ // A field with NO input is not editable at all: it is read-only on the page,
181
+ // which is how a generated secret and an installed store domain are shown.
182
+ const INPUTS = Object.freeze([
183
+ 'checkbox',
184
+ 'email',
185
+ 'number',
186
+ 'password',
187
+ 'select',
188
+ 'text',
189
+ 'textarea',
190
+ 'url'
191
+ ]);
192
+
193
+ // Klaviyo, exported from the brand kit and left as authored — the fills are the
194
+ // vendor's own mark, not a recolour.
195
+ //
196
+ // A .js wrapper around otherwise untouched SVG so `node --test` can run against
197
+ // lib/ directly. A bare .svg import would need a bundler loader and force the
198
+ // tests onto dist/, which is a worse trade than one line of wrapper.
199
+ var icon$3 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
200
+ <rect width="500" height="500" fill="white"/>
201
+ <path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
202
+ </svg>`;
203
+
204
+ // Klaviyo — contact and event sync, over OAuth.
205
+ //
206
+ // THE FIRST VENDOR ADDED AS A MANIFEST AND NOTHING ELSE. No form component, no
207
+ // connect route, no consent builder, no token exchange: the shared runner in
208
+ // oauth.js reads the descriptor below and does all of it. That is the test of
209
+ // whether this exercise worked, and the things it forced out are the interesting
210
+ // part — clientAuth and pkce both exist because Klaviyo needs them and Google
211
+ // does not.
212
+ var klaviyo = {
213
+ // OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
214
+ // exchange without a code_verifier matching the challenge the consent
215
+ // carried. Most vendors treat it as optional hardening; this one does not,
216
+ // which is why it is a descriptor flag and not a global.
217
+ //
218
+ // clientAuth is the other thing Klaviyo does differently. The token endpoint
219
+ // wants HTTP Basic — base64( client_id : client_secret ) in an Authorization
220
+ // header — and rejects the same pair sent as form fields, which is how every
221
+ // Google product wants it.
222
+ auth : {
223
+ oauth : {
224
+ authorize : 'https://a.klaviyo.com/oauth/authorize',
225
+ // NAMES the env vars holding OUR application's client. One identity,
226
+ // every merchant — the token is the merchant's and arrives from their
227
+ // own consent, which is what stops one organization reading another's
228
+ // data.
229
+ client : {
230
+ id : 'KLAVIYO_OAUTH_CLIENT_ID',
231
+ secret : 'KLAVIYO_OAUTH_CLIENT_SECRET'
232
+ },
233
+ clientAuth : 'basic',
234
+ pkce : true,
235
+ // DECLARED, never derived from the slug. It is registered in Klaviyo's
236
+ // app settings and they refuse anything that does not byte-match, so
237
+ // it is a fact about someone else's records rather than a string this
238
+ // code computes. Deriving one from a provider key produced
239
+ // redirect_uri_mismatch on a connection nobody had touched.
240
+ redirect : '/api/connection/klaviyo/callback',
241
+ // Space separated. accounts:read is required by Klaviyo on every app
242
+ // and must stay in the list; the rest are what a contact sync needs.
243
+ scopes : 'accounts:read lists:read lists:write profiles:read profiles:write',
244
+ token : 'https://a.klaviyo.com/oauth/token'
245
+ },
246
+ type : 'oauth'
247
+ },
248
+ category : 'contacts',
249
+ confirm : 'Disconnecting revokes Drawbridge\'s access to your Klaviyo account. Your profiles and lists stay in both Klaviyo and Drawbridge — neither is deleted.',
250
+ connect : {
251
+ errors : {
252
+ denied : 'The Klaviyo authorization was declined, so nothing was connected.',
253
+ invalid : 'We couldn\'t complete the Klaviyo connection. Try connecting again.'
254
+ }
255
+ },
256
+ description : [
257
+ 'Connecting Klaviyo lets Drawbridge sync the contacts your campaigns collect into a Klaviyo list, so the people who enter a giveaway can be marketed to alongside the rest of your audience.',
258
+ 'You authorize Drawbridge from inside Klaviyo and can revoke that access there at any time. Drawbridge never sees or stores your Klaviyo password, and only asks for the permissions listed on the consent screen.',
259
+ '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.'
260
+ ],
261
+ excerpt : 'Sync the contacts your campaigns collect into a Klaviyo list.',
262
+ feature : 'organization:connection:klaviyo',
263
+ // Nothing typed. The consent returns the grant, and the account it belongs to
264
+ // is read back from Klaviyo rather than asked for.
265
+ fields : [
266
+ {
267
+ key : 'account',
268
+ label : 'Klaviyo account'
269
+ }
270
+ ],
271
+ icon: icon$3,
272
+ label : 'klaviyo',
273
+ requires : [
274
+ 'KLAVIYO_OAUTH_CLIENT_ID',
275
+ 'KLAVIYO_OAUTH_CLIENT_SECRET'
276
+ ],
277
+ setup : [
278
+ 'Press Connect. Drawbridge sends you to Klaviyo to approve access.',
279
+ 'Sign in to Klaviyo if you are not already, and choose the account to connect.',
280
+ 'Approve the permissions Klaviyo lists. You are returned here and the connection shows Active.',
281
+ 'You can revoke access at any time from Klaviyo, under Integrations.'
282
+ ],
283
+ slug : 'klaviyo',
284
+ // No steps yet. The sync itself is unbuilt, and a step offered in the builder
285
+ // that nothing runs is worse than no step at all — the merchant configures it
286
+ // and waits for something that never happens.
287
+ steps : {},
288
+ supports : {
289
+ 'auth.connect' : true,
290
+ 'auth.disconnect' : true,
291
+ // The refresh mint IS the probe: a revoked or rotated grant fails there in
292
+ // Klaviyo's own words rather than as an empty sync three steps later.
293
+ 'auth.probe' : true,
294
+ // Klaviyo scopes are fixed at app level and re-consented, not drifted.
295
+ 'auth.scopes' : false,
296
+ 'catalog.prices' : false,
297
+ 'catalog.products' : false,
298
+ 'catalog.promotions' : false,
299
+ 'inbound.event' : false,
300
+ 'inbound.process' : false,
301
+ 'inbound.receive' : false,
302
+ 'inbound.verify' : false,
303
+ 'lifecycle.cleanup' : false,
304
+ 'lifecycle.register' : false,
305
+ 'lifecycle.rehydrate' : false
306
+ },
307
+ title : 'Klaviyo'
308
+ };
309
+
310
+ // Mailchimp, exported from the brand kit and left as authored — the fills are the
311
+ // vendor's own mark, not a recolour.
312
+ //
313
+ // A .js wrapper around otherwise untouched SVG so `node --test` can run against
314
+ // lib/ directly. A bare .svg import would need a bundler loader and force the
315
+ // tests onto dist/, which is a worse trade than one line of wrapper.
316
+ var icon$2 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
317
+ <rect width="500" height="500" fill="#FFE01B"/>
318
+ <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"/>
319
+ <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"/>
320
+ </svg>`;
321
+
322
+ // Mailchimp — contact sync, not a sender.
323
+ //
324
+ // Deliberately no `group`. Mailchimp and SendGrid shared one while they were
325
+ // SENDERS, where an org picking two providers to send the same mail was
326
+ // meaningless. As contact syncs they are destinations, and a merchant could
327
+ // reasonably keep several up to date, so the exclusivity is gone.
328
+ var mailchimp = {
329
+ // Keys TODAY. Mailchimp integrations authenticate with OAuth 2 (authorization
330
+ // code) and that is where this goes, so the endpoints are recorded here
331
+ // rather than researched again later:
332
+ //
333
+ // authorize https://login.mailchimp.com/oauth2/authorize
334
+ // token https://login.mailchimp.com/oauth2/token
335
+ // metadata https://login.mailchimp.com/oauth2/metadata
336
+ //
337
+ // The metadata call is Mailchimp's quirk and cannot be skipped: the access
338
+ // token alone is not enough to call the Marketing API, because every account
339
+ // lives behind a data-centre prefix (us1, us19...) that only that call
340
+ // returns and every subsequent request needs in its host. No PKCE.
341
+ //
342
+ // Switching is filling in auth.oauth and moving the apiKey field out. It is
343
+ // still a publish -- a manifest change always is -- but a value change
344
+ // rather than a shape one.
345
+ auth : {
346
+ type : 'keys'
347
+ },
348
+ category : 'contacts',
349
+ confirm : 'Disconnecting removes your stored Mailchimp key. Your contacts stay in both Drawbridge and Mailchimp — neither list is deleted.',
350
+ description : [
351
+ 'Drawbridge no longer sends email through Mailchimp. Notification email now sends from Drawbridge itself, and verifying a domain under Networking in your organization settings puts your own brand in the from line.',
352
+ 'This connection is becoming the way your Drawbridge contacts sync into a Mailchimp audience. Audience syncing is not live yet, so a key stored here does nothing today.'
353
+ ],
354
+ excerpt : 'Sync your Drawbridge contacts into a Mailchimp audience.',
355
+ feature : 'organization:connection:mailchimp',
356
+ fields : [
357
+ {
358
+ input : 'password',
359
+ key : 'apiKey',
360
+ label : 'Mailchimp API key',
361
+ message : 'Your Mailchimp API key',
362
+ placeholder : '••••••••••••••••••••••••••••••••-us1',
363
+ redact : true,
364
+ required : true
365
+ }
366
+ ],
367
+ icon: icon$2,
368
+ label : 'mailchimp',
369
+ // Uniform surface, honest answers. A key is stored and can be removed; nothing
370
+ // else is built yet, because audience sync has not shipped. Every false here
371
+ // is "not yet", not "never" — when the sync lands, probe and catalog become
372
+ // the first two to flip.
373
+ supports : {
374
+ 'auth.connect' : true,
375
+ 'auth.disconnect' : true,
376
+ 'auth.probe' : false,
377
+ 'auth.scopes' : false,
378
+ 'catalog.prices' : false,
379
+ 'catalog.products' : false,
380
+ 'catalog.promotions' : false,
381
+ 'inbound.event' : false,
382
+ 'inbound.process' : false,
383
+ 'inbound.receive' : false,
384
+ 'inbound.verify' : false,
385
+ 'lifecycle.cleanup' : false,
386
+ 'lifecycle.register' : false,
387
+ 'lifecycle.rehydrate' : false
388
+ },
389
+ setup : [
390
+ 'In Mailchimp, open Account & billing, then Extras, then API keys.',
391
+ 'Create a key and copy it.',
392
+ 'Paste it here. The key ends in a data-centre suffix like -us19, which tells Drawbridge which Mailchimp server your account is on.'
393
+ ],
394
+ slug : 'mailchimp',
395
+ // No steps: audience sync has not shipped, so this vendor contributes nothing
396
+ // to a workflow yet. An empty steps object is the honest declaration — the
397
+ // catalog renders the connection, and no builder offers a step it cannot run.
398
+ steps : {},
399
+ tasks : () => [
400
+ {
401
+ message : 'Contact syncing to Mailchimp audiences has not shipped yet, and this connection no longer sends your email. Nothing is being sent to Mailchimp right now.',
402
+ title : 'Audience sync not available yet',
403
+ type : 'warning'
404
+ }
405
+ ],
406
+ title : 'Mailchimp'
407
+ };
408
+
409
+ // Shopify, exported from the brand kit and left as authored — the fills are the
410
+ // vendor's own mark, not a recolour.
411
+ //
412
+ // A .js wrapper around otherwise untouched SVG so `node --test` can run against
413
+ // lib/ directly. A bare .svg import would need a bundler loader and force the
414
+ // tests onto dist/, which is a worse trade than one line of wrapper.
415
+ var icon$1 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
416
+ <rect width="500" height="500" fill="white"/>
417
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M309.524 407.192L308.799 128.423C306.921 126.545 303.258 127.112 301.827 127.531L292.29 130.487C291.113 126.613 289.585 122.854 287.726 119.258C280.959 106.337 271.069 99.5052 259.096 99.4866H259.059C258.259 99.4866 257.469 99.5609 256.67 99.626L256.577 99.6353C256.231 99.2089 255.871 98.7935 255.499 98.3897C250.293 92.8125 243.601 90.0889 235.588 90.3213C220.139 90.7675 204.755 101.941 192.271 121.786C183.487 135.757 176.823 153.298 174.917 166.878L144.493 176.313C135.542 179.129 135.263 179.408 134.082 187.858C133.199 194.253 109.766 375.69 109.766 375.69L306.159 409.692L309.524 407.192ZM245.181 103.065C242.569 101.346 239.511 100.546 235.885 100.621C212.033 101.308 191.23 138.611 185.923 163.467L208.771 156.384L212.851 155.119C215.845 139.336 223.355 122.957 233.181 112.416C236.616 108.639 240.671 105.477 245.172 103.065H245.181ZM224.145 151.615L256.94 141.446C257.042 132.894 256.112 120.252 251.836 111.329C247.282 113.207 243.452 116.497 240.7 119.444C233.329 127.373 227.315 139.466 224.155 151.615H224.145ZM267.211 138.267L282.455 133.536C280.02 125.616 274.238 112.342 262.517 110.111C266.161 119.527 267.099 130.431 267.211 138.267Z" fill="#95BF47"/>
418
+ <path d="M353.528 149.156C352.356 149.063 329.657 148.709 329.657 148.709C329.657 148.709 310.666 130.249 308.789 128.362C308.062 127.691 307.141 127.268 306.158 127.153V409.64L391.257 388.456C391.257 388.456 356.53 153.366 356.307 151.758C356.199 151.075 355.866 150.448 355.361 149.976C354.856 149.505 354.216 149.216 353.528 149.156Z" fill="#5E8E3E"/>
419
+ <path d="M258.993 193.019L249.103 230.052C249.103 230.052 238.079 225.023 225 225.85C205.833 227.059 205.628 239.171 205.824 242.21C206.865 258.756 250.376 262.381 252.821 301.171C254.745 331.688 236.656 352.574 210.592 354.21C179.313 356.19 162.089 337.711 162.089 337.711L168.716 309.472C168.716 309.472 186.052 322.569 199.921 321.686C208.993 321.119 212.228 313.738 211.903 308.514C210.536 286.921 175.102 288.185 172.862 252.695C170.985 222.811 190.57 192.554 233.803 189.821C250.46 188.762 258.993 193.028 258.993 193.028" fill="white"/>
420
+ </svg>`;
421
+
422
+ // THE SHARED HALF OF inbound.verify.
423
+ //
424
+ // A vendor whose scheme is "hash the raw body with a shared secret and compare,
425
+ // constant-time, against a header" declares that shape as `inbound.signature`
426
+ // data on its manifest (algorithm, encoding, the env var naming the secret) and
427
+ // wires this straight in as its hook — Shopify does exactly that.
428
+ //
429
+ // A vendor whose scheme is not that shape — Twilio signs the REGISTERED URL
430
+ // rather than the body, a JWT-bodied vendor verifies against a JWKS — writes its
431
+ // own inbound.verify instead. That is why verify is a hook and not config: this
432
+ // file covers the common case, not the contract.
433
+ const verifySignature = ({ body, descriptor, headers }) => {
434
+
435
+ const provided = headers[ descriptor.headers.signature ];
436
+
437
+ if( ! provided ){
438
+
439
+ throw Object.assign( new Error( 'Missing webhook signature' ), { status : 401 });
440
+
441
+ }
442
+
443
+ const digest = createHmac( descriptor.signature.algorithm, process.env[ descriptor.signature.secret ] )
444
+ .update( body )
445
+ .digest( descriptor.signature.encoding );
446
+
447
+ // Buffers of different lengths crash timingSafeEqual rather than compare
448
+ // false — decided here, before the length itself becomes a timing signal.
449
+ const digestBuffer = Buffer.from( digest, descriptor.signature.encoding );
450
+ const providedBuffer = Buffer.from( provided, descriptor.signature.encoding );
451
+
452
+ if(
453
+ digestBuffer.length !== providedBuffer.length ||
454
+ ! timingSafeEqual( digestBuffer, providedBuffer )
455
+ ){
456
+
457
+ throw Object.assign( new Error( 'Invalid webhook signature' ), { status : 401 });
458
+
459
+ }
460
+
461
+ // The proof and the parse happen together on purpose. Nothing downstream of
462
+ // inbound.verify ever sees the raw bytes, which is what makes acting on
463
+ // unverified data impossible rather than merely discouraged.
464
+ return JSON.parse( body.toString() );
465
+
466
+ };
467
+
468
+ // THE SHARED HALF OF inbound.event, for a vendor that puts it in a header.
469
+ const readEventHeader = ({ descriptor, headers }) => headers[ descriptor.headers.event ];
470
+
471
+ // WHERE THIS VENDOR PUTS THINGS ON AN INBOUND REQUEST. Header names are facts
472
+ // about someone else's product, so they belong beside the rest of the vendor
473
+ // rather than as string literals in a route — which is where they were, and is
474
+ // why a second inbound vendor meant a second route file.
475
+ //
476
+ // Pulled out to a const (rather than written inline under `inbound:` below) so
477
+ // the hooks further down can reference the SAME object the manifest publishes,
478
+ // instead of a second copy that could drift from it.
479
+ //
480
+ // `signature` describes an HMAC scheme the shared verifier can run: hash the
481
+ // raw body with the named secret and compare, constant-time, against the
482
+ // header. Vendors whose scheme is not that shape declare no signature block and
483
+ // implement inbound.verify themselves — a JWT-bodied vendor (Kinde) verifies
484
+ // against a JWKS and returns the decoded claims, and Twilio signs the
485
+ // registered URL rather than the body. That is why verify is a hook and not
486
+ // config.
487
+ const inbound = {
488
+ headers : {
489
+ event : 'x-shopify-topic',
490
+ id : 'x-shopify-webhook-id',
491
+ shop : 'x-shopify-shop-domain',
492
+ signature : 'x-shopify-hmac-sha256'
493
+ },
494
+ signature : {
495
+ algorithm : 'sha256',
496
+ encoding : 'base64',
497
+ secret : 'SHOPIFY_API_SECRET'
498
+ }
499
+ };
500
+
501
+ // Shopify's own GDPR deadline, not ours — these three are the only topics
502
+ // registered under `compliance_topics` in the app toml, and Shopify expects an
503
+ // answer even for a shop that has already uninstalled. Anything else on this
504
+ // action is refused here rather than buffered, the same way a bad signature is:
505
+ // it is a request that should never have arrived.
506
+ const COMPLIANCE_TOPICS = new Set([
507
+ 'customers/data_request',
508
+ 'customers/redact',
509
+ 'shop/redact'
510
+ ]);
511
+
512
+ // Shopify — installed from the App Store, never connected with keys.
513
+ var shopify = {
514
+ // INSTALL, not oauth. The grant is an OAuth grant, but the merchant never
515
+ // sees a consent screen we sent them to -- they start at the App Store, and
516
+ // the install completes inside Shopify admin without redirecting back. A
517
+ // Connect button here would be lying about where connecting happens.
518
+ auth : {
519
+ type : 'install'
520
+ },
521
+ category : 'commerce',
522
+ confirm : 'Disconnecting deactivates all products from this store, drafts any advertisements that use them, disables Shopify steps in your workflows until you reconnect, and stops revenue tracking for this organization.',
523
+ // How connecting is DESCRIBED — the copy and destination. What kind of connect
524
+ // it is lives in auth.type, once, so the two cannot disagree.
525
+ //
526
+ // The redirect title is copy: it names where the link GOES rather than what it
527
+ // does, since installing happens on the App Store listing and the dashboard
528
+ // must never imply a store can be linked from inside it.
529
+ connect : {
530
+ errors : {
531
+ conflict : 'This store is already connected to another Drawbridge organization.',
532
+ currency : 'This store settles in a currency we can\'t bill yet. Connect a store with a supported settlement currency.',
533
+ invalid : 'We couldn\'t verify the install. Please try connecting again from the Shopify App Store.'
534
+ },
535
+ redirect : {
536
+ env : 'SHOPIFY_APP_LISTING_URL',
537
+ title : 'View on the Shopify App Store'
538
+ }
539
+ },
540
+ description : [
541
+ 'Installing the Drawbridge app from the Shopify App Store links your store to a single Drawbridge organization and makes your product catalog available inside Drawbridge, so you can feature products in your campaigns and advertisements.',
542
+ 'Drawbridge attributes orders that originate from your campaigns — matched through cart parameters and lead-mapped discount codes — so you can see the revenue each campaign drives.',
543
+ 'On connect, Drawbridge registers webhooks for product and order updates to keep your catalog and revenue in sync. Disconnecting removes those webhooks and unlinks the catalog.'
544
+ ],
545
+ excerpt : 'Connect your Shopify store to feature products in your campaigns and track conversions.',
546
+ feature : 'organization:connection:shopify',
547
+ fields : [
548
+ {
549
+ // `shop` on the connection record wins when present — it is written by
550
+ // the install, while settings.domain is the stored copy.
551
+ from : 'shop',
552
+ key : 'domain',
553
+ label : 'Store domain'
554
+ }
555
+ ],
556
+ group : 'ecommerce',
557
+ // verify and event lean entirely on the shared HMAC helper — Shopify's scheme
558
+ // is exactly the shape it covers, so there is nothing vendor-specific to
559
+ // write for either. receive is the one hook that genuinely differs by
560
+ // action: /events buffers whatever arrives with the shop domain stamped on;
561
+ // /compliance enforces the topic allowlist above, because answering one late
562
+ // is a legal deadline rather than a retry.
563
+ hooks : {
564
+ 'inbound.event' : ( args ) => readEventHeader({ ...args, descriptor : inbound }),
565
+ 'inbound.receive' : ({ action, event, headers, payload }) => {
566
+
567
+ if( action === 'compliance' && ! COMPLIANCE_TOPICS.has( event ) ){
568
+
569
+ throw Object.assign( new Error( 'Unrecognized compliance topic: ' + event ), { status : 401 });
570
+
571
+ }
572
+
573
+ return {
574
+ // Compliance payloads already carry shop_domain in the body — Shopify's
575
+ // own GDPR shape. The app-level event stream does not; that domain
576
+ // lives only in the header, so it is stamped on here rather than left
577
+ // for drawbridge-sync to reach into headers nobody hands it.
578
+ data : action === 'compliance'
579
+ ? payload
580
+ : { ...payload, shop_domain : headers[ inbound.headers.shop ] || null },
581
+ provider : { id : headers[ inbound.headers.id ] || null }
582
+ };
583
+
584
+ },
585
+ 'inbound.verify' : ( args ) => verifySignature({ ...args, descriptor : inbound })
586
+ },
587
+ icon: icon$1,
588
+ inbound,
589
+ label : 'shopify',
590
+ // A pre-launch integration: it only surfaces once the App Store listing
591
+ // exists and the app is fully configured. Requiring all four means it can
592
+ // never render half-configured — and absence of any one excludes the
593
+ // connection AND every step below it.
594
+ requires : [
595
+ 'SHOPIFY_API_KEY',
596
+ 'SHOPIFY_API_SECRET',
597
+ 'SHOPIFY_APP_LISTING_URL',
598
+ 'SHOPIFY_APP_HANDLE'
599
+ ],
600
+ // The only vendor implementing most of the surface, which is why it was the
601
+ // one every slug branch in three repos was written for.
602
+ //
603
+ // auth.probe is false deliberately: the health check re-registers webhooks
604
+ // rather than answering "is this token still good", and scope drift is its own
605
+ // hook because a token can be perfectly valid while the grant is too narrow.
606
+ supports : {
607
+ 'auth.connect' : true,
608
+ 'auth.disconnect' : true,
609
+ 'auth.probe' : false,
610
+ 'auth.scopes' : true,
611
+ // Shopify has no separate price resource — a price belongs to a product
612
+ // variant and arrives with it, so there is nothing for prices to answer
613
+ // that products does not already.
614
+ 'catalog.prices' : false,
615
+ 'catalog.products' : true,
616
+ 'catalog.promotions' : true,
617
+ 'inbound.event' : true,
618
+ 'inbound.process' : true,
619
+ 'inbound.receive' : true,
620
+ 'inbound.verify' : true,
621
+ 'lifecycle.cleanup' : true,
622
+ 'lifecycle.register' : true,
623
+ 'lifecycle.rehydrate' : true
624
+ },
625
+ setup : [
626
+ 'Open the Drawbridge listing on the Shopify App Store.',
627
+ 'Install the app on the store you want to connect. It opens in Shopify admin and stays there.',
628
+ 'Choose a plan when Shopify asks. The connection shows Pending until you do, then Active.',
629
+ 'Come back here — the connections list updates on its own once the install lands.'
630
+ ],
631
+ slug : 'shopify',
632
+ // Step types name the CAPABILITY, not this vendor. A second store platform
633
+ // implements the same four commerce steps, and the connection on the step
634
+ // says which store it runs against — so a merchant sees one "Create
635
+ // customer", not one per platform. The three connection.* steps are not
636
+ // commerce at all: any vendor holding a rotating credential needs them.
637
+ steps : {
638
+ 'step.commerce.customer.insert' : {
639
+ billable : true,
640
+ key : 'Create customer',
641
+ queue : 'connection',
642
+ returns : [
643
+ { key : 'shopifyCustomerId', label : 'Shopify Customer ID' }
644
+ ],
645
+ settings : {},
646
+ triggers : [ 'lead.insert' ]
647
+ },
648
+ 'step.commerce.code.issue' : {
649
+ billable : true,
650
+ key : 'Issue a discount code',
651
+ queue : 'connection',
652
+ returns : [
653
+ { key : 'shopifyDiscountCode', label : 'Shopify Discount Code' },
654
+ { key : 'shopifyDiscountId', label : 'Shopify Discount ID' }
655
+ ],
656
+ settings : {
657
+ discount : {
658
+ required : true,
659
+ shape : {
660
+ id : { required : true, type : 'string' }
661
+ },
662
+ type : 'object'
663
+ }
664
+ },
665
+ triggers : [ 'lead.insert' ]
666
+ },
667
+ // System steps: dispatched by sync itself rather than offered in the
668
+ // builder, so they carry no trigger. They are declared because the
669
+ // routing table and the system-workflow descriptions both read from here.
670
+ // Not a webhook monitor, despite the name it carried. Webhooks are
671
+ // declarative — declared in the app's toml, applied by Shopify to every
672
+ // install — so nothing registers or checks them here. This rotates the
673
+ // access token before Shopify's idle window closes, and reconciles the
674
+ // scopes the store granted against the ones the app now needs.
675
+ 'step.connection.health.check' : {
676
+ description : 'Keeps store access working — refreshes the access token before it goes stale and reports when the store\'s approved permissions fall behind.',
677
+ key : 'Shopify Connection Health',
678
+ queue : 'connection',
679
+ system : true
680
+ },
681
+ 'step.commerce.order.record' : {
682
+ description : 'Records an order and billing charge when a purchase is made via a Drawbridge campaign link.',
683
+ key : 'Shopify Order Tracking',
684
+ queue : 'connection',
685
+ system : true
686
+ },
687
+ 'step.commerce.product.sync' : {
688
+ description : 'Syncs Shopify product data on webhook updates.',
689
+ key : 'Shopify Product Sync',
690
+ queue : 'connection',
691
+ system : true
692
+ },
693
+ // Audit-only. The "Shopify Token Activity" system workflow lists these for
694
+ // descriptive grouping, but its audit step docs are written manually at
695
+ // OAuth time — the workflow is never dispatched. Routing is declared
696
+ // defensively so that if it ever IS dispatched, the job lands on a real
697
+ // queue and the handler lookup misses cleanly instead of throwing
698
+ // "Unknown step type".
699
+ 'step.connection.token.exchange' : {
700
+ key : 'Shopify Token Exchange',
701
+ queue : 'connection',
702
+ system : true
703
+ },
704
+ 'step.connection.token.refresh' : {
705
+ key : 'Shopify Token Refresh',
706
+ queue : 'connection',
707
+ system : true
708
+ }
709
+ },
710
+ title : 'Shopify'
711
+ };
712
+
713
+ // Drawbridge, exported from the brand kit and left as authored — the fills are the
714
+ // vendor's own mark, not a recolour.
715
+ //
716
+ // A .js wrapper around otherwise untouched SVG so `node --test` can run against
717
+ // lib/ directly. A bare .svg import would need a bundler loader and force the
718
+ // tests onto dist/, which is a worse trade than one line of wrapper.
719
+ var icon = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
720
+ <rect width="500" height="500" fill="#BAEC5F"/>
721
+ <g clip-path="url(#clip0_2115_2832)">
722
+ <path d="M140.224 127.586L174.803 188.73V311.176L140 372.32L176.084 392.031L216.111 321.753V178.278L176.341 108L140.224 127.586Z" fill="#0D1314"/>
723
+ <path d="M360.001 127.523L323.693 108.282L284.948 178.498V321.596L322.923 391.749L359.393 372.79L326.224 311.52V188.73L360.001 127.523Z" fill="#0D1314"/>
724
+ </g>
725
+ <defs>
726
+ <clipPath id="clip0_2115_2832">
727
+ <rect width="220" height="284" fill="white" transform="translate(140 108)"/>
728
+ </clipPath>
729
+ </defs>
730
+ </svg>`;
731
+
732
+ // Webhooks — the only connection with no third party behind it. Connecting
733
+ // generates a signing secret rather than asking for a credential, which is why
734
+ // its one field declares no `input`.
735
+ var webhook = {
736
+ // Connecting GENERATES the secret rather than storing one the merchant typed,
737
+ // so the buttons say what actually happens.
738
+ actions : {
739
+ create : 'Connect',
740
+ update : 'Regenerate secret'
741
+ },
742
+ // GENERATED. There is no third party and nothing to authenticate against --
743
+ // connecting mints a secret rather than proving a credential.
744
+ auth : {
745
+ type : 'generated'
746
+ },
747
+ category : 'developer',
748
+ confirm : 'Disconnecting stops Drawbridge from sending signed webhook payloads to your endpoint.',
749
+ description : [
750
+ 'Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react in real time.',
751
+ '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.'
752
+ ],
753
+ excerpt : 'Sign outgoing webhook payloads with an HMAC secret to verify authenticity.',
754
+ feature : 'organization:connection:webhook',
755
+ fields : [
756
+ {
757
+ // No `input`: Drawbridge generates this, the merchant never types it.
758
+ // No `redact` either — it is shared with the merchant's own endpoint
759
+ // rather than being a third-party credential, so it round-trips for
760
+ // them to copy and configure.
761
+ copy : true,
762
+ key : 'secret',
763
+ label : 'Signing secret'
764
+ }
765
+ ],
766
+ // Borrowed: this is the Drawbridge mark, because Webhooks has none of its own.
767
+ // It is the one card that reads wrong — our logo among vendor logos — and it
768
+ // wants a mark of its own when there is one.
769
+ icon,
770
+ label : 'webhook',
771
+ // Gated on the encryption secret: without it the signing secret could not be
772
+ // stored safely, so the connection must not be offered at all.
773
+ requires : [ 'ENCRYPT_CONNECTION_SECRET' ],
774
+ // Outbound only. inbound.* is false because the direction is the point: we
775
+ // sign and POST to the merchant's endpoint, they never call us. Every other
776
+ // false follows from there being no third party to authenticate against —
777
+ // connect generates a secret rather than proving a credential.
778
+ supports : {
779
+ 'auth.connect' : true,
780
+ 'auth.disconnect' : true,
781
+ 'auth.probe' : false,
782
+ 'auth.scopes' : false,
783
+ 'catalog.prices' : false,
784
+ 'catalog.products' : false,
785
+ 'catalog.promotions' : false,
786
+ 'inbound.event' : false,
787
+ 'inbound.process' : false,
788
+ 'inbound.receive' : false,
789
+ 'inbound.verify' : false,
790
+ 'lifecycle.cleanup' : false,
791
+ 'lifecycle.register' : false,
792
+ 'lifecycle.rehydrate' : false
793
+ },
794
+ setup : [
795
+ 'Press Connect. Drawbridge generates a signing secret and shows it here.',
796
+ 'Copy the secret into your own endpoint.',
797
+ 'On each request, compute HMAC-SHA256 of the raw body using the secret and compare it against the X-Drawbridge-Signature header before acting on the payload.'
798
+ ],
799
+ slug : 'webhook',
800
+ steps : {
801
+ 'step.webhook.send' : {
802
+ billable : true,
803
+ key : 'Send webhook',
804
+ queue : 'webhook',
805
+ returns : [],
806
+ settings : {
807
+ url : { format : 'url', required : true, type : 'string' }
808
+ },
809
+ triggers : [ 'lead.insert', 'lead.delete' ]
810
+ }
811
+ },
812
+ // The card the connection page raises. Before connecting it explains what
813
+ // pressing Connect will do; afterwards it states the verification the
814
+ // merchant's own endpoint has to perform, because a signed payload nobody
815
+ // checks is an unsigned payload.
816
+ tasks : ({ settings }) => ( settings?.secret
817
+ ? [
818
+ {
819
+ message : 'Compute HMAC-SHA256( secret, body ) and compare against the X-Drawbridge-Signature header to confirm each payload.',
820
+ title : 'Requests must be verified',
821
+ type : 'warning'
822
+ }
823
+ ]
824
+ : [
825
+ {
826
+ message : 'Connect to generate a signing secret. Drawbridge signs every webhook it sends with it.',
827
+ title : 'Webhook signing'
828
+ }
829
+ ]
830
+ ),
831
+ title : 'Webhooks'
832
+ };
833
+
834
+ // Every connection, one file each, and this is the whole registry.
835
+ //
836
+ // It lives in utils rather than in the api because BOTH sides need it and they
837
+ // cannot import each other. The api renders the catalog, validates step settings
838
+ // and redacts stored values; sync routes a step to a queue and runs it. Before
839
+ // this, adding one vendor meant editing nine places across two repos and a third
840
+ // for the form, and the two halves could disagree — a step the api would happily
841
+ // persist that sync had no route for is a workflow that silently never runs.
842
+ //
843
+ // WHAT LIVES HERE is everything true about a vendor regardless of environment:
844
+ // its copy, the fields it stores, and the steps it contributes. WHAT DOES NOT is
845
+ // anything that reads process.env — image origins, OAuth redirect urls, whether
846
+ // the integration is configured at all. Those are deployment facts, they differ
847
+ // per environment, and a package baked at publish time is the wrong place for
848
+ // them. A manifest names what it needs (`requires`) and the api resolves it.
849
+ //
850
+ // WHAT SYNC OWNS is the step HANDLERS. They need vendor SDKs, a queue, a
851
+ // controller and a logger, and putting those behind a published package would
852
+ // make every consumer carry them. The manifest declares the step and sync
853
+ // implements it — and because the declaration is the source of truth, a declared
854
+ // step with no handler is a failing test rather than a workflow that quietly
855
+ // does nothing.
856
+
857
+ // The queues sync runs. A step routes to exactly one, and naming them here is
858
+ // what lets sync derive its routing table instead of maintaining a parallel copy
859
+ // that can fall out of step with the catalog.
860
+ const QUEUES = [ 'connection', 'notification', 'segment', 'webhook' ];
861
+
862
+ // WHAT A CONNECTION MUST DECLARE. Thrown at import rather than discovered by a
863
+ // merchant looking at a broken card, or by a workflow that accepted a step it
864
+ // could never run.
865
+ const build = ( manifest ) => {
866
+
867
+ if( ! manifest?.slug ) throw new Error( 'A connection needs a slug' );
868
+ if( ! manifest?.title ) throw new Error( manifest.slug + ' needs a title' );
869
+ if( ! manifest?.feature ) throw new Error( manifest.slug + ' needs a plan feature key' );
870
+ if( ! manifest?.excerpt ) throw new Error( manifest.slug + ' needs an excerpt for its card' );
871
+
872
+ if( ! Array.isArray( manifest?.description ) || ! manifest.description.length ){
873
+
874
+ throw new Error( manifest.slug + ' needs a description — an array of paragraphs for its page' );
875
+
876
+ }
877
+
878
+ for( const field of manifest.fields || [] ){
879
+
880
+ if( ! field?.key || ! field?.label ){
881
+
882
+ throw new Error( manifest.slug + ' declares a field with no key or label' );
883
+
884
+ }
885
+
886
+ // An editable field must name an input the form can actually render. A
887
+ // kind nobody implemented falls through to a text box, which is how a
888
+ // merchant ends up typing the word true into a toggle.
889
+ if( field.input && ! INPUTS.includes( field.input ) ){
890
+
891
+ throw new Error( manifest.slug + '.' + field.key + ' declares an unknown input: ' + field.input + ' — one of ' + INPUTS.join( ', ' ) );
892
+
893
+ }
894
+
895
+ // A choice with nothing to choose from renders an empty dropdown, which
896
+ // looks like a loading state that never resolves.
897
+ if( field.input === 'select' && ! ( field.options || [] ).length ){
898
+
899
+ throw new Error( manifest.slug + '.' + field.key + ' is a select and must declare options' );
900
+
901
+ }
902
+
903
+ }
904
+
905
+ // THE ICON RIDES WITH THE MANIFEST, so a vendor cannot name an asset nobody
906
+ // added — which is what the old arrangement allowed, with the markup in one
907
+ // repo and the file in another.
908
+ //
909
+ // Checked for the three things that actually break a card:
910
+ // it must be an svg a filename here means the file is elsewhere again
911
+ // it must carry a viewBox without one it will not scale into the 50px tile
912
+ // it must not wrap a raster Figma exports a placed bitmap inside an <svg>
913
+ // shell, which looks like a vector, weighs 90KB and
914
+ // blurs exactly like the png it actually is
915
+ if( typeof manifest?.icon !== 'string' || ! manifest.icon.includes( '<svg' ) ){
916
+
917
+ throw new Error( manifest.slug + ' needs an icon — the svg markup itself, not a path to one' );
918
+
919
+ }
920
+
921
+ if( ! manifest.icon.includes( 'viewBox' ) ){
922
+
923
+ throw new Error( manifest.slug + ' icon has no viewBox, so it cannot scale' );
924
+
925
+ }
926
+
927
+ if( manifest.icon.includes( '<image' ) ){
928
+
929
+ throw new Error( manifest.slug + ' icon wraps a raster — re-export it as vector shapes' );
930
+
931
+ }
932
+
933
+ if( ! CATEGORIES.includes( manifest?.category ) ){
934
+
935
+ throw new Error( manifest.slug + ' needs a category — one of ' + CATEGORIES.join( ', ' ) );
936
+
937
+ }
938
+
939
+ // One field says how a vendor is connected, and it is auth.type. `connect`
940
+ // carries the COPY around connecting (errors, where a link goes); a `type`
941
+ // there too is a second answer to one question, and two answers drift.
942
+ if( manifest?.connect?.type ){
943
+
944
+ throw new Error( manifest.slug + ' declares connect.type — that is auth.type now' );
945
+
946
+ }
947
+
948
+ if( ! AUTH_TYPES.includes( manifest?.auth?.type ) ){
949
+
950
+ throw new Error( manifest.slug + ' needs auth.type — one of ' + AUTH_TYPES.join( ', ' ) );
951
+
952
+ }
953
+
954
+ // An oauth vendor declares the whole flow, so one implementation serves all
955
+ // of them. Half a descriptor is worse than none: it looks connectable and
956
+ // fails at the callback, which is the point a merchant has already left the
957
+ // dashboard and consented.
958
+ if( manifest.auth.type === 'oauth' ){
959
+
960
+ for( const field of OAUTH_FIELDS ){
961
+
962
+ if( ! manifest.auth.oauth?.[ field ] ){
963
+
964
+ throw new Error( manifest.slug + ' is oauth and must declare auth.oauth.' + field );
965
+
966
+ }
967
+
968
+ }
969
+
970
+ }
971
+
972
+ // A vendor that receives from the outside must say where it puts the event
973
+ // name. Without it the receiver has nothing to dispatch on, and the failure
974
+ // is a request accepted and dropped rather than an error.
975
+ if( manifest.supports?.[ 'inbound.event' ] && ! manifest.inbound?.headers?.event ){
976
+
977
+ throw new Error( manifest.slug + ' supports inbound.event but declares no inbound.headers.event' );
978
+
979
+ }
980
+
981
+ if( manifest.supports?.[ 'inbound.verify' ] && ! manifest.inbound?.headers?.signature ){
982
+
983
+ throw new Error( manifest.slug + ' supports inbound.verify but declares no inbound.headers.signature' );
984
+
985
+ }
986
+
987
+ // How to get the credential, in the merchant's words. A connection page that
988
+ // cannot say where to find an API key sends somebody to a vendor's docs
989
+ // written for a different integration.
990
+ if( ! Array.isArray( manifest?.setup ) || ! manifest.setup.length ){
991
+
992
+ throw new Error( manifest.slug + ' needs a setup guide — an array of steps for its page' );
993
+
994
+ }
995
+
996
+ // HOOKS A MANIFEST CAN CARRY ITSELF.
997
+ //
998
+ // Not every hook can live here — a step handler needs a queue, a controller
999
+ // and vendor SDKs, and putting those behind a published package makes every
1000
+ // consumer carry them. But the hooks that are pure or plain HTTP can, and
1001
+ // auth.connect is the one that matters most: it is the only vendor-specific
1002
+ // step in an OAuth callback, so putting it here is what lets ONE route serve
1003
+ // every OAuth vendor instead of one route each.
1004
+ //
1005
+ // Keyed flat ('auth.connect') to match the vocabulary and the supports map,
1006
+ // so the three cannot drift apart in naming.
1007
+ for( const [ name, hook ] of Object.entries( manifest.hooks || {} ) ){
1008
+
1009
+ if( ! HOOK_NAMES.includes( name ) ){
1010
+
1011
+ throw new Error( manifest.slug + ' implements an unknown hook: ' + name );
1012
+
1013
+ }
1014
+
1015
+ if( typeof hook !== 'function' ){
1016
+
1017
+ throw new Error( manifest.slug + ' declares hook ' + name + ' but it is not a function' );
1018
+
1019
+ }
1020
+
1021
+ // Implementing a hook you declared unsupported is the support matrix
1022
+ // lying, and the matrix is what a caller reads to decide whether to
1023
+ // bother asking. Caught here rather than discovered as a hook that is
1024
+ // never called.
1025
+ if( manifest.supports?.[ name ] !== true ){
1026
+
1027
+ throw new Error( manifest.slug + ' implements ' + name + ' but declares supports[ \'' + name + '\' ] false' );
1028
+
1029
+ }
1030
+
1031
+ }
1032
+
1033
+ // THE UNIFORM SURFACE. Every connection answers every hook in the vocabulary,
1034
+ // with true where it implements one and false where it deliberately does not.
1035
+ // A missing entry is rejected rather than defaulted, because a default is
1036
+ // exactly the silence this exists to remove: it would be impossible to tell a
1037
+ // vendor that declined a hook from one written before the hook existed.
1038
+ const supports = manifest.supports || {};
1039
+
1040
+ for( const name of HOOK_NAMES ){
1041
+
1042
+ if( typeof supports[ name ] !== 'boolean' ){
1043
+
1044
+ throw new Error( manifest.slug + ' must declare supports[ \'' + name + '\' ] as true or false' );
1045
+
1046
+ }
1047
+
1048
+ }
1049
+
1050
+ for( const name of Object.keys( supports ) ){
1051
+
1052
+ if( ! HOOK_NAMES.includes( name ) ){
1053
+
1054
+ throw new Error( manifest.slug + ' declares an unknown hook: ' + name );
1055
+
1056
+ }
1057
+
1058
+ }
1059
+
1060
+ for( const [ type, step ] of Object.entries( manifest.steps || {} ) ){
1061
+
1062
+ if( ! type.startsWith( 'step.' ) ){
1063
+
1064
+ throw new Error( manifest.slug + ' declares a step type that is not step.<domain>.<verb>: ' + type );
1065
+
1066
+ }
1067
+
1068
+ if( ! step?.key ) throw new Error( manifest.slug + ' step ' + type + ' needs a key — the label the builder shows' );
1069
+
1070
+ if( ! QUEUES.includes( step?.queue ) ){
1071
+
1072
+ throw new Error( manifest.slug + ' step ' + type + ' needs a queue — one of ' + QUEUES.join( ', ' ) );
1073
+
1074
+ }
1075
+
1076
+ }
1077
+
1078
+ return Object.freeze({
1079
+ ...manifest,
1080
+ fields : Object.freeze( manifest.fields || [] ),
1081
+ hooks : Object.freeze( manifest.hooks || {} ),
1082
+ inbound : Object.freeze( manifest.inbound || {} ),
1083
+ setup : Object.freeze( manifest.setup || [] ),
1084
+ supports : Object.freeze( supports ),
1085
+ requires : Object.freeze( manifest.requires || [] ),
1086
+ steps : Object.freeze( manifest.steps || {} )
1087
+ });
1088
+
1089
+ };
1090
+
1091
+ const connections = Object.freeze({
1092
+ klaviyo : build( klaviyo ),
1093
+ mailchimp : build( mailchimp ),
1094
+ shopify : build( shopify ),
1095
+ webhook : build( webhook )
1096
+ });
1097
+
1098
+ // A step type belongs to exactly one vendor.
1099
+ //
1100
+ // This is checked because the current naming HIDES the problem rather than
1101
+ // solving it: every commerce step is namespaced by vendor
1102
+ // (step.shopify.customer.insert), so a collision is impossible only for as long
1103
+ // as that holds. The moment a capability is named for what it does rather than
1104
+ // who does it — step.commerce.customer.insert, which is where this should go —
1105
+ // two vendors declaring it would silently collapse into one entry in stepQueues
1106
+ // and one of them would route nowhere.
1107
+ //
1108
+ // Fail at import instead. When the neutral names land, this is the guard that
1109
+ // makes the ambiguity visible on the first line of the first test run.
1110
+ ( () => {
1111
+
1112
+ const owners = {};
1113
+
1114
+ for( const [ slug, manifest ] of Object.entries( connections ) ){
1115
+
1116
+ for( const type of Object.keys( manifest.steps ) ){
1117
+
1118
+ if( owners[ type ] ){
1119
+
1120
+ throw new Error( 'Step ' + type + ' is declared by both ' + owners[ type ] + ' and ' + slug );
1121
+
1122
+ }
1123
+
1124
+ owners[ type ] = slug;
1125
+
1126
+ }
1127
+
1128
+ }
1129
+
1130
+ })();
1131
+
1132
+ // The vendors whose environment is actually configured. Every consumer asks this
1133
+ // the same way, so "is Shopify available" has one answer rather than one per
1134
+ // repo — the api gated it on four env vars and sync inferred it from a different
1135
+ // signal, which is how the two drifted.
1136
+ const availableConnections = ( env = {} ) => Object.fromEntries(
1137
+ Object.entries( connections ).filter(
1138
+ ( [ , manifest ] ) => manifest.requires.every( ( name ) => Boolean( env[ name ] ) )
1139
+ )
1140
+ );
1141
+
1142
+ // Per-slug allowlist of stored setting keys safe to return in an API response.
1143
+ // DERIVED from each vendor's own field declaration — a field is public unless it
1144
+ // says `redact : true`. An unknown slug gets an empty allowlist, so a leftover
1145
+ // document for a withdrawn vendor redacts entirely on its way out.
1146
+ const publicSettingsBySlug = Object.fromEntries(
1147
+ Object.entries( connections ).map( ( [ slug, manifest ] ) => [
1148
+ slug,
1149
+ manifest.fields.filter( ( field ) => ! field.redact ).map( ( field ) => field.key )
1150
+ ] )
1151
+ );
1152
+
1153
+ // Every step every configured vendor contributes, flattened and stamped with the
1154
+ // slug that owns it. The api builds its workflow catalog from this and sync
1155
+ // builds its routing table from it, so a step cannot exist on one side only.
1156
+ const connectionSteps = ( env = {} ) => Object.entries( availableConnections( env ) )
1157
+ .flatMap( ( [ slug, manifest ] ) => Object.entries( manifest.steps )
1158
+ .map( ( [ type, step ] ) => ({ ...step, slug, type }) )
1159
+ );
1160
+
1161
+ // Which vendors implement a given hook. The uniform surface makes this total —
1162
+ // every vendor appears in exactly one of the two lists, never neither.
1163
+ const hookSupport = ( name ) => ({
1164
+ no : Object.keys( connections ).filter( ( slug ) => ! connections[ slug ].supports[ name ] ),
1165
+ yes : Object.keys( connections ).filter( ( slug ) => connections[ slug ].supports[ name ] )
1166
+ });
1167
+
1168
+ // A vendor's fields, described for the dashboard. Served so one generic form
1169
+ // covers every vendor — a new connection needs no new component.
1170
+ //
1171
+ // EVERY field, not only the editable ones. A field without an `input` is not
1172
+ // absent from the page, it is READ-ONLY there: the webhook signing secret is
1173
+ // displayed with a copy button, the Shopify store domain as text. The form
1174
+ // decides which of the two a field is; this only describes it.
1175
+ //
1176
+ // Safe to send for secret fields because a descriptor is a key and a label. The
1177
+ // VALUE is withheld independently by whatever redacts stored settings, and a
1178
+ // test asserts no descriptor ever carries one.
1179
+ const connectFields = ( slug ) => ( connections[ slug ]?.fields || [] )
1180
+ .map( ({ copy, from, input, key, label, message, options, placeholder, redact, required }) => ({
1181
+ ...( copy && { copy : true }),
1182
+ ...( from && { from }),
1183
+ ...( input && { input }),
1184
+ key,
1185
+ label,
1186
+ ...( message && { message }),
1187
+ ...( options && { options }),
1188
+ ...( placeholder && { placeholder }),
1189
+ required : Boolean( required ),
1190
+ // A UI hint, not a leak: the form uses it to stop requiring the field once
1191
+ // the connection exists, and to say "leave blank to keep" — because the GET
1192
+ // response deliberately never returns the stored value.
1193
+ secret : Boolean( redact )
1194
+ }) );
1195
+
1196
+ // Run a vendor's hook, or say why it did not run.
1197
+ //
1198
+ // ONE CALL SURFACE. A route, a worker or a form handler asks for a hook by name
1199
+ // and never names a vendor — which is what lets a variable endpoint like
1200
+ // /connection/:slug/callback serve every vendor that declares one.
1201
+ //
1202
+ // The four outcomes are the point. "unsupported", "unimplemented", "failed" and
1203
+ // a result are different answers to why there is no data, and collapsing them is
1204
+ // how "this vendor cannot do that" becomes indistinguishable from "it broke".
1205
+ const runHook = async ( slug, name, args = {} ) => {
1206
+
1207
+ const manifest = connections[ slug ];
1208
+
1209
+ if( ! manifest ) return { outcome : OUTCOMES.unsupported, reason : 'no such connection: ' + slug };
1210
+
1211
+ if( ! manifest.supports?.[ name ] ) return { outcome : OUTCOMES.unsupported, reason : slug + ' does not implement ' + name };
1212
+
1213
+ const hook = manifest.hooks?.[ name ];
1214
+
1215
+ // Declared supported, implemented somewhere else. Sync owns the step handlers
1216
+ // and lifecycle jobs, so this is a legitimate answer here rather than a fault
1217
+ // — the caller in that repo has its own registry.
1218
+ if( typeof hook !== 'function' ){
1219
+
1220
+ return { outcome : OUTCOMES.unimplemented, reason : slug + ' implements ' + name + ' outside this package' };
1221
+
1222
+ }
1223
+
1224
+ try {
1225
+
1226
+ return { outcome : OUTCOMES.answered, result : await hook( args ) };
1227
+
1228
+ } catch ( error ) {
1229
+
1230
+ // A hook throws to REJECT, not just to report failure — inbound.verify
1231
+ // on a forged signature, inbound.receive on a topic outside the vendor's
1232
+ // declared set. The status rides along so a caller can answer 401 rather
1233
+ // than always folding a hook's own rejection into a 500.
1234
+ return { error : error?.message || 'failed', outcome : OUTCOMES.failed, status : error?.status || null };
1235
+
1236
+ }
1237
+
1238
+ };
1239
+
1240
+ // step type → queue name. Sync's router reads this instead of a hand-kept map.
1241
+ const stepQueues = ( env = {} ) => Object.fromEntries(
1242
+ connectionSteps( env ).map( ( step ) => [ step.type, step.queue ] )
1243
+ );
1244
+
1245
+ // Merchant-facing scope-drift copy.
1246
+ //
1247
+ // THIS IS A CROSS-REPO CONTRACT. drawbridge-sync writes it onto the connection
1248
+ // document (reconcileConnectionScopes) and drawbridge-api reads the same text
1249
+ // back out, so the two repos each held a copy of one string and a change to
1250
+ // either produced a connection whose message changed as the document converged.
1251
+ // One copy, here, because neither repo can import the other.
1252
+ const scopesMessage = 'Shopify permissions are out of date. Open the Drawbridge app in your Shopify admin to approve the updated permissions.';
1253
+
1254
+ // Merge incoming settings into stored ones. Keys whose incoming value is empty
1255
+ // are PRESERVED, which is how "leave blank to keep your current key" works —
1256
+ // the GET response never returns a secret, so blank has to mean keep rather
1257
+ // than clear.
1258
+ const mergeSettings = ({ existing, incoming }) => {
1259
+
1260
+ if( ! existing || typeof existing !== 'object' ) return incoming;
1261
+
1262
+ const merged = { ...existing };
1263
+
1264
+ for( const [ key, value ] of Object.entries( incoming || {} ) ){
1265
+
1266
+ if( value !== undefined && value !== null && value !== '' ){
1267
+
1268
+ merged[ key ] = value;
1269
+
1270
+ }
1271
+ }
1272
+ return merged;
1273
+
1274
+ };
1275
+
1276
+ // Project stored settings to the keys that are safe to return, per slug.
1277
+ //
1278
+ // THE SECURITY BOUNDARY. It derives from each vendor's own field declaration —
1279
+ // public unless the field says `redact` — so a credential added to a form
1280
+ // cannot be forgotten here. An unknown slug gets an empty allowlist, so a
1281
+ // leftover document for a withdrawn vendor redacts entirely on its way out.
1282
+ const redactSettings = ({ slug, settings }) => {
1283
+
1284
+ if( ! settings || typeof settings !== 'object' ) return settings;
1285
+
1286
+ const allowed = publicSettingsBySlug[ slug ] || [];
1287
+
1288
+ return Object.fromEntries(
1289
+ Object.entries( settings ).filter( ( [ key ] ) => allowed.includes( key ) )
1290
+ );
1291
+
1292
+ };
1293
+
1294
+ // Connection-record fields safe to return in an API response. Defense in depth:
1295
+ // anything absent here is stripped, so an internal field added to the document
1296
+ // later does not reach a browser by default.
1297
+ //
1298
+ // `docs` and `links` were listed and set by no vendor and read by nothing —
1299
+ // dropped rather than carried forward as keys that look supported.
1300
+ const publicConnectionKeys = Object.freeze([
1301
+ 'actions',
1302
+ 'category',
1303
+ 'confirm',
1304
+ 'connect',
1305
+ 'createdAt',
1306
+ 'errors',
1307
+ 'description',
1308
+ 'excerpt',
1309
+ 'fields',
1310
+ 'group',
1311
+ 'id',
1312
+ 'image',
1313
+ 'label',
1314
+ 'setup',
1315
+ 'settings',
1316
+ 'shop',
1317
+ 'slug',
1318
+ 'status',
1319
+ 'tasks',
1320
+ 'title',
1321
+ 'updatedAt',
1322
+ 'warnings'
1323
+ ]);
1324
+
1325
+ const projectConnection = ( record ) => {
1326
+
1327
+ if( ! record || typeof record !== 'object' ) return record;
1328
+
1329
+ return publicConnectionKeys.reduce(
1330
+ ( accumulator, key ) => {
1331
+
1332
+ if( record[ key ] !== undefined ){
1333
+
1334
+ accumulator[ key ] = record[ key ];
1335
+
1336
+ }
1337
+ return accumulator;
1338
+
1339
+ },
1340
+ {}
1341
+ );
1342
+
1343
+ };
1344
+
1345
+ // Resolve a manifest against its stored data: any field declared as a function
1346
+ // is called with the data, everything else passes through. This lets a field
1347
+ // pivot on database state — webhook `tasks` depend on whether a secret exists —
1348
+ // while leaving static metadata directly readable.
1349
+ //
1350
+ // Catalog wiring is dropped rather than resolved. `fields` in particular must
1351
+ // never reach the output under that name: the connection DOCUMENT's own
1352
+ // `settings` is spread over this downstream, and two different things sharing a
1353
+ // key is how a redaction quietly stops applying.
1354
+ const resolveConnection = ( item, data ) => {
1355
+
1356
+ if( ! item ) return item;
1357
+
1358
+ return Object.fromEntries(
1359
+ Object.entries( item )
1360
+ .filter( ( [ key ] ) => ! [ 'auth', 'enabled', 'fields', 'hooks', 'inbound', 'requires', 'steps', 'supports' ].includes( key ) )
1361
+ .map( ( [ key, value ] ) => [
1362
+ key,
1363
+ ( typeof value === 'function' ? value( data ) : value )
1364
+ ] )
1365
+ );
1366
+
1367
+ };
1368
+
1369
+ export { AUTH_TYPES, CATEGORIES, HOOKS, HOOK_NAMES, INPUTS, OAUTH_FIELDS, OUTCOMES, availableConnections, build, connectFields, connectionSteps, connections, hookSupport, mergeSettings, projectConnection, publicConnectionKeys, publicSettingsBySlug, redactSettings, resolveConnection, runHook, scopesMessage, stepQueues };