@drawbridge/drawbridge-utils 0.0.117 → 0.0.121

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.
@@ -2,19 +2,22 @@ import { authToken } from './oauth.cjs';
2
2
  export { consentUrl, pkcePair } from './oauth.cjs';
3
3
  import { request } from '../http.cjs';
4
4
  import { channels } from '../pricing.cjs';
5
- import crypto, { createHmac, timingSafeEqual } from 'node:crypto';
5
+ import crypto, { createHmac, timingSafeEqual, randomUUID } from 'node:crypto';
6
+ import { customAlphabet } from 'nanoid';
7
+ import { toCanonicalEmail } from '../email.cjs';
8
+ import { toE164 } from '../phone.cjs';
9
+ import { conversionRate } from '../plans.cjs';
6
10
  import { safeRequest } from '../safe-http.cjs';
7
- import '../plans.cjs';
11
+ import '../billing.cjs';
12
+ import '../transactions.cjs';
13
+ import '@drawbridge/drawbridge-telemetry';
14
+ import '../usage.cjs';
15
+ import 'libphonenumber-js';
8
16
  import '../features.cjs';
9
17
  import '../index.cjs';
10
18
  import 'currency-codes';
11
- import 'nanoid';
12
19
  import '../color.cjs';
13
20
  import 'tinycolor2';
14
- import '../usage.cjs';
15
- import '../billing.cjs';
16
- import '../transactions.cjs';
17
- import '@drawbridge/drawbridge-telemetry';
18
21
  import 'dns';
19
22
  import 'node:http';
20
23
  import 'node:https';
@@ -228,6 +231,246 @@ const HOOKS = Object.freeze({
228
231
 
229
232
  });
230
233
 
234
+ // WHAT A HOOK RETURNS — and, just as much, what it must NOT do.
235
+ //
236
+ // A HOOK DOES THE WORK AND SAYS WHAT HAPPENED. It never records the run, never
237
+ // bills, never enqueues the next step and never decides its own price: that is
238
+ // the shell's, identically for every step. Five keys say what happened, and
239
+ // every one of them is unchanged from the day the shell was written:
240
+ //
241
+ // context merged forward into the next step's payload
242
+ // message the sentence a merchant reads in the run log
243
+ // request what was sent, for support to read back
244
+ // response what came back
245
+ // skipped true when it deliberately did no work, so it does not bill
246
+ //
247
+ // THE PROBLEM THOSE FIVE COULD NOT SOLVE. A hook that had to write a document,
248
+ // enqueue a job or tell an open dashboard something had no way to say so — so it
249
+ // did it, with a controller, a queue and a socket server handed to it. That is
250
+ // why the bodies lived in drawbridge-sync rather than beside the declarations
251
+ // that name them: a published package cannot carry a database, so any hook
252
+ // needing one had to be somewhere else, and "a vendor's logic lives in a repo
253
+ // the vendor file cannot see" is the split these manifests exist to close.
254
+ //
255
+ // So a hook DESCRIBES the side effects instead, and the shell performs them:
256
+ //
257
+ // writes [ { collection, data, ignoreDuplicate, operation, options, query } ]
258
+ // enqueues [ { data, name, options, queue } ]
259
+ // events [ { event, payload, room } ]
260
+ //
261
+ // Each is a plain array of plain objects, in the order the shell must run them,
262
+ // and each descriptor is named for the call it becomes — `operation` is the
263
+ // controller method, `queue` is the queue's own name, `room` is the socket room.
264
+ // No descriptor carries a function: a hook that could hand back a callback would
265
+ // be doing the work again with an extra step.
266
+ //
267
+ // A REJECTION MAY CARRY EFFECTS TOO. Throwing is how a hook rejects, and some
268
+ // rejections have to leave the world tidy — segment.sync locks each segment at
269
+ // `syncing` and every exit, including the failing one, has to put it back or the
270
+ // merchant watches a spinner forever. So the three keys may ride out on the
271
+ // thrown error, and the shell performs them before it records the failure. It is
272
+ // the same precedent as `error.status` and `error.response`, which the shell
273
+ // already reads off a rejection.
274
+ //
275
+ // WHAT IS STILL PASSED IN. Reads are not side effects and are not described:
276
+ // gathering the facts IS the work. A hook receives `read` — the controller's
277
+ // read-only methods and nothing else — so it cannot write through it even by
278
+ // mistake. Anything else a hook genuinely needs is injected the same way the
279
+ // `shopify` SDK is: as an argument, named for what it does.
280
+ const HOOK_EFFECTS = Object.freeze([ 'enqueues', 'events', 'writes' ]);
281
+
282
+ // The controller methods a described write may become. Only the two that a hook
283
+ // has ever needed — a deletion is a cascade, and cascades are owned by
284
+ // drawbridge-sync's streams rather than by a step.
285
+ const WRITE_OPERATIONS = Object.freeze([ 'create', 'update' ]);
286
+
287
+ // READ A HOOK'S EFFECTS, or refuse them.
288
+ //
289
+ // The shell performs whatever comes back here, so a malformed descriptor is a
290
+ // write against the wrong collection or an emit to every open dashboard. Caught
291
+ // at the boundary instead, in the hook's own words, because the alternative is a
292
+ // TypeError inside the controller naming nothing that would help.
293
+ //
294
+ // Absent keys are an EMPTY ARRAY rather than undefined: the caller performs
295
+ // `writes` unconditionally, and a shell that has to null-check three keys is
296
+ // three places one of them gets forgotten.
297
+ // WHAT A HOOK IS HANDED: TWO OBJECTS, so a signature says on sight what is a
298
+ // property of the run and what is a service being passed in.
299
+ //
300
+ // hook( props, options )
301
+ //
302
+ // PROPS are facts — data about this run, this merchant, this payload. OPTIONS
303
+ // are services — capabilities the caller injects, every one either read-only or
304
+ // owned by the shell. A hook that wants a service reaches into its second
305
+ // argument, which is what keeps a fact from quietly becoming a handle.
306
+ //
307
+ // One vocabulary for every manifest. A name below means the same thing in every
308
+ // hook that receives it; a synonym splits the vocabulary and fails the test
309
+ // that walks these.
310
+ //
311
+ // PROPS — facts of the run
312
+ // channel which inbound surface the request arrived on
313
+ // clientId, OUR app's credential VALUES, resolved by the caller from the
314
+ // clientSecret provider collection — auth.disconnect and auth.probe, where
315
+ // the vendor wants them re-presented
316
+ // connection the merchant's connection document
317
+ // contact the person's email — the address, not the lead document
318
+ // context the step's accumulated trigger data
319
+ // cursor, resources.* pagination, always these names
320
+ // limit,
321
+ // search, sort
322
+ // declaration the step declaration the shell resolved for this run
323
+ // doc the document a stream-driven hook acts on
324
+ // email, id the identifiers a contacts hook acts on when there is no doc
325
+ // event the inbound event name the manifest extracted
326
+ // headers the inbound request's headers
327
+ // lead the lead document, resolved by the shell when the run names one
328
+ // manifest the hook's own manifest, for the vendor facts it declares
329
+ // payload the verified inbound body
330
+ // scope the granted scope string being judged
331
+ // secret the credential VALUE the caller resolved for verification
332
+ // settings the connection's decrypted settings
333
+ // step the step document
334
+ // suppressed the opt-out floor's answer for this lead, computed by the shell
335
+ // token a LIVE access token, refreshed by the shell before the call —
336
+ // oauth vendors only; null everywhere else
337
+ // tokens the FRESH GRANT auth.connect receives from the oauth runner —
338
+ // the whole exchange response, not one live token
339
+ // workflow the workflow document
340
+ //
341
+ // OPTIONS — services passed in
342
+ // adminToken() mint or refresh the store token — Shopify's install
343
+ // model, where rotation persists mid-call; a resolver
344
+ // rather than a value, unlike `token`
345
+ // canSend() the opt-out floor, bound to this run's organization
346
+ // chunkSize the worker fleet's fan-out width — deployment tuning,
347
+ // passed, never published
348
+ // dispatch() the caller's own coordinator table, for hooks that are
349
+ // dispatches
350
+ // fetcher fetch-shaped transport: ( url, options )
351
+ // logger the caller's logger
352
+ // mintId() an id minted before the write it belongs to, so one
353
+ // described write can reference another
354
+ // read the controller's READ methods — get, aggregate, count —
355
+ // and never anything that writes; not `controller`, not
356
+ // `db`, because a name that implies writing invites it
357
+ // reconcileScopes() the caller's scope writer-of-record; injected because
358
+ // the hook needs its result
359
+ // request safeRequest-shaped transport: ({ body, headers, method,
360
+ // url }) — a different SHAPE than fetcher, so a different
361
+ // name
362
+ // resolveContact() the one write a description cannot carry — its result
363
+ // decides billing
364
+ // resolveSettings() fresh from wherever the vendor keeps credentials, per
365
+ // call, never a snapshot
366
+ // rotateToken() deliberate rotation, for the health check that rotates
367
+ // before the window closes
368
+ // shopify the SDK namespaces, injected — this package cannot
369
+ // import what depends on it
370
+ // THE ONE EXEMPTION: hooks.auth.token takes a SINGLE bag. It is the OAuth token
371
+ // request itself — called directly by the oauth runner and the refresh path,
372
+ // never through runHook — and its argument is the request's fields (clientId,
373
+ // code, refreshToken, fetcher) as one unit. Splitting it would change a
374
+ // wire-shaped call for symmetry's sake. Callers must never invoke it via
375
+ // runHook with a second bag: the options would be silently discarded, which is
376
+ // exactly the stubbed-fetcher-goes-live failure the split exists to prevent.
377
+ const HOOK_PROPS = Object.freeze([
378
+ 'channel', 'clientId', 'clientSecret', 'connection', 'contact', 'context',
379
+ 'cursor', 'declaration', 'doc', 'email', 'event', 'headers', 'id', 'lead', 'limit',
380
+ 'manifest', 'payload',
381
+ 'scope', 'search', 'secret', 'settings', 'sort', 'step', 'suppressed',
382
+ 'token', 'tokens', 'workflow'
383
+ ]);
384
+
385
+ const HOOK_OPTIONS = Object.freeze([
386
+ 'adminToken', 'canSend', 'chunkSize', 'dispatch', 'fetcher', 'logger',
387
+ 'mintId', 'read', 'reconcileScopes', 'request', 'resolveContact',
388
+ 'resolveSettings', 'rotateToken', 'shopify'
389
+ ]);
390
+
391
+ const effectsOf = ( answer ) => {
392
+
393
+ for( const key of HOOK_EFFECTS ){
394
+
395
+ if( answer?.[ key ] !== undefined && ! Array.isArray( answer[ key ] ) ){
396
+
397
+ throw new Error( 'A hook returned ' + key + ' that is not an array' );
398
+
399
+ }
400
+
401
+ }
402
+
403
+ const enqueues = answer?.enqueues || [];
404
+ const events = answer?.events || [];
405
+ const writes = answer?.writes || [];
406
+
407
+ for( const write of writes ){
408
+
409
+ if( ! write?.collection ) throw new Error( 'A described write names no collection' );
410
+
411
+ if( ! WRITE_OPERATIONS.includes( write?.operation ) ){
412
+
413
+ throw new Error( 'A described write on ' + write.collection + ' needs an operation — one of ' + WRITE_OPERATIONS.join( ', ' ) );
414
+
415
+ }
416
+
417
+ if( ! write?.data ) throw new Error( 'A described write on ' + write.collection + ' carries no data' );
418
+
419
+ // AN UPDATE WITH NO QUERY IS EVERY DOCUMENT IN THE COLLECTION. Refused here
420
+ // rather than survived, because the controller would happily run it.
421
+ if( write.operation === 'update' && ! write.query ){
422
+
423
+ throw new Error( 'A described update on ' + write.collection + ' has no query — that is every document in it' );
424
+
425
+ }
426
+
427
+ // The controller's create takes no query, so one here is a filter the
428
+ // author believed in and the database never saw.
429
+ if( write.operation === 'create' && write.query ){
430
+
431
+ throw new Error( 'A described create on ' + write.collection + ' carries a query — create does not filter' );
432
+
433
+ }
434
+
435
+ }
436
+
437
+ for( const enqueue of enqueues ){
438
+
439
+ if( ! enqueue?.queue ) throw new Error( 'A described enqueue names no queue' );
440
+
441
+ // BullMQ's own first argument. Without it the job lands unnamed and no
442
+ // worker handler matches it — a job accepted and never run.
443
+ if( ! enqueue?.name ) throw new Error( 'A described enqueue on the ' + enqueue.queue + ' queue names no job' );
444
+
445
+ }
446
+
447
+ for( const event of events ){
448
+
449
+ if( ! event?.event ) throw new Error( 'A described event has no name' );
450
+
451
+ // A ROOM IS NOT OPTIONAL. An emit without one reaches every connected
452
+ // dashboard, which is one organization's data arriving in another's tab.
453
+ if( ! event?.room ) throw new Error( 'A described ' + event.event + ' event has no room' );
454
+
455
+ }
456
+
457
+ // ALL OR NONE. `transaction : true` asks the shell to land every described
458
+ // write in one transaction — for the order/redemption pair, where a
459
+ // half-written attribution is revenue counted twice or not at all. It covers
460
+ // the WRITES only: enqueues and events still run after the writes commit,
461
+ // which is the ordering a job that reads a row it expects to exist depends on.
462
+ const transaction = Boolean( answer?.transaction );
463
+
464
+ if( transaction && ! writes.length ){
465
+
466
+ throw new Error( 'A hook asked for a transaction and described no writes' );
467
+
468
+ }
469
+
470
+ return { enqueues, events, transaction, writes };
471
+
472
+ };
473
+
231
474
  // WHAT A WORKFLOW STEP CAN BE. Closed, and the LABEL LIVES HERE rather than on a
232
475
  // vendor, because a step type belongs to the capability and not to whoever
233
476
  // implements it: Klaviyo and Mailchimp both do contacts.sync, and a label taken
@@ -366,7 +609,9 @@ const GROUPS = Object.freeze([ 'commerce', 'contacts', 'developer', 'messaging'
366
609
  //
367
610
  // authorize the consent url
368
611
  // token the exchange/refresh url
369
- // client the ENV VAR NAMES holding our own id and secret, never the values
612
+ // client the NAMES of our own id and secret, never the values — keys into
613
+ // the credential map the provider collection answers (historically
614
+ // env var names, kept as the stable vocabulary)
370
615
  // redirect our callback path — DECLARED, never derived from the slug. It is
371
616
  // registered in the vendor's console and they refuse anything that
372
617
  // does not byte-match, so it is a fact about someone else's records
@@ -562,8 +807,9 @@ var icon$4 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
562
807
  //
563
808
  // DORMANT UNTIL REGISTERED. `requires` names the client credentials that only
564
809
  // exist once the Attentive app is created (their console: enable distribution,
565
- // set the redirect URL, Generate Credentials). Until those env vars land, no
566
- // deployment offers this connection — the manifest ships complete and inert.
810
+ // set the redirect URL, Generate Credentials). Until an admin enters them on
811
+ // the provider screen, no deployment offers this connection — the manifest
812
+ // ships complete and inert.
567
813
  //
568
814
  // THREE THINGS TO VERIFY AT REGISTRATION, because the docs conflict or are
569
815
  // silent, and only a live install answers them:
@@ -584,9 +830,12 @@ var icon$4 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
584
830
  var attentive = {
585
831
  auth : {
586
832
  oauth : {
587
- // NAMES of the env vars holding OUR app's client — set at registration,
588
- // never before. No `headers` on the client: Attentive takes credentials
589
- // as form fields, which is the runner's default.
833
+ // NAMES of the credentials holding OUR app's client — keys into the map
834
+ // the provider collection answers, entered on the admin screen at
835
+ // registration, never before. (The names are the env vars they once
836
+ // were; the vocabulary stayed when the storage moved.) No `headers` on
837
+ // the client: Attentive takes credentials as form fields, which is the
838
+ // runner's default.
590
839
  client : {
591
840
  id : 'ATTENTIVE_OAUTH_CLIENT_ID',
592
841
  secret : 'ATTENTIVE_OAUTH_CLIENT_SECRET'
@@ -696,7 +945,7 @@ var attentive = {
696
945
  // show a picker quietly missing most of a real account. The response's
697
946
  // only identifier is `externalId`, so an entry without one cannot be
698
947
  // stored and is dropped.
699
- audiences : async ({ cursor, fetcher = fetch, limit = 100, search, token }) => {
948
+ audiences : async ( { cursor, limit = 100, search, token }, { fetcher = fetch } = {} ) => {
700
949
 
701
950
  const query = new URLSearchParams({
702
951
  limit : String( Math.min( limit, 1000 ) ),
@@ -809,15 +1058,22 @@ var attentive = {
809
1058
 
810
1059
  const HUBSPOT_BASE = 'https://api.hubapi.com';
811
1060
 
812
- // Best-effort: with no Private App token configured the whole integration
813
- // no-ops so a missing secret never crashes sync. Callers wrap these in
814
- // try/catch and treat a missing id gracefully.
1061
+ // THE TOKEN IS THE CALLER'S TO PASS. It used to fall back to process.env; it
1062
+ // lives encrypted in the `provider` collection now (lib/providers.js).
1063
+ //
1064
+ // THROWS RATHER THAN DEGRADES, once a request is actually being made. Whether
1065
+ // the portal is configured at all is decided by the callers below — the token is
1066
+ // declared optional on purpose — but a path that got as far as here without one
1067
+ // would send `Bearer undefined` and read the 401 as HubSpot being down. The
1068
+ // absence has to be named where it happens.
815
1069
  const hubspotRequest = ({ body, fetcher, method, path, query, token }) => {
816
1070
 
1071
+ if( ! token ) throw new Error( 'HubSpot access token missing — pass token (the drawbridge provider\'s hubspotToken)' );
1072
+
817
1073
  return ( fetcher || request )({
818
1074
  body,
819
1075
  headers : {
820
- 'Authorization' : 'Bearer ' + ( token || process.env.HUBSPOT_ACCESS_TOKEN )
1076
+ 'Authorization' : 'Bearer ' + token
821
1077
  },
822
1078
  method,
823
1079
  query,
@@ -1012,13 +1268,16 @@ const contacts = {
1012
1268
  // FORGET A CONTACT, by id or by email. Account deletion — the caller had
1013
1269
  // to search then remove, which is one round trip it should not have to
1014
1270
  // know about.
1015
- remove : async ({ email, fetcher, id, token }) => {
1016
-
1017
- const key = token || process.env.HUBSPOT_ACCESS_TOKEN;
1271
+ remove : async ( { email, id, token }, { fetcher } = {} ) => {
1018
1272
 
1019
- if( ! key ) return;
1273
+ // NO TOKEN IS A NO-OP HERE, unlike sendgrid and twilio, and the
1274
+ // difference is what the credential is declared to be: hubspotToken
1275
+ // is the one provider field that is not `required`, because this is
1276
+ // internal CRM tooling no merchant sees. A deployment with no portal
1277
+ // is a supported state, not a missing credential.
1278
+ if( ! token ) return;
1020
1279
 
1021
- const contact = id || await lookup({ email, fetcher, token : key });
1280
+ const contact = id || await lookup({ email, fetcher, token });
1022
1281
 
1023
1282
  if( ! contact ) return;
1024
1283
 
@@ -1026,7 +1285,7 @@ const contacts = {
1026
1285
  fetcher,
1027
1286
  method : 'DELETE',
1028
1287
  path : '/crm/v3/objects/contacts/' + contact,
1029
- token : key
1288
+ token
1030
1289
  });
1031
1290
 
1032
1291
  },
@@ -1037,19 +1296,18 @@ const contacts = {
1037
1296
  // no delete-old-then-create-new.
1038
1297
  //
1039
1298
  // Prefer the cached hubspotId; fall back to a search; create last.
1040
- sync : async ({ doc, fetcher, token }) => {
1041
-
1042
- const key = token || process.env.HUBSPOT_ACCESS_TOKEN;
1299
+ sync : async ( { doc, token }, { fetcher } = {} ) => {
1043
1300
 
1044
- // NO TOKEN IS A NO-OP, not an error. A deployment without a portal
1045
- // configured must not crash the user stream.
1046
- if( ! key ) return;
1301
+ // NO TOKEN IS A NO-OP, not an error see remove() above. A
1302
+ // deployment without a portal configured must not crash the user
1303
+ // stream over internal tooling.
1304
+ if( ! token ) return;
1047
1305
 
1048
1306
  if( doc?.hubspotId ){
1049
1307
 
1050
1308
  try {
1051
1309
 
1052
- return ( await send({ doc, fetcher, method : 'PATCH', path : '/crm/v3/objects/contacts/' + doc.hubspotId, token : key }) )?.id;
1310
+ return ( await send({ doc, fetcher, method : 'PATCH', path : '/crm/v3/objects/contacts/' + doc.hubspotId, token }) )?.id;
1053
1311
 
1054
1312
  } catch ( error ){
1055
1313
 
@@ -1061,14 +1319,14 @@ const contacts = {
1061
1319
 
1062
1320
  }
1063
1321
 
1064
- const existing = await lookup({ email : doc?.email, fetcher, token : key });
1322
+ const existing = await lookup({ email : doc?.email, fetcher, token });
1065
1323
 
1066
1324
  return ( await send({
1067
1325
  doc,
1068
1326
  fetcher,
1069
1327
  method : existing ? 'PATCH' : 'POST',
1070
1328
  path : existing ? '/crm/v3/objects/contacts/' + existing : '/crm/v3/objects/contacts',
1071
- token : key
1329
+ token
1072
1330
  }) )?.id;
1073
1331
 
1074
1332
  }
@@ -1110,6 +1368,82 @@ var icon$3 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
1110
1368
  // answers "can this merchant connect it" through `requires` and a plan feature.
1111
1369
  // A private one is always present, in every deployment, for every organization —
1112
1370
  // so it declares neither, and build() knows not to ask.
1371
+
1372
+ // Replace {{key}} placeholders with values from the run's accumulated context.
1373
+ // An unresolved key is left as it was written rather than blanked: a merchant
1374
+ // reading "Hello {{name}}" knows their template is wrong, where "Hello " reads
1375
+ // like a person with no name.
1376
+ //
1377
+ // Moved here from drawbridge-sync's lib/step.js with the bodies that were its
1378
+ // only callers. It is a string function — nothing about it needed a service.
1379
+ const interpolate = ( template, data ) => {
1380
+
1381
+ if( ! template ) return template;
1382
+
1383
+ return template.replace( /\{\{(\w+)\}\}/g, ( _, key ) => ( data?.[ key ] != null ? String( data[ key ] ) : '{{' + key + '}}' ) );
1384
+
1385
+ };
1386
+
1387
+ // WHO ON THE TEAM GETS TOLD. Shared by email.notify and email.digest, which
1388
+ // resolved it identically — the same forty lines twice, and the dedupe rule is
1389
+ // subtle enough that two copies would eventually differ.
1390
+ //
1391
+ // The OWNER IS ALWAYS A RECIPIENT. `settings.members` is an optional list of
1392
+ // ADDITIONAL people: the members endpoint is owner-gated and the owner is not a
1393
+ // `member` document, so a solo merchant has nothing selectable and could
1394
+ // otherwise not use these steps at all.
1395
+ const teamRecipients = async ({ memberIds = [], organization, read }) => {
1396
+
1397
+ const org = await read.get({ collection : 'organization', query : { id : organization } });
1398
+
1399
+ const owner = org?.owner
1400
+ ? await read.get({ collection : 'user', query : { id : org.owner } })
1401
+ : null;
1402
+
1403
+ // Re-scoped to the org and to ACCEPTED members: the id list is stored on the
1404
+ // step and outlives the membership it names — someone who declined, was
1405
+ // removed, or never accepted.
1406
+ const members = memberIds.length
1407
+ ? await read.aggregate({
1408
+ collection : 'member',
1409
+ pipeline : [
1410
+ {
1411
+ $match : {
1412
+ id : { $in : memberIds },
1413
+ organization,
1414
+ status : 'accepted'
1415
+ }
1416
+ }
1417
+ ]
1418
+ })
1419
+ : [];
1420
+
1421
+ // Deduped by address, lower-cased and NO FURTHER. The owner is commonly also
1422
+ // named in settings.members and nobody should get two copies. Canonicalizing
1423
+ // (toCanonicalEmail) would be wrong: it strips +tags, so two genuinely
1424
+ // different teammates collapse into one and the second is never told.
1425
+ const seen = new Set();
1426
+
1427
+ return [ owner, ...members ].filter( ( member ) => {
1428
+
1429
+ if( ! member?.id || ! member?.email ) return false;
1430
+
1431
+ const address = member.email.toLowerCase();
1432
+
1433
+ if( seen.has( address ) ) return false;
1434
+
1435
+ seen.add( address );
1436
+
1437
+ return true;
1438
+
1439
+ });
1440
+
1441
+ };
1442
+
1443
+ // ONE NOTIFICATION, DESCRIBED. queue/notification.js owns delivery, the
1444
+ // unsubscribe token and the CAN-SPAM footer — these steps only say who and what.
1445
+ const queueNotification = ( data ) => ({ collection : 'notification', data, operation : 'create' });
1446
+
1113
1447
  var drawbridge = {
1114
1448
  auth : {
1115
1449
  type : 'none'
@@ -1127,10 +1461,15 @@ var drawbridge = {
1127
1461
  exclusive : false,
1128
1462
  fields : [],
1129
1463
  group : 'developer',
1130
- // EVERY BODY LIVES IN drawbridge-sync. Sending needs the provider clients, the
1131
- // suppression collection and the queues; segment sync needs the streams. A
1132
- // published package carrying those makes every consumer carry them, which is
1133
- // the reason `{}` exists as an answer.
1464
+ // THE BODIES LIVE HERE, beside the declarations that name them. They used to
1465
+ // live in drawbridge-sync because they touch the database, the queues and the
1466
+ // sockets and a published package cannot carry a controller.
1467
+ //
1468
+ // It does not have to. A hook is a function, so everything it needs is PASSED
1469
+ // IN: `read` for the reads, `canSend` for the opt-out floor, `resolveContact`
1470
+ // for the one write whose RESULT the hook has to count. Everything else a hook
1471
+ // wants done it DESCRIBES — `writes`, `enqueues`, `events` — and the shell
1472
+ // performs it. See lib/connections/contract.js for that shape.
1134
1473
  hooks : {
1135
1474
  auth : {
1136
1475
  // Nothing to connect, revoke, probe or re-scope.
@@ -1151,13 +1490,221 @@ var drawbridge = {
1151
1490
  // accounts DRAWBRIDGE holds rather than ones a merchant connects.
1152
1491
  contacts,
1153
1492
  email : {
1154
- digest : {},
1155
- // To organization members. NEVER suppressed and never billed: an
1156
- // entrant's opt-out must not silence an alert to staff, and staff mail is
1157
- // not a metered send.
1158
- notify : {},
1159
- // To a lead. Suppression applies and the send is billed.
1160
- send : {}
1493
+
1494
+ // A PERIODIC SUMMARY to the team, on a schedule trigger rather than per
1495
+ // lead.
1496
+ //
1497
+ // The count is the point: `email.notify` tells the owner one lead arrived
1498
+ // and dampens a spike to one message per bucket, which is deliberately not
1499
+ // a count. This is where "you got 43 entries this week" comes from.
1500
+ digest : async ( { context, step, workflow }, { read } = {} ) => {
1501
+
1502
+ // The window comes from the TRIGGER that fired it, so Daily/Weekly/
1503
+ // Monthly each summarise their own period without a second setting to
1504
+ // keep in step.
1505
+ const days = { day : 1, month : 30, week : 7 }[ workflow?.trigger?.event ] || 7;
1506
+
1507
+ const since = new Date( Date.now() - ( days * 24 * 60 * 60 * 1000 ) );
1508
+ const campaign = workflow?.trigger?.filters?.campaign || null;
1509
+
1510
+ const [ counted ] = await read.aggregate({
1511
+ collection : 'lead',
1512
+ pipeline : [
1513
+ {
1514
+ $match : {
1515
+ createdAt : { $gte : since },
1516
+ organization : workflow.organization,
1517
+ ...( campaign && { campaigns : { $in : [ campaign ] } })
1518
+ }
1519
+ },
1520
+ { $count : 'count' }
1521
+ ]
1522
+ });
1523
+
1524
+ const count = Number( counted?.count || 0 );
1525
+
1526
+ const request = { campaign, count, days };
1527
+
1528
+ // Nothing happened, so nobody is told. A digest reading "0 new leads" is
1529
+ // mail the merchant did not ask for and would learn to ignore.
1530
+ if( ! count ) return { message : 'No new leads in the period — digest skipped.', request, response : { skipped : true }, skipped : true };
1531
+
1532
+ const recipients = await teamRecipients({
1533
+ memberIds : step.settings?.members || [],
1534
+ organization : workflow.organization,
1535
+ read
1536
+ });
1537
+
1538
+ // `count` joins the interpolation values so a merchant can write
1539
+ // "{{count}} new entries this week" in the step's own copy.
1540
+ const values = { ...context, count };
1541
+
1542
+ return {
1543
+ message : 'Digest of ' + count + ' new lead(s) queued for ' + recipients.length + ' recipient(s).',
1544
+ request,
1545
+ response : { count, notified : recipients.length },
1546
+ writes : recipients.map( ( member ) => queueNotification({
1547
+ audience : 'member',
1548
+ message : interpolate( step.settings?.message, values ),
1549
+ organization : workflow.organization,
1550
+ send : { type : 'email', email : member.email },
1551
+ title : interpolate( step.settings?.subject, values ),
1552
+ workflow : workflow.id
1553
+ }) )
1554
+ };
1555
+
1556
+ },
1557
+
1558
+ // To the organization's OWN PEOPLE. Never suppressed, never
1559
+ // subscription-gated, no unsubscribe footer — telling an org's staff about
1560
+ // their own leads is not commercial mail to a stranger.
1561
+ //
1562
+ // FREE, permanently. The lead that triggered this run already consumed the
1563
+ // billable action, and `members` is a list — billing here would turn one
1564
+ // lead into five more charges and the org would be paying to read its own
1565
+ // mail. The declaration prices it at zero; the shell bills nothing for
1566
+ // zero.
1567
+ notify : async ( { context, step, workflow }, { read } = {} ) => {
1568
+
1569
+ const memberIds = step.settings?.members || [];
1570
+
1571
+ const request = { members : memberIds.length };
1572
+
1573
+ const recipients = await teamRecipients({ memberIds, organization : workflow.organization, read });
1574
+
1575
+ if( ! recipients.length ){
1576
+
1577
+ return {
1578
+ message : 'No owner or accepted member with an email address — team notification skipped.',
1579
+ request,
1580
+ response : { skipped : true },
1581
+ skipped : true
1582
+ };
1583
+
1584
+ }
1585
+
1586
+ // SPIKE DAMPER, as a fixed window rather than a sliding one.
1587
+ //
1588
+ // This used `coalesce`, which is supersede-and-delay: every new entry
1589
+ // pre-empted the pending job and re-enqueued it 30 seconds out. Under a
1590
+ // genuine spike — entries arriving faster than one per 30s, which is
1591
+ // what viral means — delivery deferred indefinitely and the owner heard
1592
+ // NOTHING until traffic dropped, precisely when they would most want to
1593
+ // know.
1594
+ //
1595
+ // `key` is first-write-wins against a partial unique index: the first
1596
+ // entry in each bucket creates the notification and it goes out
1597
+ // immediately, the rest collide and are refused. A spike yields at most
1598
+ // one heads-up per recipient per bucket, promptly, instead of one
1599
+ // eventually or never.
1600
+ //
1601
+ // Deliberately NOT a change to `coalesce` itself — stream/transaction.js
1602
+ // uses it for balance and credit notices, and its semantics are not ours
1603
+ // to redefine from here.
1604
+ const bucket = Math.floor( Date.now() / ( 15 * 60 * 1000 ) );
1605
+
1606
+ return {
1607
+ message : 'Team notification queued for ' + recipients.length + ' recipient(s).',
1608
+ request,
1609
+ response : { notified : recipients.length },
1610
+ writes : recipients.map( ( member ) => ({
1611
+ ...queueNotification({
1612
+ audience : 'member',
1613
+ // Per workflow, recipient AND bucket, so one recipient's damper
1614
+ // can never swallow another's mail and a later bucket is never
1615
+ // mistaken for a duplicate of an earlier one.
1616
+ key : 'team.notify.' + workflow.id + '.' + member.id + '.' + bucket,
1617
+ message : interpolate( step.settings?.message, context ),
1618
+ organization : workflow.organization,
1619
+ send : { type : 'email', email : member.email },
1620
+ title : interpolate( step.settings?.subject, context ),
1621
+ workflow : workflow.id
1622
+ }),
1623
+ // E11000 IS THE DAMPER WORKING: this recipient has already been
1624
+ // told within the bucket. Declared per write rather than assumed by
1625
+ // the shell, because on every other write here a duplicate key is a
1626
+ // real failure.
1627
+ ignoreDuplicate : true
1628
+ }) )
1629
+ };
1630
+
1631
+ },
1632
+
1633
+ // Drawbridge sends lead-facing email itself — no merchant provider gates
1634
+ // it.
1635
+ //
1636
+ // This QUEUES rather than sends: queue/notification.js owns delivery, the
1637
+ // unsubscribe token and the CAN-SPAM footer. The step's job is to say who
1638
+ // and what, correctly, and to refuse early when it must not send at all.
1639
+ send : async ( { context, step, workflow }, { canSend, read } = {} ) => {
1640
+
1641
+ const to = context?.email;
1642
+
1643
+ if( ! to ) throw new Error( 'No email address on context (context.email is required)' );
1644
+
1645
+ const request = { to };
1646
+
1647
+ // PRE-CHECKED HERE so an opted-out recipient skips WITHOUT billing. The
1648
+ // send() gate in queue/notification.js would cancel the doc anyway, but
1649
+ // only after the action had already counted.
1650
+ //
1651
+ // `canSend` is INJECTED rather than read through `read`: suppression is
1652
+ // the only consent source and canSend is its only reader, so a hook
1653
+ // querying the collection itself would be a second reader of the
1654
+ // opt-out floor — and the second one is the one that gets the query
1655
+ // wrong.
1656
+ const { ok : sendable } = await canSend({ channel : 'email', to });
1657
+
1658
+ if( ! sendable ) return { message : 'Recipient has opted out — skipped.', request, response : { skipped : true }, skipped : true };
1659
+
1660
+ // A LIVE SUBSCRIPTION, in two reads. Free organizations get system mail
1661
+ // only; a lead-facing send is a paid feature.
1662
+ const organization = await read.get({ collection : 'organization', query : { id : workflow.organization } });
1663
+
1664
+ const subscription = organization?.subscription
1665
+ ? await read.get({ collection : 'subscription', query : { id : organization.subscription } })
1666
+ : null;
1667
+
1668
+ if( subscription?.status !== 'active' ){
1669
+
1670
+ return {
1671
+ message : 'Organization has no active subscription — workflow-step email skipped.',
1672
+ request,
1673
+ response : { skipped : true },
1674
+ skipped : true
1675
+ };
1676
+
1677
+ }
1678
+
1679
+ return {
1680
+ message : 'Email queued for delivery to ' + to + '.',
1681
+ request,
1682
+ response : { queued : true },
1683
+ // NO `connection` FIELD, deliberately: the platform sends this.
1684
+ // `audience : 'lead'` states what the queue would otherwise infer from
1685
+ // shape.
1686
+ //
1687
+ // `campaign` is not decoration. queue/notification.js mints the
1688
+ // unsubscribe token with it, so it decides whether opting out is
1689
+ // scoped to this campaign or the whole organization, and it names the
1690
+ // campaign in the footer. Sending without it silently broadens every
1691
+ // opt-out to the entire organization.
1692
+ writes : [
1693
+ queueNotification({
1694
+ audience : 'lead',
1695
+ campaign : context?.campaign || null,
1696
+ lead : context?.lead || null,
1697
+ message : interpolate( step.settings?.message, context ),
1698
+ organization : workflow.organization,
1699
+ send : { type : 'email', email : to },
1700
+ title : interpolate( step.settings?.subject, context ),
1701
+ workflow : workflow.id
1702
+ })
1703
+ ]
1704
+ };
1705
+
1706
+ }
1707
+
1161
1708
  },
1162
1709
  inbound : false,
1163
1710
  lifecycle : false,
@@ -1167,8 +1714,264 @@ var drawbridge = {
1167
1714
  products : false,
1168
1715
  promotions : false
1169
1716
  },
1170
- segment : { sync : {} },
1171
- sms : { send : {} },
1717
+ segment : {
1718
+
1719
+ // RECALCULATE SEGMENT MEMBERSHIP. The one FAN-OUT hook: it evaluates every
1720
+ // contact in an organization against every segment, which is too much for
1721
+ // one job, so it returns chunks and the shell defers completion.
1722
+ //
1723
+ // Returning `chunks` is the only thing that makes it different. The
1724
+ // declaration, the guards, the step document and the price are the shell's,
1725
+ // exactly as they are for a step that finishes in one go.
1726
+ sync : async ( { context, step }, { chunkSize, logger, read, resolveContact } = {} ) => {
1727
+
1728
+ // The fan-out width is the WORKER FLEET'S number, not the vendor's — how
1729
+ // much work one job may carry is a fact about the machines running it.
1730
+ // Required rather than defaulted, because a default here would silently
1731
+ // disagree with the deployment's own tuning and nothing would say so.
1732
+ if( ! chunkSize ) throw new Error( 'segment.sync needs chunkSize from the shell' );
1733
+
1734
+ const organization = context?.organization;
1735
+
1736
+ const configured = step?.settings?.segment;
1737
+
1738
+ const request = { organization : organization || null, segmentId : configured || null };
1739
+
1740
+ // A segment sitting at `syncing` with nothing running is a spinner that
1741
+ // never stops, so every exit below puts it back — DESCRIBED, and
1742
+ // performed by the shell. Including the throwing exit: the effects ride
1743
+ // out on the error, which is the only way a rejection can still release
1744
+ // what it locked.
1745
+ const release = ( ids, status = 'active' ) => {
1746
+
1747
+ const released = ( ids || [] ).filter( Boolean );
1748
+
1749
+ return {
1750
+ events : organization
1751
+ ? released.map( ( id ) => ({
1752
+ event : 'organization.segments',
1753
+ payload : { id, status },
1754
+ room : 'organization.' + organization
1755
+ }) )
1756
+ : [],
1757
+ writes : released.map( ( id ) => ({
1758
+ collection : 'segment',
1759
+ data : { $set : { status } },
1760
+ operation : 'update',
1761
+ query : { id }
1762
+ }) )
1763
+ };
1764
+
1765
+ };
1766
+
1767
+ if( ! organization ){
1768
+
1769
+ return {
1770
+ ...release([ configured ]),
1771
+ message : 'Trigger data missing organization id — cannot sync segments.',
1772
+ request,
1773
+ response : { skipped : true },
1774
+ skipped : true
1775
+ };
1776
+
1777
+ }
1778
+
1779
+ const segments = await read.aggregate({
1780
+ collection : 'segment',
1781
+ pipeline : [ { $match : configured ? { id : configured, organization } : { organization } } ]
1782
+ });
1783
+
1784
+ if( ! segments.length ){
1785
+
1786
+ return {
1787
+ ...release([ configured ]),
1788
+ message : 'No segments matched the request — nothing to sync.',
1789
+ request,
1790
+ response : { skipped : true },
1791
+ skipped : true
1792
+ };
1793
+
1794
+ }
1795
+
1796
+ const segmentIds = segments.map( ( entry ) => entry.id );
1797
+
1798
+ try {
1799
+
1800
+ let backfilled = 0;
1801
+
1802
+ // A SYSTEM SEGMENT evaluates contacts, so any lead without one has to
1803
+ // get one first or it can never be a member of anything.
1804
+ if( segments.some( ( entry ) => entry.system ) ){
1805
+
1806
+ const contacted = await read.aggregate({
1807
+ collection : 'contact',
1808
+ pipeline : [
1809
+ { $match : { organization } },
1810
+ { $project : { _id : 0, leads : 1 } },
1811
+ { $unwind : '$leads' },
1812
+ { $group : { _id : null, ids : { $addToSet : '$leads' } } }
1813
+ ]
1814
+ });
1815
+
1816
+ const uncontacted = await read.aggregate({
1817
+ collection : 'lead',
1818
+ pipeline : [
1819
+ { $match : { id : { $nin : contacted[ 0 ]?.ids || [] }, organization } },
1820
+ { $project : { _id : 0, id : 1 } }
1821
+ ]
1822
+ });
1823
+
1824
+ // THE ONE WRITE THIS HOOK CANNOT DESCRIBE, and the reason it is
1825
+ // injected instead. Its RESULT is an input to what the hook returns:
1826
+ // how many contacts this run had to create decides whether the
1827
+ // chunks bill, and a description cannot be counted before it runs.
1828
+ //
1829
+ // So the caller supplies the collaborator — the same shape as the
1830
+ // `shopify` SDK injection elsewhere in this directory — and the
1831
+ // hook still reaches for no controller of its own.
1832
+ for( const lead of uncontacted ){
1833
+
1834
+ try {
1835
+
1836
+ await resolveContact({ leadId : lead.id });
1837
+
1838
+ backfilled += 1;
1839
+
1840
+ } catch ( error ){
1841
+
1842
+ // A duplicate means a concurrent resolve created it first, which
1843
+ // is the outcome we wanted.
1844
+ if( error.code !== 11000 ) throw error;
1845
+
1846
+ }
1847
+
1848
+ }
1849
+
1850
+ logger?.info?.( 'segment.sync.backfill', { backfilled, organization, uncontacted : uncontacted.length });
1851
+
1852
+ }
1853
+
1854
+ const contacts = await read.aggregate({
1855
+ collection : 'contact',
1856
+ pipeline : [
1857
+ { $match : { organization } },
1858
+ { $project : { _id : 0, id : 1 } },
1859
+ { $sort : { id : 1 } }
1860
+ ]
1861
+ });
1862
+
1863
+ if( ! contacts.length ){
1864
+
1865
+ return {
1866
+ ...release( segmentIds ),
1867
+ message : 'Organization has no contacts to evaluate against segments.',
1868
+ request,
1869
+ response : { skipped : true },
1870
+ skipped : true
1871
+ };
1872
+
1873
+ }
1874
+
1875
+ const contactIds = contacts.map( ( contact ) => contact.id );
1876
+
1877
+ const org = await read.get({ collection : 'organization', query : { id : organization } });
1878
+
1879
+ // THE FAN-OUT. `chunks` tells the shell to open the step rather than
1880
+ // close it, and the SHELL builds one slot per chunk — the slot is the
1881
+ // step document's own shape, so counting them here was a manifest
1882
+ // carrying a schema that is not its.
1883
+ const chunks = [];
1884
+
1885
+ for( let index = 0 ; index < contactIds.length ; index += chunkSize ){
1886
+
1887
+ chunks.push({
1888
+ contactIds : contactIds.slice( index, index + chunkSize ),
1889
+ organization,
1890
+ segments : segmentIds,
1891
+ // A BACKFILL IS NOT BILLABLE. It creates the contacts this run
1892
+ // then evaluates, so charging for it would bill an organization
1893
+ // for work its own history made necessary.
1894
+ usage : ( context?.billable === true && backfilled === 0 ) ? org?.usage || null : null
1895
+ });
1896
+
1897
+ }
1898
+
1899
+ return {
1900
+ chunks,
1901
+ ...( configured && { extra : { segment : configured } }),
1902
+ message : 'Queued ' + contactIds.length + ' contacts across ' + chunks.length + ' chunks for segment evaluation.',
1903
+ queue : 'segment',
1904
+ request : { ...request, segments : segmentIds },
1905
+ response : { chunks : chunks.length, contacts : contactIds.length, segments : segments.length }
1906
+ };
1907
+
1908
+ } catch ( error ){
1909
+
1910
+ // A segment left mid-sync shows as errored rather than syncing forever.
1911
+ // The release rides OUT ON THE ERROR because a throw is how a hook
1912
+ // rejects, and the shell performs a rejection's effects before it
1913
+ // records the failure.
1914
+ throw Object.assign( error, release( segmentIds, 'error' ) );
1915
+
1916
+ }
1917
+
1918
+ }
1919
+
1920
+ },
1921
+ sms : {
1922
+
1923
+ // SMS TO A LEAD, through the merchant's own Twilio connection.
1924
+ //
1925
+ // WITHDRAWN from the builder — twilio went, and a connection-gated step
1926
+ // with no connection to gate on could only ever render permanently
1927
+ // disabled. Stored workflows still carry it, so it still runs.
1928
+ //
1929
+ // It looks its own connection up rather than relying on the shell, because
1930
+ // the step is declared by the PRIVATE drawbridge connection (which has
1931
+ // none) while the credential belongs to twilio (which has no manifest).
1932
+ // Platform SMS will remove that split the way it did for email.
1933
+ send : async ( { context, step, workflow }, { canSend, read } = {} ) => {
1934
+
1935
+ const to = context?.phone?.number;
1936
+
1937
+ if( ! to ) throw new Error( 'No phone number on context (context.phone.number is required)' );
1938
+
1939
+ const request = { to };
1940
+
1941
+ const connection = await read.get({
1942
+ collection : 'connection',
1943
+ query : { organization : workflow.organization, slug : 'twilio', status : 'active' }
1944
+ });
1945
+
1946
+ if( ! connection ) return { message : 'No active Twilio SMS connection — workflow-step SMS skipped.', request, response : { skipped : true }, skipped : true };
1947
+
1948
+ // PRE-CHECKED so an opted-out recipient skips WITHOUT billing. The
1949
+ // carrier opt-out is a legal obligation, not a preference.
1950
+ const { ok : sendable } = await canSend({ channel : 'sms', to : context.phone });
1951
+
1952
+ if( ! sendable ) return { message : 'Recipient has opted out — skipped.', request, response : { skipped : true }, skipped : true };
1953
+
1954
+ return {
1955
+ message : 'SMS queued for delivery to ' + to + ' via twilio.',
1956
+ request,
1957
+ response : { provider : 'twilio', queued : true },
1958
+ // QUEUES rather than sends: queue/notification.js owns delivery, the
1959
+ // carrier opt-out line and the segment count this is billed on.
1960
+ writes : [
1961
+ queueNotification({
1962
+ connection : connection.id,
1963
+ message : interpolate( step.settings?.message, context ),
1964
+ organization : workflow.organization,
1965
+ send : { phone : { number : to }, type : 'phone' },
1966
+ title : interpolate( step.settings?.subject, context ),
1967
+ workflow : workflow.id
1968
+ })
1969
+ ]
1970
+ };
1971
+
1972
+ }
1973
+
1974
+ },
1172
1975
  webhook : false
1173
1976
  },
1174
1977
  icon: icon$3,
@@ -1212,11 +2015,11 @@ var drawbridge = {
1212
2015
  key : 'Email — Digest',
1213
2016
  queue : 'notification',
1214
2017
  settings : {
1215
- // The organization OWNER is always a recipient, resolved in sync,
1216
- // so this is additional recipients rather than the list. It cannot
1217
- // be required: the members endpoint is owner-gated and the owner is
1218
- // not a member document, so a solo merchant has nothing to pick and
1219
- // could never save the step.
2018
+ // The organization OWNER is always a recipient, resolved by the
2019
+ // hook, so this is additional recipients rather than the list. It
2020
+ // cannot be required: the members endpoint is owner-gated and the
2021
+ // owner is not a member document, so a solo merchant has nothing to
2022
+ // pick and could never save the step.
1220
2023
  members : { of : 'string', type : 'array' },
1221
2024
  message : { required : true, type : 'string' },
1222
2025
  subject : { required : true, type : 'string' }
@@ -1395,7 +2198,8 @@ var klaviyo = {
1395
2198
  // differently, and it says so in hooks.auth.token rather than as a flag here.
1396
2199
  auth : {
1397
2200
  oauth : {
1398
- // NAMES the env vars holding OUR application's client. One identity,
2201
+ // NAMES the credentials holding OUR application's client keys into
2202
+ // the stored provider credentials, not env vars. One identity,
1399
2203
  // every merchant — the token is the merchant's and arrives from their
1400
2204
  // own consent, which is what stops one organization reading another's
1401
2205
  // data.
@@ -1544,7 +2348,7 @@ var klaviyo = {
1544
2348
  // renders an empty "Klaviyo account" field, because the merchant is
1545
2349
  // never asked which account they connected — the consent already
1546
2350
  // decided it, and asking again would be a question we can answer.
1547
- connect : async ({ fetcher, tokens }) => {
2351
+ connect : async ( { tokens }, { fetcher } = {} ) => {
1548
2352
 
1549
2353
  const body = await api( '/accounts', { fetcher, token : tokens.accessToken });
1550
2354
 
@@ -1563,7 +2367,7 @@ var klaviyo = {
1563
2367
  //
1564
2368
  // Basic auth with our client, exactly like the token exchange — the
1565
2369
  // token being revoked is the subject, not the credential.
1566
- disconnect : async ({ clientId, clientSecret, fetcher = fetch, manifest, settings }) => {
2370
+ disconnect : async ( { clientId, clientSecret, manifest, settings }, { fetcher = fetch } = {} ) => {
1567
2371
 
1568
2372
  const token = settings?.refreshToken || settings?.accessToken;
1569
2373
 
@@ -1595,7 +2399,7 @@ var klaviyo = {
1595
2399
  // the refresh token is the only thing that asks Klaviyo.
1596
2400
  //
1597
2401
  // It also keeps the grant warm against the 90-day idle window above.
1598
- probe : async ({ clientId, clientSecret, fetcher, manifest, settings }) => {
2402
+ probe : async ( { clientId, clientSecret, manifest, settings }, { fetcher } = {} ) => {
1599
2403
 
1600
2404
  const token = await accessToken({
1601
2405
  clientId,
@@ -1634,14 +2438,13 @@ var klaviyo = {
1634
2438
  commerce : false,
1635
2439
 
1636
2440
  // The verb the contacts.sync step points at. It does the work — including
1637
- // writing the profile id back onto the lead — and returns what happened.
1638
2441
  contacts : {
1639
2442
 
1640
2443
  // Not yet. Suppression syncs an opt-out as unsubscribed, which is a
1641
2444
  // different thing from deleting the profile.
1642
2445
  remove : false,
1643
2446
 
1644
- sync : async ({ contact, fetcher, lead, settings, suppressed, token }) => {
2447
+ sync : async ( { contact, lead, settings, suppressed, token }, { fetcher } = {} ) => {
1645
2448
 
1646
2449
  const list = settings?.list;
1647
2450
 
@@ -1768,7 +2571,7 @@ var klaviyo = {
1768
2571
  // it, so one call quietly returns the first ten lists and an account
1769
2572
  // with more shows a picker missing the one they wanted, with nothing to
1770
2573
  // indicate anything was cut.
1771
- audiences : async ({ cursor, fetcher, limit = 100, search, token }) => {
2574
+ audiences : async ( { cursor, limit = 100, search, token }, { fetcher } = {} ) => {
1772
2575
 
1773
2576
  // `limit` is what the CALLER wants back rather than what one request
1774
2577
  // can carry — the loop keeps pulling until it has that many or the
@@ -2023,7 +2826,7 @@ var mailchimp = {
2023
2826
  // The header here is `OAuth <token>`, not Bearer — that is specific to
2024
2827
  // the metadata endpoint. Marketing API calls take Bearer; see the
2025
2828
  // audiences hook.
2026
- connect : async ({ fetcher = fetch, tokens }) => {
2829
+ connect : async ( { tokens }, { fetcher = fetch } = {} ) => {
2027
2830
 
2028
2831
  const response = await fetcher( 'https://login.mailchimp.com/oauth2/metadata', {
2029
2832
  headers : {
@@ -2086,7 +2889,7 @@ var mailchimp = {
2086
2889
  // successful — the same silent truncation Klaviyo has, at a different
2087
2890
  // number. Paged against total_items so an account past a thousand still
2088
2891
  // resolves.
2089
- audiences : async ({ cursor, fetcher = fetch, limit = 100, search, settings, token }) => {
2892
+ audiences : async ( { cursor, limit = 100, search, settings, token }, { fetcher = fetch } = {} ) => {
2090
2893
 
2091
2894
  // Bearer, not Basic. Mailchimp's fundamentals doc states "API keys and
2092
2895
  // OAuth 2 tokens can be used to make authenticated requests the same
@@ -2204,14 +3007,30 @@ var icon = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmln
2204
3007
  //
2205
3008
  // A vendor whose scheme is "hash the raw body with a shared secret and compare,
2206
3009
  // constant-time, against a header" declares that shape as `inbound.signature`
2207
- // data on its manifest (algorithm, encoding, the env var naming the secret) and
3010
+ // data on its manifest (algorithm, encoding, the NAME of the credential) and
2208
3011
  // wires this straight in as its hook — Shopify does exactly that.
2209
3012
  //
2210
3013
  // A vendor whose scheme is not that shape — Twilio signs the REGISTERED URL
2211
3014
  // rather than the body, a JWT-bodied vendor verifies against a JWKS — writes its
2212
3015
  // own inbound.verify instead. That is why verify is a hook and not config: this
2213
3016
  // file covers the common case, not the contract.
2214
- const verifySignature = ({ body, descriptor, headers }) => {
3017
+ const verifySignature = ({ body, descriptor, headers, secret }) => {
3018
+
3019
+ // THE MANIFEST STILL DECLARES THE NAME, the caller supplies the value.
3020
+ // descriptor.signature.secret is 'SHOPIFY_API_SECRET' and stays that way —
3021
+ // naming which credential a vendor needs is the manifest's job. Reading it is
3022
+ // not: the value lives encrypted in the `provider` collection, and a package
3023
+ // that reached into process.env for it would force every service to copy the
3024
+ // collection back into its environment at boot before this could work.
3025
+ //
3026
+ // A 500 rather than the 401 below, because a secret we never loaded is our
3027
+ // misconfiguration and must not be indistinguishable in the logs from the
3028
+ // forged request that gets the same door slammed on it.
3029
+ if( ! secret ){
3030
+
3031
+ throw Object.assign( new Error( 'Missing webhook secret: ' + descriptor.signature.secret ), { status : 500 });
3032
+
3033
+ }
2215
3034
 
2216
3035
  const provided = headers[ descriptor.headers.signature ];
2217
3036
 
@@ -2221,7 +3040,7 @@ const verifySignature = ({ body, descriptor, headers }) => {
2221
3040
 
2222
3041
  }
2223
3042
 
2224
- const digest = createHmac( descriptor.signature.algorithm, process.env[ descriptor.signature.secret ] )
3043
+ const digest = createHmac( descriptor.signature.algorithm, secret )
2225
3044
  .update( body )
2226
3045
  .digest( descriptor.signature.encoding );
2227
3046
 
@@ -2249,6 +3068,75 @@ const verifySignature = ({ body, descriptor, headers }) => {
2249
3068
  // THE SHARED HALF OF inbound.event, for a vendor that puts it in a header.
2250
3069
  const readEventHeader = ({ descriptor, headers }) => headers[ descriptor.headers.event ];
2251
3070
 
3071
+ // ── ORDER ATTRIBUTION, the vendor's own arithmetic ───────────────────────────
3072
+ //
3073
+ // Orders are attributed via `_drwbrdg_*` line-item properties injected at
3074
+ // add-to-cart (NOT url params); only items tagged with `_drwbrdg_ca` count
3075
+ // toward a conversion's attributed gross. This is Shopify's payload shape, so
3076
+ // it lives with the vendor — it was drawbridge-sync's lib/attribution.js.
3077
+
3078
+ const toLine = ({
3079
+ price,
3080
+ product_id : productId,
3081
+ quantity,
3082
+ title,
3083
+ variant_id : variantId,
3084
+ variant_title : variantTitle
3085
+ }) => ({
3086
+ price : parseFloat( price ) || 0,
3087
+ productId : productId ? 'gid://shopify/Product/' + productId : null,
3088
+ quantity : quantity || 1,
3089
+ title : title || null,
3090
+ variantId : variantId ? 'gid://shopify/ProductVariant/' + variantId : null,
3091
+ variantTitle : variantTitle || null
3092
+ });
3093
+
3094
+ // Reduce an order's line items to the campaign attribution: the first tagged
3095
+ // item's property map wins for ids (attrMap), gross/lines accumulate across
3096
+ // every tagged item. Untagged items contribute nothing.
3097
+ const attributeLineItems = ( lineItems = [] ) => lineItems.reduce(
3098
+ ( acc, item ) => {
3099
+
3100
+ const attrs = ( item.properties || [] ).reduce(
3101
+ ( map, { name, value } ) => {
3102
+
3103
+ map[ name ] = value;
3104
+
3105
+ return map;
3106
+
3107
+ },
3108
+ {}
3109
+ );
3110
+
3111
+ if( ! attrs[ '_drwbrdg_ca' ] ) return acc;
3112
+
3113
+ if( ! Object.keys( acc.attrMap ).length ) acc.attrMap = attrs;
3114
+
3115
+ const line = toLine( item );
3116
+
3117
+ acc.attributedGross += line.price * line.quantity;
3118
+ acc.attributedLines.push( line );
3119
+
3120
+ return acc;
3121
+
3122
+ },
3123
+ { attrMap : {}, attributedGross : 0, attributedLines : [] }
3124
+ );
3125
+
3126
+ // Unambiguous alphabet and length, because a merchant reads these aloud and
3127
+ // types them into a checkout.
3128
+ const generateDiscountCode = customAlphabet( '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', 8 );
3129
+
3130
+ // Rotate before the window closes rather than at it: Shopify's refresh token has
3131
+ // an expiry, and a rotation attempted after it has passed cannot succeed.
3132
+ const REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1000;
3133
+
3134
+ const OAUTH_ERROR_SOURCE = 'oauth';
3135
+
3136
+ // What Shopify says when the merchant has uninstalled or revoked. Neither is
3137
+ // retryable and both mean the same thing to a merchant: reconnect.
3138
+ const OAUTH_GRANT_REVOKED_CODES = [ 'application_cannot_be_found', 'invalid_grant' ];
3139
+
2252
3140
  // WHERE THIS VENDOR PUTS THINGS ON AN INBOUND REQUEST. Header names are facts
2253
3141
  // about someone else's product, so they belong beside the rest of the vendor
2254
3142
  // rather than as string literals in a route — which is where they were, and is
@@ -2391,20 +3279,670 @@ var shopify = {
2391
3279
  //
2392
3280
  // `shopify` is injected for the same reason it is everywhere else — this
2393
3281
  // package cannot import @drawbridge/shopify, which depends on it.
2394
- scopes : ({ scope, shopify }) => ( scope ? shopify.oauth.missingScopes( scope ) : null ),
3282
+ scopes : ( { scope }, { shopify } = {} ) => ( scope ? shopify.oauth.missingScopes( scope ) : null ),
2395
3283
  // Shopify's install grant is exchanged inside its own app flow, not
2396
3284
  // through the shared OAuth runner.
2397
3285
  token : false
2398
3286
  },
2399
- // Implemented in drawbridge-sync, which owns the attribution and the
2400
- // controllers it needs. Declared here so the steps below can point at them:
2401
- // a step naming a hook the vendor does not implement is a workflow that
2402
- // accepts the step and then silently does nothing.
3287
+ // THE VENDOR'S OWN WORK, here in full. Every body describes its writes,
3288
+ // enqueues and events for the shell to perform see contract.js and
3289
+ // everything it needs arrives as an argument: `read` (the controller's
3290
+ // read methods, nothing that writes), `shopify` (the SDK, injected because
3291
+ // this package cannot import what depends on it), `adminToken` (minted by
3292
+ // the shell, which persists rotations), `mintId` (so one described write
3293
+ // can reference another), `dispatch` (the caller's own coordinator table,
3294
+ // for the hooks that are dispatches).
2403
3295
  commerce : {
2404
- code : {},
2405
- customer : {},
2406
- order : {},
2407
- product : {}
3296
+
3297
+ // MINT A DISCOUNT CODE against the merchant's chosen discount, mapped to
3298
+ // one lead — which is what lets an order that redeems it be attributed
3299
+ // back.
3300
+ code : async ( { connection, context, step }, { adminToken, shopify } = {} ) => {
3301
+
3302
+ const discount = step.settings?.discount;
3303
+
3304
+ const request = { email : context?.email || null, lead : context?.lead || null, shop : connection.shop };
3305
+
3306
+ if( ! context?.email ) return { message : 'Lead email is missing.', request, response : { skipped : true }, skipped : true };
3307
+ if( ! context?.lead ) return { message : 'Lead id is missing.', request, response : { skipped : true }, skipped : true };
3308
+ if( ! discount?.id ) return { message : 'Discount is not configured on this step.', request, response : { skipped : true }, skipped : true };
3309
+
3310
+ const adminAccessToken = await adminToken();
3311
+
3312
+ // The customer must exist before a code is mapped to them. An earlier
3313
+ // commerce.customer step usually did this and left the id on the
3314
+ // context; when this step runs alone, it does it here.
3315
+ if( ! context.shopifyCustomerId ){
3316
+
3317
+ const customer = await shopify.admin.getOrCreateCustomer({ adminAccessToken, domain : connection.shop, email : context.email });
3318
+
3319
+ if( ! customer?.id ) return { message : 'Shopify did not return a customer id — create/lookup failed.', request, response : { skipped : true }, skipped : true };
3320
+
3321
+ }
3322
+
3323
+ const discountCode = await shopify.admin.createDiscountCode({
3324
+ adminAccessToken,
3325
+ code : 'DB-' + generateDiscountCode(),
3326
+ discountId : discount.id,
3327
+ domain : connection.shop
3328
+ });
3329
+
3330
+ if( ! discountCode ) return { message : 'Shopify did not return a discount code — create failed.', request, response : { skipped : true }, skipped : true };
3331
+
3332
+ return {
3333
+ context : {
3334
+ shopifyDiscountCode : discountCode.code,
3335
+ shopifyDiscountId : String( discountCode.id )
3336
+ },
3337
+ message : 'Discount code created and linked to lead.',
3338
+ request,
3339
+ response : { code : discountCode.code, id : String( discountCode.id ) },
3340
+ // bypassDocumentValidation because these are vendor ids on a
3341
+ // Drawbridge document the schema does not declare — the
3342
+ // canonical-identity work resolves it properly.
3343
+ writes : [ {
3344
+ collection : 'lead',
3345
+ data : {
3346
+ $set : {
3347
+ shopifyDiscountCode : discountCode.code,
3348
+ shopifyDiscountId : String( discountCode.id )
3349
+ }
3350
+ },
3351
+ operation : 'update',
3352
+ options : { bypassDocumentValidation : true },
3353
+ query : { id : context.lead }
3354
+ } ]
3355
+ };
3356
+
3357
+ },
3358
+
3359
+ // CREATE THE BUYER AT THE STORE, so an order can be attributed to them.
3360
+ //
3361
+ // IDEMPOTENT THREE WAYS, because this runs on every entry and a duplicate
3362
+ // customer at the store is a support ticket: the context may already
3363
+ // carry the id from an earlier step, the lead may already be linked from
3364
+ // an earlier run, and Shopify's own get-or-create settles the rest.
3365
+ customer : async ( { connection, context }, { adminToken, read, shopify } = {} ) => {
3366
+
3367
+ const request = { email : context?.email || null, lead : context?.lead || null, shop : connection.shop };
3368
+
3369
+ if( ! context?.email ) return { message : 'Lead email is missing — cannot create Shopify customer.', request, response : { skipped : true }, skipped : true };
3370
+ if( ! context?.lead ) return { message : 'Lead id is missing — cannot create Shopify customer.', request, response : { skipped : true }, skipped : true };
3371
+
3372
+ // Already known from an earlier step in this run.
3373
+ if( context.shopifyCustomerId ){
3374
+
3375
+ return {
3376
+ context : { shopifyCustomerId : context.shopifyCustomerId },
3377
+ message : 'Trigger data already includes a Shopify customer id — reusing.',
3378
+ request,
3379
+ response : { shopifyCustomerId : context.shopifyCustomerId },
3380
+ // Reusing an id is not a creation, so it does not bill.
3381
+ skipped : true
3382
+ };
3383
+
3384
+ }
3385
+
3386
+ const lead = await read.get({ collection : 'lead', query : { id : context.lead } });
3387
+
3388
+ // Already linked by an earlier run.
3389
+ if( lead?.shopifyCustomerId ){
3390
+
3391
+ return {
3392
+ context : { shopifyCustomerId : lead.shopifyCustomerId },
3393
+ message : 'Lead already has a Shopify customer id — reusing.',
3394
+ request,
3395
+ response : { shopifyCustomerId : lead.shopifyCustomerId },
3396
+ skipped : true
3397
+ };
3398
+
3399
+ }
3400
+
3401
+ const adminAccessToken = await adminToken();
3402
+
3403
+ // Shopify keeps first and last separately; Drawbridge keeps one name.
3404
+ // Split on the first space and give everything after it to the
3405
+ // surname, which is wrong for some names and is what the vendor's
3406
+ // shape allows.
3407
+ const parts = ( lead?.name || '' ).trim().split( /\s+/ ).filter( Boolean );
3408
+
3409
+ const customer = await shopify.admin.getOrCreateCustomer({
3410
+ adminAccessToken,
3411
+ domain : connection.shop,
3412
+ email : context.email,
3413
+ firstName : parts.length ? parts[ 0 ] : null,
3414
+ lastName : parts.length > 1 ? parts.slice( 1 ).join( ' ' ) : null,
3415
+ source : 'drawbridge'
3416
+ });
3417
+
3418
+ if( ! customer?.id ) return { message : 'Shopify did not return a customer id — create/lookup failed.', request, response : { skipped : true }, skipped : true };
3419
+
3420
+ return {
3421
+ context : { shopifyCustomerId : customer.id },
3422
+ message : 'Shopify customer created/linked to lead.',
3423
+ request,
3424
+ response : { shopifyCustomerId : customer.id },
3425
+ // The hook's own result, described beside the call that produced it.
3426
+ writes : [ {
3427
+ collection : 'lead',
3428
+ data : { $set : { shopifyCustomerId : customer.id } },
3429
+ operation : 'update',
3430
+ options : { bypassDocumentValidation : true },
3431
+ query : { id : context.lead }
3432
+ } ]
3433
+ };
3434
+
3435
+ },
3436
+
3437
+ // AN ORDER ARRIVED AT THE STORE. The largest hook in the family, because
3438
+ // attribution genuinely is: an order can reach Drawbridge two ways and
3439
+ // they bill differently.
3440
+ //
3441
+ // CONVERSION — a `_drwbrdg_ca` line-item property, injected at
3442
+ // add-to-cart. Causal: the campaign produced the sale, so
3443
+ // it carries a fee.
3444
+ // REDEMPTION — a DB- discount code matched to a lead. Associative: we
3445
+ // cannot claim we caused the purchase, so it is fee-free.
3446
+ //
3447
+ // Both can be true, and an order already recorded as a conversion can
3448
+ // later have a redemption backfilled onto it — `backfill` below.
3449
+ //
3450
+ // IDEMPOTENT THROUGH THE RETRY, one layer up: two deliveries of the same
3451
+ // order race, the loser's transaction hits a duplicate key, the step
3452
+ // fails and BullMQ redelivers — and the re-run's read at the top finds
3453
+ // what the winner wrote and skips instead of double-billing a merchant
3454
+ // for one purchase. The hook used to loop for this itself; describing
3455
+ // the writes moved the retry to the queue, with the same guarantee.
3456
+ order : async ( { connection, context }, { logger, mintId, read } = {} ) => {
3457
+
3458
+ const {
3459
+ advertisement,
3460
+ created_at : createdAt,
3461
+ currency,
3462
+ customer : orderCustomer,
3463
+ email,
3464
+ id : orderId,
3465
+ line_items : lineItems = [],
3466
+ organization,
3467
+ phone
3468
+ } = context || {};
3469
+
3470
+ const request = { orderId : orderId ? String( orderId ) : null, organization };
3471
+
3472
+ const [ existingOrder, existingRedemption ] = await Promise.all([
3473
+ read.get({ collection : 'order', query : { 'provider.id' : String( orderId ), 'provider.slug' : 'shopify' } }),
3474
+ read.get({ collection : 'redemption', query : { 'provider.id' : String( orderId ), 'provider.slug' : 'shopify' } })
3475
+ ]);
3476
+
3477
+ // Already fully recorded. This is the branch the redelivery exists to
3478
+ // reach.
3479
+ if( existingRedemption ){
3480
+
3481
+ return {
3482
+ message : 'Order/redemption already recorded — skipping duplicate.',
3483
+ request,
3484
+ response : {
3485
+ existingOrderId : existingOrder?.id || null,
3486
+ existingRedemptionId : existingRedemption.id,
3487
+ skipped : true
3488
+ },
3489
+ skipped : true
3490
+ };
3491
+
3492
+ }
3493
+
3494
+ const backfill = ! ! existingOrder;
3495
+
3496
+ // ONLY `_drwbrdg_ca`-TAGGED LINES COUNT toward attributed gross.
3497
+ const { attrMap, attributedGross, attributedLines } = attributeLineItems( lineItems );
3498
+
3499
+ const campaign = attrMap[ '_drwbrdg_ca' ] || null;
3500
+
3501
+ const discountCodes = Array.isArray( context?.discount_codes ) ? context.discount_codes : [];
3502
+ const codes = [ ...new Set( discountCodes.map( ( dc ) => dc?.code ).filter( Boolean ) ) ];
3503
+
3504
+ const matchedLeads = codes.length
3505
+ ? await read.aggregate({
3506
+ collection : 'lead',
3507
+ pipeline : [ { $match : { organization, shopifyDiscountCode : { $in : codes } } } ]
3508
+ })
3509
+ : [];
3510
+
3511
+ const codeToLead = {};
3512
+
3513
+ for( const found of matchedLeads ){
3514
+
3515
+ if( found.shopifyDiscountCode ) codeToLead[ found.shopifyDiscountCode ] = found;
3516
+
3517
+ }
3518
+
3519
+ const matchedDiscounts = discountCodes
3520
+ .filter( ( dc ) => dc?.code && codeToLead[ dc.code ] )
3521
+ .map( ( dc ) => ({
3522
+ amount : parseFloat( dc.amount ) || 0,
3523
+ code : dc.code,
3524
+ id : codeToLead[ dc.code ].shopifyDiscountId || null
3525
+ }) );
3526
+
3527
+ const matchedLead = matchedDiscounts.length ? codeToLead[ matchedDiscounts[ 0 ].code ] : null;
3528
+
3529
+ const discount = matchedDiscounts.length
3530
+ ? {
3531
+ amount : matchedDiscounts.reduce( ( sum, entry ) => sum + entry.amount, 0 ),
3532
+ codes : matchedDiscounts
3533
+ }
3534
+ : null;
3535
+
3536
+ const matchedCodes = new Set( matchedDiscounts.map( ( entry ) => entry.code ) );
3537
+
3538
+ // A DB- code we minted that matched no lead. Logged rather than
3539
+ // ignored: it means a code went out and its lead link was lost, which
3540
+ // is revenue we cannot attribute and nobody would otherwise notice.
3541
+ const unmatched = codes.filter( ( code ) => code.startsWith( 'DB-' ) && ! matchedCodes.has( code ) );
3542
+
3543
+ if( unmatched.length ){
3544
+
3545
+ logger?.warn?.( 'shopify.order.discount.unmatched', {
3546
+ campaign : campaign || null,
3547
+ codes : JSON.stringify( unmatched ),
3548
+ isConversion : ! ! campaign,
3549
+ orderId : String( orderId ),
3550
+ organization
3551
+ });
3552
+
3553
+ }
3554
+
3555
+ if( ( ! campaign && ! discount ) || ( backfill && ! discount ) ){
3556
+
3557
+ return {
3558
+ message : backfill
3559
+ ? 'Order already recorded and no Drawbridge discount code matched — nothing to backfill.'
3560
+ : 'Order has no Drawbridge attribution — not recording.',
3561
+ request,
3562
+ response : { skipped : true },
3563
+ skipped : true
3564
+ };
3565
+
3566
+ }
3567
+
3568
+ let advertisementId = null;
3569
+ let affiliateId = null;
3570
+ let campaignOrganization = organization;
3571
+ let gross = 0;
3572
+ let leadId = null;
3573
+ let lines = [];
3574
+ let orderCampaign = null;
3575
+ let pageId = null;
3576
+
3577
+ const isConversion = ! ! campaign;
3578
+
3579
+ const customerPhone = toE164( orderCustomer?.phone || phone ) || null;
3580
+
3581
+ const matchPhones = [ ...new Set([
3582
+ customerPhone,
3583
+ toE164( context?.billing_address?.phone ),
3584
+ toE164( context?.shipping_address?.phone )
3585
+ ].filter( Boolean ) ) ];
3586
+
3587
+ if( isConversion ){
3588
+
3589
+ const campaignDoc = await read.get({ collection : 'campaign', query : { id : campaign } });
3590
+
3591
+ // THE CAMPAIGN MUST BELONG TO THE DELIVERING SHOP'S OWN ORG.
3592
+ //
3593
+ // `_drwbrdg_ca` is a line-item property, and on most Shopify themes
3594
+ // a buyer can attach arbitrary line-item properties via cart
3595
+ // permalinks or the AJAX cart API — and campaign ids are
3596
+ // discoverable from public campaign links. Without this check, a $1
3597
+ // order on ANY connected store carrying another org's campaign id
3598
+ // records a conversion under that org: its revenue totals climb,
3599
+ // its usage document is incremented, and its matching leads gain
3600
+ // order counts — a cross-tenant write driven entirely by the buyer.
3601
+ //
3602
+ // A stale or garbage id lands here too, so this is also the null
3603
+ // check: either way the order simply has no Drawbridge attribution.
3604
+ if( ! campaignDoc || campaignDoc.organization !== organization ){
3605
+
3606
+ return {
3607
+ message : 'Order carried a campaign attribution that does not belong to this store — not recording.',
3608
+ request,
3609
+ response : { skipped : true },
3610
+ skipped : true
3611
+ };
3612
+
3613
+ }
3614
+
3615
+ advertisementId = attrMap[ '_drwbrdg_ad' ] || advertisement || null;
3616
+ affiliateId = attrMap[ '_drwbrdg_af' ] || null;
3617
+ campaignOrganization = campaignDoc.organization;
3618
+ gross = attributedGross;
3619
+ lines = attributedLines;
3620
+ orderCampaign = campaign;
3621
+ pageId = attrMap[ '_drwbrdg_pg' ] || null;
3622
+
3623
+ // MATCHED ON EVERY IDENTITY WE HOLD, canonical forms included,
3624
+ // because the address on an order is often not the one they entered
3625
+ // with.
3626
+ const identifiers = [];
3627
+
3628
+ const canonicalEmail = toCanonicalEmail( email );
3629
+
3630
+ if( email ) identifiers.push({ email : email.toLowerCase() });
3631
+ if( canonicalEmail ) identifiers.push({ 'canonical.email.value' : canonicalEmail });
3632
+ if( matchPhones.length ) identifiers.push({ 'phone.number' : { $in : matchPhones } });
3633
+ if( matchPhones.length ) identifiers.push({ 'canonical.phone.value' : { $in : matchPhones } });
3634
+
3635
+ if( identifiers.length ){
3636
+
3637
+ // Narrowed to the campaign first; an org-wide match is the
3638
+ // fallback, because a buyer who entered a different campaign is
3639
+ // still the same person and still worth linking.
3640
+ const lead = await read.get({
3641
+ collection : 'lead',
3642
+ query : {
3643
+ campaigns : { $in : [ campaign ] },
3644
+ organization : campaignOrganization,
3645
+ $or : identifiers
3646
+ }
3647
+ });
3648
+
3649
+ leadId = lead?.id || null;
3650
+
3651
+ if( ! leadId ){
3652
+
3653
+ const orgLead = await read.get({
3654
+ collection : 'lead',
3655
+ query : { organization : campaignOrganization, $or : identifiers }
3656
+ });
3657
+
3658
+ leadId = orgLead?.id || null;
3659
+
3660
+ }
3661
+
3662
+ }
3663
+
3664
+ } else {
3665
+
3666
+ // REDEMPTION. The lead is known from the code, and the whole order
3667
+ // counts as gross — there are no tagged lines to narrow it to.
3668
+ leadId = matchedLead.id;
3669
+ orderCampaign = ( matchedLead.campaigns || [] ).length === 1 ? matchedLead.campaigns[ 0 ] : null;
3670
+ gross = lineItems.reduce( ( sum, item ) => {
3671
+
3672
+ const line = toLine( item );
3673
+
3674
+ return sum + ( line.price * line.quantity );
3675
+
3676
+ }, 0 );
3677
+ lines = lineItems.map( toLine );
3678
+
3679
+ }
3680
+
3681
+ const org = await read.get({ collection : 'organization', query : { id : campaignOrganization } });
3682
+
3683
+ let rate = 0;
3684
+
3685
+ // THE FEE IS THE CONVERSION FEE, and only a conversion carries one. A
3686
+ // redemption is associative — we cannot claim we caused the purchase —
3687
+ // so it is recorded fee-free.
3688
+ if( isConversion ){
3689
+
3690
+ const subscription = await read.get({ collection : 'subscription', query : { id : org?.subscription } });
3691
+
3692
+ rate = conversionRate( subscription );
3693
+
3694
+ }
3695
+
3696
+ const fee = isConversion ? Math.round( gross * rate ) / 100 : 0;
3697
+ const net = Math.round( ( gross - fee ) * 100 ) / 100;
3698
+
3699
+ const currencyCode = ( currency || 'usd' ).toLowerCase();
3700
+ const purchasedAt = new Date( createdAt || Date.now() );
3701
+
3702
+ const customer = ( orderCustomer || email || phone )
3703
+ ? {
3704
+ acceptsMarketing : orderCustomer?.email_marketing_consent?.state
3705
+ ? orderCustomer.email_marketing_consent.state === 'subscribed'
3706
+ : ( typeof orderCustomer?.accepts_marketing === 'boolean' ? orderCustomer.accepts_marketing : null ),
3707
+ email : orderCustomer?.email || email || null,
3708
+ firstName : orderCustomer?.first_name || null,
3709
+ id : orderCustomer?.id ? String( orderCustomer.id ) : null,
3710
+ lastName : orderCustomer?.last_name || null,
3711
+ phone : customerPhone
3712
+ }
3713
+ : null;
3714
+
3715
+ const source = connection?.source
3716
+ ? { domain : connection.source.domain, id : connection.source.id }
3717
+ : undefined;
3718
+
3719
+ // MINTED HERE, because the redemption names its order and the usage
3720
+ // job names both — a description cannot read a write's result, so the
3721
+ // id exists before either does.
3722
+ const orderDocId = existingOrder?.id || ( isConversion && ! backfill ? mintId() : null );
3723
+
3724
+ const writes = [];
3725
+
3726
+ if( isConversion && ! backfill ){
3727
+
3728
+ writes.push({
3729
+ collection : 'order',
3730
+ data : {
3731
+ advertisement : advertisementId,
3732
+ affiliate : affiliateId,
3733
+ campaign : orderCampaign,
3734
+ currency : currencyCode,
3735
+ customer,
3736
+ discount,
3737
+ fee,
3738
+ gross,
3739
+ id : orderDocId,
3740
+ lead : leadId,
3741
+ lines,
3742
+ net,
3743
+ organization : campaignOrganization,
3744
+ page : pageId,
3745
+ provider : { id : String( orderId ), slug : 'shopify' },
3746
+ purchasedAt,
3747
+ rate,
3748
+ source,
3749
+ status : 'completed'
3750
+ },
3751
+ operation : 'create'
3752
+ });
3753
+
3754
+ if( org?.usage ){
3755
+
3756
+ writes.push({
3757
+ collection : 'usage',
3758
+ data : { $inc : { 'totals.revenue' : gross } },
3759
+ operation : 'update',
3760
+ query : { id : org.usage }
3761
+ });
3762
+
3763
+ }
3764
+
3765
+ if( leadId ){
3766
+
3767
+ writes.push({
3768
+ collection : 'lead',
3769
+ data : { $inc : { 'totals.orders' : 1 } },
3770
+ operation : 'update',
3771
+ options : { bypassDocumentValidation : true },
3772
+ query : { id : leadId }
3773
+ });
3774
+
3775
+ }
3776
+
3777
+ }
3778
+
3779
+ if( discount ){
3780
+
3781
+ writes.push({
3782
+ collection : 'redemption',
3783
+ data : {
3784
+ advertisement : advertisementId,
3785
+ affiliate : affiliateId,
3786
+ campaign : orderCampaign,
3787
+ code : matchedDiscounts[ 0 ]?.code || null,
3788
+ currency : currencyCode,
3789
+ customer,
3790
+ discount,
3791
+ gross,
3792
+ lead : leadId,
3793
+ order : orderDocId,
3794
+ organization : campaignOrganization,
3795
+ page : pageId,
3796
+ provider : { id : String( orderId ), slug : 'shopify' },
3797
+ purchasedAt,
3798
+ source,
3799
+ status : 'completed'
3800
+ },
3801
+ operation : 'create'
3802
+ });
3803
+
3804
+ if( org?.usage ){
3805
+
3806
+ writes.push({
3807
+ collection : 'usage',
3808
+ data : { $inc : { 'totals.redemptions' : 1 } },
3809
+ operation : 'update',
3810
+ query : { id : org.usage }
3811
+ });
3812
+
3813
+ }
3814
+
3815
+ if( leadId ){
3816
+
3817
+ writes.push({
3818
+ collection : 'lead',
3819
+ data : { $inc : { 'totals.redemptions' : 1 } },
3820
+ operation : 'update',
3821
+ options : { bypassDocumentValidation : true },
3822
+ query : { id : leadId }
3823
+ });
3824
+
3825
+ }
3826
+
3827
+ }
3828
+
3829
+ // SHOPIFY-BILLED ORGS ARE CHARGED THROUGH SHOPIFY, keyed on the order
3830
+ // id so a redelivery cannot charge twice. Never on a backfill: the fee
3831
+ // was charged when the order was first recorded. Enqueues run after
3832
+ // the transaction commits, so the job can never observe rows that
3833
+ // roll back.
3834
+ const enqueues = ( org?.billingProvider === 'shopify' && fee > 0 && connection?.source?.id && ! backfill )
3835
+ ? [ {
3836
+ data : {
3837
+ idempotencyKey : String( orderId ),
3838
+ orderDocId,
3839
+ orderId : String( orderId ),
3840
+ rate,
3841
+ shopId : connection.source.id,
3842
+ // The App Events API returns no event id, so one is generated
3843
+ // here — the event handle plus the order id — and sent as the
3844
+ // event's `reference`. queue/usage.js stamps the same id onto
3845
+ // the order as billed.transaction.
3846
+ transaction : 'drawbridge-orders.' + orderId,
3847
+ value : Math.round( fee * 100 )
3848
+ },
3849
+ name : 'billing',
3850
+ options : { jobId : 'shopify.usage.' + orderId },
3851
+ queue : 'usage'
3852
+ } ]
3853
+ : [];
3854
+
3855
+ return {
3856
+ enqueues,
3857
+ message : backfill
3858
+ ? 'Redemption backfilled for an already-recorded order.'
3859
+ : isConversion ? 'Order recorded.' : 'Discount redemption recorded (fee-free).',
3860
+ request,
3861
+ response : {
3862
+ campaign : orderCampaign,
3863
+ currency : currencyCode,
3864
+ discount,
3865
+ fee,
3866
+ gross,
3867
+ lead : leadId,
3868
+ lines : lines.length,
3869
+ net,
3870
+ orderId : String( orderId )
3871
+ },
3872
+ // ONE TRANSACTION. The order, the redemption and both totals
3873
+ // counters land together or not at all — a half-written attribution
3874
+ // is revenue counted twice or not at all, and neither is
3875
+ // recoverable by hand.
3876
+ transaction : writes.length > 0,
3877
+ writes
3878
+ };
3879
+
3880
+ },
3881
+
3882
+ // A PRODUCT CHANGED AT THE STORE. Upserts the product row and hands it to
3883
+ // the product pipeline; the actual field sync happens there.
3884
+ //
3885
+ // The shell has already refused a missing or inactive Shopify connection,
3886
+ // so what is left is the two things only this hook can know are wrong.
3887
+ product : async ( { connection, context, workflow }, { mintId } = {} ) => {
3888
+
3889
+ const request = {
3890
+ numericId : context?.id || null,
3891
+ organizationId : workflow.organization,
3892
+ title : context?.title || null
3893
+ };
3894
+
3895
+ if( ! context?.id ) return { message : 'Skipped — product webhook payload had no id.', request, response : { skipped : true }, skipped : true };
3896
+
3897
+ // The shop domain is half the identity below. Without it the upsert
3898
+ // would match on provider id alone and could collide across stores.
3899
+ if( ! connection.shop ) return { message : 'Skipped — Shopify connection is missing shop domain.', request, response : { skipped : true }, skipped : true };
3900
+
3901
+ const providerId = 'gid://shopify/Product/' + context.id;
3902
+
3903
+ // Minted so the enqueue can name the row this upsert makes — and the
3904
+ // job carries the PROVIDER identity too, because under a concurrent
3905
+ // redelivery this id may be the one that lost the upsert race. The
3906
+ // worker falls back to provider + shop, which are stable either way.
3907
+ const productId = mintId();
3908
+
3909
+ return {
3910
+ enqueues : [ {
3911
+ data : { product : productId, providerId, shop : connection.shop },
3912
+ name : 'workflow',
3913
+ options : { jobId : 'product.workflow.shopify.' + providerId + '.' + Date.now() },
3914
+ queue : 'product.shopify'
3915
+ } ],
3916
+ message : 'Product sync queued from Shopify webhook.',
3917
+ request,
3918
+ response : { productId, providerId, title : context?.title || null },
3919
+ // KEYED ON PROVIDER + SHOP, so the same product in two stores stays
3920
+ // two rows. `connections` accumulates rather than replaces: one
3921
+ // store can be linked to several organizations, and each keeps its
3922
+ // own claim on the row.
3923
+ writes : [ {
3924
+ collection : 'product',
3925
+ data : {
3926
+ $addToSet : { connections : connection.id },
3927
+ $setOnInsert : {
3928
+ id : productId,
3929
+ provider : { id : providerId, slug : 'shopify' },
3930
+ 'source.id' : connection.id,
3931
+ status : 'active'
3932
+ }
3933
+ },
3934
+ operation : 'update',
3935
+ options : { upsert : true },
3936
+ query : {
3937
+ 'provider.id' : providerId,
3938
+ 'provider.slug' : 'shopify',
3939
+ 'source.domain' : connection.shop
3940
+ }
3941
+ } ]
3942
+ };
3943
+
3944
+ }
3945
+
2408
3946
  },
2409
3947
  contacts : { remove : false, sync : false },
2410
3948
 
@@ -2423,8 +3961,22 @@ var shopify = {
2423
3961
  sms : false,
2424
3962
  inbound : {
2425
3963
  event : ( args ) => readEventHeader({ ...args, descriptor : inbound }),
2426
- process : {},
2427
- receive : ({ channel, event, headers, payload }) => {
3964
+ // One hook over the whole topic table, because that is what this
3965
+ // manifest declares: Shopify processes its own buffered events. The
3966
+ // topic rides in on the context rather than being a second hook name per
3967
+ // topic; the caller's handler table arrives as a prop.
3968
+ process : async ( { context }, { dispatch } = {} ) => {
3969
+
3970
+ const key = 'shopify.' + context?.topic;
3971
+
3972
+ const handled = await dispatch({ data : context?.data, handler : key });
3973
+
3974
+ if( ! handled ) return { message : 'No handler for ' + key, skipped : true };
3975
+
3976
+ return { message : 'Processed ' + key, request : { topic : context?.topic } };
3977
+
3978
+ },
3979
+ receive : ( { channel, event, headers, payload } ) => {
2428
3980
 
2429
3981
  if( channel === 'compliance' && ! COMPLIANCE_TOPICS.has( event ) ){
2430
3982
 
@@ -2446,7 +3998,173 @@ var shopify = {
2446
3998
  },
2447
3999
  verify : ( args ) => verifySignature({ ...args, descriptor : inbound })
2448
4000
  },
2449
- lifecycle : { cleanup : {}, health : {}, register : {}, rehydrate : {} },
4001
+ lifecycle : {
4002
+
4003
+ // DISPATCHES INTO THE CALLER'S OWN COORDINATORS. These three are
4004
+ // declarations made true: the work is queue orchestration over
4005
+ // Drawbridge's own collections, which is coordinator work and stays in
4006
+ // the repo that owns the queues. The hook receives the dispatch table as
4007
+ // a prop and picks the entry, so the manifest owns the SEAM — asking
4008
+ // Shopify whether it handles its own lifecycle now gets a real function
4009
+ // instead of `unimplemented` while the work happened anyway.
4010
+ cleanup : async ( { context }, { dispatch } = {} ) => {
4011
+
4012
+ await dispatch({ data : context, handler : 'cleanup' });
4013
+
4014
+ return { message : 'Ran shopify lifecycle.cleanup', request : context || null };
4015
+
4016
+ },
4017
+
4018
+ // KEEP STORE ACCESS WORKING. Not a webhook monitor, despite the name the
4019
+ // step once carried — webhooks are declarative, declared in the app's
4020
+ // toml and applied by Shopify to every install, so nothing here registers
4021
+ // or checks them.
4022
+ //
4023
+ // It rotates the refresh token before its window closes, proves the
4024
+ // access token still works, reconciles the scopes the store granted
4025
+ // against the ones the app now needs, and queues a webhook
4026
+ // reconciliation.
4027
+ health : async ( { connection, workflow }, { adminToken, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {} ) => {
4028
+
4029
+ const request = {
4030
+ connectionId : workflow.connection,
4031
+ organizationId : workflow.organization,
4032
+ shop : connection.shop
4033
+ };
4034
+
4035
+ // Captured BEFORE anything runs. If the calls below fail with
4036
+ // invalid_grant, this is what distinguishes "the merchant revoked us"
4037
+ // from "a concurrent rotation spent the token we were holding" — and
4038
+ // only the first should error the connection.
4039
+ const refreshTokenAtStart = ( await resolveSettings() ).refreshToken || null;
4040
+
4041
+ try {
4042
+
4043
+ const adminAccessToken = await adminToken();
4044
+
4045
+ const settings = await resolveSettings();
4046
+
4047
+ const refreshTokenExpiresAt = settings.refreshTokenExpiresAt;
4048
+
4049
+ const needsRotation = refreshTokenExpiresAt
4050
+ && new Date( refreshTokenExpiresAt ) < new Date( Date.now() + REFRESH_TOKEN_FRESHNESS_BUFFER_MS );
4051
+
4052
+ let refreshTokenRotated = false;
4053
+
4054
+ if( needsRotation ){
4055
+
4056
+ await rotateToken();
4057
+
4058
+ refreshTokenRotated = true;
4059
+
4060
+ }
4061
+
4062
+ await shopify.oauth.ping({ adminAccessToken, domain : connection.shop });
4063
+
4064
+ // SCOPE DRIFT IS ITS OWN FAILURE. A token can be perfectly valid
4065
+ // while the grant is too narrow, which no ping would ever reveal.
4066
+ // Injected rather than described: reconciliation is the caller's
4067
+ // writer-of-record routine, shared with the webhook drain, and this
4068
+ // hook needs its RESULT to compose the message below.
4069
+ const scopesMissing = await reconcileScopes({ shop : connection.shop });
4070
+
4071
+ return {
4072
+ enqueues : [ {
4073
+ data : {
4074
+ data : {
4075
+ connectionId : workflow.connection,
4076
+ organizationId : workflow.organization
4077
+ },
4078
+ event : 'shopify.register.webhooks'
4079
+ },
4080
+ name : 'register',
4081
+ options : { jobId : 'connection.update.register.' + workflow.connection + '.' + randomUUID() },
4082
+ queue : 'connection'
4083
+ } ],
4084
+ message : scopesMissing?.length
4085
+ ? 'Health check: ping ok, webhooks reconciled — connection errored, granted scopes are missing: ' + scopesMissing.join( ', ' ) + '.'
4086
+ : refreshTokenRotated
4087
+ ? 'Health check passed — refresh token rotated, ping ok, webhooks reconciled.'
4088
+ : 'Health check passed — ping ok, webhooks reconciled.',
4089
+ request,
4090
+ response : {
4091
+ pingedAt : new Date(),
4092
+ refreshTokenExpiresAt : refreshTokenExpiresAt || null,
4093
+ refreshTokenRotated,
4094
+ scopesMissing,
4095
+ webhookReconciliationQueued : true
4096
+ }
4097
+ };
4098
+
4099
+ } catch ( error ){
4100
+
4101
+ // A REVOKED GRANT IS THE MERCHANT'S TO FIX, so the connection says
4102
+ // so rather than failing silently on a schedule nobody watches. The
4103
+ // write rides OUT ON THE REJECTION — the contract performs a thrown
4104
+ // error's effects — because the step must still fail.
4105
+ if( OAUTH_GRANT_REVOKED_CODES.includes( error.code ) ){
4106
+
4107
+ const current = await read.get({ collection : 'connection', query : { id : connection.id } });
4108
+
4109
+ // Against the FRESH doc, not the one this run started with — a legacy
4110
+ // connection keeps its tokens in its own settings blob, and a
4111
+ // concurrent rotation rewrote that blob after our snapshot.
4112
+ const refreshTokenStored = current ? ( await resolveSettings( current ) ).refreshToken || null : null;
4113
+
4114
+ // A CONCURRENT ROTATION, not a revocation: another job spent the
4115
+ // refresh token between our read and our use of it. Erroring the
4116
+ // connection here would disconnect a store that is working fine.
4117
+ const rotated = error.code === 'invalid_grant' && refreshTokenStored !== refreshTokenAtStart;
4118
+
4119
+ if( current && ! rotated ){
4120
+
4121
+ const others = ( current.errors || [] ).filter( ( entry ) => entry.source !== OAUTH_ERROR_SOURCE );
4122
+
4123
+ error.writes = [ {
4124
+ collection : 'connection',
4125
+ data : {
4126
+ $set : {
4127
+ errors : [
4128
+ ...others,
4129
+ {
4130
+ message : 'Shopify disconnected this store. Open the Drawbridge app in your Shopify admin to reconnect.',
4131
+ source : OAUTH_ERROR_SOURCE
4132
+ }
4133
+ ],
4134
+ status : 'error'
4135
+ }
4136
+ },
4137
+ operation : 'update',
4138
+ query : { id : connection.id }
4139
+ } ];
4140
+
4141
+ }
4142
+
4143
+ }
4144
+
4145
+ throw error;
4146
+
4147
+ }
4148
+
4149
+ },
4150
+
4151
+ register : async ( { context }, { dispatch } = {} ) => {
4152
+
4153
+ await dispatch({ data : context, handler : 'register' });
4154
+
4155
+ return { message : 'Ran shopify lifecycle.register', request : context || null };
4156
+
4157
+ },
4158
+
4159
+ rehydrate : async ( { context }, { dispatch } = {} ) => {
4160
+
4161
+ await dispatch({ data : context, handler : 'rehydrate' });
4162
+
4163
+ return { message : 'Ran shopify lifecycle.rehydrate', request : context || null };
4164
+
4165
+ }
4166
+
4167
+ },
2450
4168
  resources : {
2451
4169
  audiences : false,
2452
4170
  // Shopify has no separate price resource — a price belongs to a product
@@ -2467,7 +4185,7 @@ var shopify = {
2467
4185
  // credential is the caller's job because it is Drawbridge's job: the
2468
4186
  // admin token refreshes and writes itself back, which is service work,
2469
4187
  // not vendor work.
2470
- products : async ({ cursor, limit = 100, search, settings, shopify, sort }) => {
4188
+ products : async ( { cursor, limit = 100, search, settings, sort }, { shopify } = {} ) => {
2471
4189
 
2472
4190
  const products = await shopify.storefront.getProducts({
2473
4191
  cursor,
@@ -2490,7 +4208,7 @@ var shopify = {
2490
4208
 
2491
4209
  },
2492
4210
 
2493
- promotions : async ({ cursor, limit = 100, search, settings, shopify }) => {
4211
+ promotions : async ( { cursor, limit = 100, search, settings }, { shopify } = {} ) => {
2494
4212
 
2495
4213
  const discounts = await shopify.admin.getDiscounts({
2496
4214
  adminAccessToken : settings?.adminAccessToken,
@@ -2574,9 +4292,6 @@ var shopify = {
2574
4292
  // workflow document, and those strings cannot be renamed without a backfill.
2575
4293
  //
2576
4294
  // EVERY LEAF IS A FUNCTION so a step can read the merchant's own connection.
2577
- // The bodies these point at still live in drawbridge-sync; moving them is the
2578
- // next unit, and commerce.order.record is the one that decides whether the
2579
- // shape holds — 569 lines and 15 controller calls.
2580
4295
  steps : {
2581
4296
 
2582
4297
  commerce : {
@@ -2785,7 +4500,7 @@ var webhook = {
2785
4500
  // needs Drawbridge's own database, sockets or queues. This one does not.
2786
4501
  webhook : {
2787
4502
 
2788
- send : async ({ context, controller, request : send = safeRequest, settings, step }) => {
4503
+ send : async ( { context, lead, settings, step }, { request : send = safeRequest } = {} ) => {
2789
4504
 
2790
4505
  const { headers = {}, method = 'POST', url } = step.settings || {};
2791
4506
 
@@ -2794,11 +4509,9 @@ var webhook = {
2794
4509
  if( ! url ) return { message : 'Outgoing webhook URL is not configured for this step.', request, response : { skipped : true }, skipped : true };
2795
4510
 
2796
4511
  // The LEAD, when there is one, rather than the accumulated context. A
2797
- // receiver wants the entrant's record, not our internal step state.
2798
- const lead = context?.lead
2799
- ? await controller.get({ collection : 'lead', query : { id : context.lead } })
2800
- : null;
2801
-
4512
+ // receiver wants the entrant's record, not our internal step state
4513
+ // and the shell already resolved it, because every hook that names a
4514
+ // lead gets the document and its consent together.
2802
4515
  const body = lead || context;
2803
4516
 
2804
4517
  request.body = body;
@@ -2864,7 +4577,7 @@ var webhook = {
2864
4577
  // pressing Connect will do; afterwards it states the verification the
2865
4578
  // merchant's own endpoint has to perform, because a signed payload nobody
2866
4579
  // checks is an unsigned payload.
2867
- tasks : ({ settings }) => ( settings?.secret
4580
+ tasks : ( { settings } ) => ( settings?.secret
2868
4581
  ? [
2869
4582
  {
2870
4583
  message : 'Compute HMAC-SHA256( secret, body ) and compare against the X-Drawbridge-Signature header to confirm each payload.',
@@ -3361,10 +5074,13 @@ const connections = Object.freeze({
3361
5074
 
3362
5075
  })();
3363
5076
 
3364
- // The vendors whose environment is actually configured. Every consumer asks this
3365
- // the same way, so "is Shopify available" has one answer rather than one per
3366
- // repo the api gated it on four env vars and sync inferred it from a different
3367
- // signal, which is how the two drifted.
5077
+ // The vendors whose credentials are actually present in the map handed in —
5078
+ // which the callers build from the provider collection, with the deployment's
5079
+ // environment answering only for names no provider field owns
5080
+ // (ENCRYPT_CONNECTION_SECRET). Every consumer asks this the same way, so "is
5081
+ // Shopify available" has one answer rather than one per repo — the api once
5082
+ // gated it on four env vars and sync inferred it from a different signal, which
5083
+ // is how the two drifted.
3368
5084
  const availableConnections = ( env = {} ) => Object.fromEntries(
3369
5085
  Object.entries( connections ).filter(
3370
5086
  ( [ , manifest ] ) => manifest.requires.every( ( name ) => Boolean( env[ name ] ) )
@@ -3460,7 +5176,9 @@ const connectFields = ( slug ) => ( connections[ slug ]?.fields || [] )
3460
5176
  // The four outcomes are the point. "unsupported", "unimplemented", "failed" and
3461
5177
  // a result are different answers to why there is no data, and collapsing them is
3462
5178
  // how "this vendor cannot do that" becomes indistinguishable from "it broke".
3463
- const runHook = async ( slug, name, args = {} ) => {
5179
+ // TWO OBJECTS: props are facts of the run, options are services being passed
5180
+ // in. A caller with no services to inject omits the second bag.
5181
+ const runHook = async ( slug, name, props = {}, options = {} ) => {
3464
5182
 
3465
5183
  const manifest = connections[ slug ];
3466
5184
 
@@ -3468,7 +5186,17 @@ const runHook = async ( slug, name, args = {} ) => {
3468
5186
 
3469
5187
  // The hook's own value is the answer — there is no `supports` map to consult,
3470
5188
  // and therefore none to disagree with what is actually here.
3471
- const hook = name.split( '.' ).reduce( ( node, key ) => node?.[ key ], manifest.hooks );
5189
+ //
5190
+ // OWN PROPERTIES ONLY. A bare property walk resolves 'resources.constructor'
5191
+ // to Object.prototype.constructor — a truthy FUNCTION, so every guard on
5192
+ // "does the vendor implement this" passes and runHook calls Object() with
5193
+ // the props, answering the caller's own payload back as a result. With the
5194
+ // api's hook route caching answers in shared Redis, that payload holds a
5195
+ // live admin token. Same fix providerFields already carries.
5196
+ const hook = name.split( '.' ).reduce(
5197
+ ( node, key ) => ( node && typeof node === 'object' && Object.hasOwn( node, key ) ) ? node[ key ] : undefined,
5198
+ manifest.hooks
5199
+ );
3472
5200
 
3473
5201
  if( hook === false || hook == null ){
3474
5202
 
@@ -3492,7 +5220,16 @@ const runHook = async ( slug, name, args = {} ) => {
3492
5220
  // disconnect until auth.oauth.urls gathered every vendor address in one
3493
5221
  // place, and a hook that cannot see its own manifest would have forced it
3494
5222
  // back. Callers never have to know to pass it.
3495
- return { outcome : OUTCOMES.answered, result : await hook({ ...args, manifest }) };
5223
+ const result = await hook({ ...props, manifest }, options );
5224
+
5225
+ // THE ANSWER IS CHECKED WHERE IT IS PRODUCED. A hook may describe writes,
5226
+ // enqueues and events for its caller to perform, and a malformed descriptor
5227
+ // is a write against the wrong collection — so it is refused here, at the one
5228
+ // call surface, rather than surviving as far as whoever performs it. A hook
5229
+ // that describes nothing (every resources.* read) validates to nothing.
5230
+ effectsOf( result );
5231
+
5232
+ return { outcome : OUTCOMES.answered, result };
3496
5233
 
3497
5234
  } catch ( error ) {
3498
5235
 
@@ -3681,4 +5418,4 @@ const resolveConnection = ( item, data, env = {} ) => {
3681
5418
 
3682
5419
  };
3683
5420
 
3684
- export { AUTH_TYPES, GROUPS, HOOKS, HOOK_NAMES, INPUTS, OAUTH_FIELDS, OUTCOMES, RETIRED, STATUSES, STEPS, STEP_TYPES, accessToken, authToken, availableConnections, build, catalogConnections, connectFields, connectionSteps, connections, hookSupport, isStale, mergeSettings, projectConnection, publicConnectionKeys, publicSettingsBySlug, redactSettings, resolveConnection, runHook, scopesMessage, stepLabels, stepQueues, stepRoutes, tokenSettings };
5421
+ export { AUTH_TYPES, GROUPS, HOOKS, HOOK_EFFECTS, HOOK_NAMES, HOOK_OPTIONS, HOOK_PROPS, INPUTS, OAUTH_FIELDS, OUTCOMES, RETIRED, STATUSES, STEPS, STEP_TYPES, WRITE_OPERATIONS, accessToken, authToken, availableConnections, build, catalogConnections, connectFields, connectionSteps, connections, effectsOf, hookSupport, isStale, mergeSettings, projectConnection, publicConnectionKeys, publicSettingsBySlug, redactSettings, resolveConnection, runHook, scopesMessage, stepLabels, stepQueues, stepRoutes, tokenSettings };