@drawbridge/drawbridge-utils 0.0.118 → 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.
@@ -29,13 +29,13 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
29
29
  // lib/providers.js
30
30
  var providers_exports = {};
31
31
  __export(providers_exports, {
32
- cacheKey: () => cacheKey,
33
- hydrateEnvironment: () => hydrateEnvironment,
32
+ clearProviderMemo: () => clearProviderMemo,
34
33
  isLive: () => isLive,
35
34
  mask: () => mask,
36
35
  providerCredentials: () => providerCredentials,
37
36
  providerEnvNames: () => providerEnvNames,
38
37
  providerFields: () => providerFields,
38
+ providerMemo: () => providerMemo,
39
39
  providerSettings: () => providerSettings,
40
40
  providerSlugs: () => providerSlugs,
41
41
  saveProviderSettings: () => saveProviderSettings
@@ -220,6 +220,53 @@ var HOOKS = Object.freeze({
220
220
  "promotions"
221
221
  ])
222
222
  });
223
+ var HOOK_EFFECTS = Object.freeze(["enqueues", "events", "writes"]);
224
+ var WRITE_OPERATIONS = Object.freeze(["create", "update"]);
225
+ var HOOK_PROPS = Object.freeze([
226
+ "channel",
227
+ "clientId",
228
+ "clientSecret",
229
+ "connection",
230
+ "contact",
231
+ "context",
232
+ "cursor",
233
+ "declaration",
234
+ "doc",
235
+ "email",
236
+ "event",
237
+ "headers",
238
+ "id",
239
+ "lead",
240
+ "limit",
241
+ "manifest",
242
+ "payload",
243
+ "scope",
244
+ "search",
245
+ "secret",
246
+ "settings",
247
+ "sort",
248
+ "step",
249
+ "suppressed",
250
+ "token",
251
+ "tokens",
252
+ "workflow"
253
+ ]);
254
+ var HOOK_OPTIONS = Object.freeze([
255
+ "adminToken",
256
+ "canSend",
257
+ "chunkSize",
258
+ "dispatch",
259
+ "fetcher",
260
+ "logger",
261
+ "mintId",
262
+ "read",
263
+ "reconcileScopes",
264
+ "request",
265
+ "resolveContact",
266
+ "resolveSettings",
267
+ "rotateToken",
268
+ "shopify"
269
+ ]);
223
270
  var STEPS = Object.freeze({
224
271
  "commerce.code.issue": "Issue discount code",
225
272
  "commerce.customer.insert": "Create customer",
@@ -401,13 +448,16 @@ var attentive_default = `<svg width="500" height="500" viewBox="0 0 500 500" fil
401
448
  <path d="M166.04 261.805C180.228 259.107 195.528 261.893 207.581 269.971C218.512 277.079 226.908 288.136 230.604 300.657C234.835 314.103 233.614 329.124 227.485 341.788C220.097 356.875 205.782 368.543 189.317 372.089C173.957 375.652 157.089 372.386 144.304 363.103C132.971 355.295 124.912 342.989 121.891 329.581C118.943 316.636 120.725 302.656 127.002 290.938C134.699 275.951 149.503 264.933 166.046 261.811" fill="#1E1C1C"/>
402
449
  </svg>`;
403
450
 
404
- // lib/connections/attentive.js
451
+ // lib/connections/providers/attentive.js
405
452
  var attentive_default2 = {
406
453
  auth: {
407
454
  oauth: {
408
- // NAMES of the env vars holding OUR app's client — set at registration,
409
- // never before. No `headers` on the client: Attentive takes credentials
410
- // as form fields, which is the runner's default.
455
+ // NAMES of the credentials holding OUR app's client — keys into the map
456
+ // the provider collection answers, entered on the admin screen at
457
+ // registration, never before. (The names are the env vars they once
458
+ // were; the vocabulary stayed when the storage moved.) No `headers` on
459
+ // the client: Attentive takes credentials as form fields, which is the
460
+ // runner's default.
411
461
  client: {
412
462
  id: "ATTENTIVE_OAUTH_CLIENT_ID",
413
463
  secret: "ATTENTIVE_OAUTH_CLIENT_SECRET"
@@ -512,7 +562,7 @@ var attentive_default2 = {
512
562
  // show a picker quietly missing most of a real account. The response's
513
563
  // only identifier is `externalId`, so an entry without one cannot be
514
564
  // stored and is dropped.
515
- audiences: async ({ cursor, fetcher = fetch, limit = 100, search, token }) => {
565
+ audiences: async ({ cursor, limit = 100, search, token }, { fetcher = fetch } = {}) => {
516
566
  const query = new URLSearchParams({
517
567
  limit: String(Math.min(limit, 1e3)),
518
568
  ...cursor && { cursor },
@@ -628,10 +678,11 @@ var request = async ({
628
678
  // lib/hubspot.js
629
679
  var HUBSPOT_BASE = "https://api.hubapi.com";
630
680
  var hubspotRequest = ({ body, fetcher, method, path, query, token }) => {
681
+ if (!token) throw new Error("HubSpot access token missing \u2014 pass token (the drawbridge provider's hubspotToken)");
631
682
  return (fetcher || request)({
632
683
  body,
633
684
  headers: {
634
- "Authorization": "Bearer " + (token || process.env.HUBSPOT_ACCESS_TOKEN)
685
+ "Authorization": "Bearer " + token
635
686
  },
636
687
  method,
637
688
  query,
@@ -733,16 +784,15 @@ var contacts = {
733
784
  // FORGET A CONTACT, by id or by email. Account deletion — the caller had
734
785
  // to search then remove, which is one round trip it should not have to
735
786
  // know about.
736
- remove: async ({ email, fetcher, id, token }) => {
737
- const key = token || process.env.HUBSPOT_ACCESS_TOKEN;
738
- if (!key) return;
739
- const contact = id || await lookup({ email, fetcher, token: key });
787
+ remove: async ({ email, id, token }, { fetcher } = {}) => {
788
+ if (!token) return;
789
+ const contact = id || await lookup({ email, fetcher, token });
740
790
  if (!contact) return;
741
791
  return hubspotRequest({
742
792
  fetcher,
743
793
  method: "DELETE",
744
794
  path: "/crm/v3/objects/contacts/" + contact,
745
- token: key
795
+ token
746
796
  });
747
797
  },
748
798
  // Connect an account to its contact by email, creating it if absent, and
@@ -751,24 +801,23 @@ var contacts = {
751
801
  // no delete-old-then-create-new.
752
802
  //
753
803
  // Prefer the cached hubspotId; fall back to a search; create last.
754
- sync: async ({ doc, fetcher, token }) => {
804
+ sync: async ({ doc, token }, { fetcher } = {}) => {
755
805
  var _a, _b;
756
- const key = token || process.env.HUBSPOT_ACCESS_TOKEN;
757
- if (!key) return;
806
+ if (!token) return;
758
807
  if (doc == null ? void 0 : doc.hubspotId) {
759
808
  try {
760
- return (_a = await send({ doc, fetcher, method: "PATCH", path: "/crm/v3/objects/contacts/" + doc.hubspotId, token: key })) == null ? void 0 : _a.id;
809
+ return (_a = await send({ doc, fetcher, method: "PATCH", path: "/crm/v3/objects/contacts/" + doc.hubspotId, token })) == null ? void 0 : _a.id;
761
810
  } catch (error) {
762
811
  if ((error == null ? void 0 : error.status) !== 404) throw error;
763
812
  }
764
813
  }
765
- const existing = await lookup({ email: doc == null ? void 0 : doc.email, fetcher, token: key });
814
+ const existing = await lookup({ email: doc == null ? void 0 : doc.email, fetcher, token });
766
815
  return (_b = await send({
767
816
  doc,
768
817
  fetcher,
769
818
  method: existing ? "PATCH" : "POST",
770
819
  path: existing ? "/crm/v3/objects/contacts/" + existing : "/crm/v3/objects/contacts",
771
- token: key
820
+ token
772
821
  })) == null ? void 0 : _b.id;
773
822
  }
774
823
  };
@@ -1224,6 +1273,36 @@ var plans = {
1224
1273
  conversion: 0.5
1225
1274
  }
1226
1275
  };
1276
+ var resolvePlan = (subscription) => {
1277
+ var _a, _b;
1278
+ const custom = subscription == null ? void 0 : subscription.custom;
1279
+ if (!custom) return plans[subscription == null ? void 0 : subscription.plan] ?? free;
1280
+ return {
1281
+ // Reusing all.features / all.limits is what keeps a custom plan the same
1282
+ // SHAPE as a catalog one: the base feature grants every plan carries, the
1283
+ // campaign limits that are always infinite, and members defaulting to
1284
+ // infinite when a deal does not name it.
1285
+ conversion: custom.conversion ?? free.conversion,
1286
+ custom: true,
1287
+ // A custom plan is a negotiated PAID deal, so it carries the paid-tier
1288
+ // baseline whether or not the deal thought to name it. Today that is the
1289
+ // sending domain: every catalog paid tier grants it, and a custom plan
1290
+ // silently lacking it would be a support ticket, not a pricing decision.
1291
+ features: all.features([organization.networking.key, ...((_a = custom.features) == null ? void 0 : _a.granted) || []]),
1292
+ limits: all.limits(((_b = custom.limits) == null ? void 0 : _b.organization) || {}),
1293
+ // A custom plan stores its overage BARE on `custom.overages` — a different
1294
+ // shape from the catalog's nested one. Number() so a deal stored as a string
1295
+ // still resolves to cents-per-action; an unnamed overage stays undefined
1296
+ // (it bills nothing) rather than becoming NaN.
1297
+ actionCents: custom.overages == null ? void 0 : Number(custom.overages),
1298
+ overages: { actions: custom.overages },
1299
+ title: custom.title || "Custom"
1300
+ };
1301
+ };
1302
+ var conversionRate = (subscription) => {
1303
+ var _a;
1304
+ return ((_a = resolvePlan(subscription)) == null ? void 0 : _a.conversion) ?? free.conversion;
1305
+ };
1227
1306
 
1228
1307
  // lib/transactions.js
1229
1308
  var import_drawbridge_telemetry = require("@drawbridge/drawbridge-telemetry");
@@ -1378,7 +1457,36 @@ var channels = {
1378
1457
  }
1379
1458
  };
1380
1459
 
1381
- // lib/connections/drawbridge.js
1460
+ // lib/connections/providers/drawbridge.js
1461
+ var interpolate = (template, data2) => {
1462
+ if (!template) return template;
1463
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => (data2 == null ? void 0 : data2[key]) != null ? String(data2[key]) : "{{" + key + "}}");
1464
+ };
1465
+ var teamRecipients = async ({ memberIds = [], organization: organization2, read }) => {
1466
+ const org = await read.get({ collection: "organization", query: { id: organization2 } });
1467
+ const owner = (org == null ? void 0 : org.owner) ? await read.get({ collection: "user", query: { id: org.owner } }) : null;
1468
+ const members = memberIds.length ? await read.aggregate({
1469
+ collection: "member",
1470
+ pipeline: [
1471
+ {
1472
+ $match: {
1473
+ id: { $in: memberIds },
1474
+ organization: organization2,
1475
+ status: "accepted"
1476
+ }
1477
+ }
1478
+ ]
1479
+ }) : [];
1480
+ const seen = /* @__PURE__ */ new Set();
1481
+ return [owner, ...members].filter((member) => {
1482
+ if (!(member == null ? void 0 : member.id) || !(member == null ? void 0 : member.email)) return false;
1483
+ const address = member.email.toLowerCase();
1484
+ if (seen.has(address)) return false;
1485
+ seen.add(address);
1486
+ return true;
1487
+ });
1488
+ };
1489
+ var queueNotification = (data2) => ({ collection: "notification", data: data2, operation: "create" });
1382
1490
  var drawbridge_default2 = {
1383
1491
  auth: {
1384
1492
  type: "none"
@@ -1396,10 +1504,15 @@ var drawbridge_default2 = {
1396
1504
  exclusive: false,
1397
1505
  fields: [],
1398
1506
  group: "developer",
1399
- // EVERY BODY LIVES IN drawbridge-sync. Sending needs the provider clients, the
1400
- // suppression collection and the queues; segment sync needs the streams. A
1401
- // published package carrying those makes every consumer carry them, which is
1402
- // the reason `{}` exists as an answer.
1507
+ // THE BODIES LIVE HERE, beside the declarations that name them. They used to
1508
+ // live in drawbridge-sync because they touch the database, the queues and the
1509
+ // sockets and a published package cannot carry a controller.
1510
+ //
1511
+ // It does not have to. A hook is a function, so everything it needs is PASSED
1512
+ // IN: `read` for the reads, `canSend` for the opt-out floor, `resolveContact`
1513
+ // for the one write whose RESULT the hook has to count. Everything else a hook
1514
+ // wants done it DESCRIBES — `writes`, `enqueues`, `events` — and the shell
1515
+ // performs it. See lib/connections/contract.js for that shape.
1403
1516
  hooks: {
1404
1517
  auth: {
1405
1518
  // Nothing to connect, revoke, probe or re-scope.
@@ -1420,13 +1533,157 @@ var drawbridge_default2 = {
1420
1533
  // accounts DRAWBRIDGE holds rather than ones a merchant connects.
1421
1534
  contacts,
1422
1535
  email: {
1423
- digest: {},
1424
- // To organization members. NEVER suppressed and never billed: an
1425
- // entrant's opt-out must not silence an alert to staff, and staff mail is
1426
- // not a metered send.
1427
- notify: {},
1428
- // To a lead. Suppression applies and the send is billed.
1429
- send: {}
1536
+ // A PERIODIC SUMMARY to the team, on a schedule trigger rather than per
1537
+ // lead.
1538
+ //
1539
+ // The count is the point: `email.notify` tells the owner one lead arrived
1540
+ // and dampens a spike to one message per bucket, which is deliberately not
1541
+ // a count. This is where "you got 43 entries this week" comes from.
1542
+ digest: async ({ context, step, workflow }, { read } = {}) => {
1543
+ var _a, _b, _c, _d;
1544
+ const days = { day: 1, month: 30, week: 7 }[(_a = workflow == null ? void 0 : workflow.trigger) == null ? void 0 : _a.event] || 7;
1545
+ const since = new Date(Date.now() - days * 24 * 60 * 60 * 1e3);
1546
+ const campaign = ((_c = (_b = workflow == null ? void 0 : workflow.trigger) == null ? void 0 : _b.filters) == null ? void 0 : _c.campaign) || null;
1547
+ const [counted] = await read.aggregate({
1548
+ collection: "lead",
1549
+ pipeline: [
1550
+ {
1551
+ $match: {
1552
+ createdAt: { $gte: since },
1553
+ organization: workflow.organization,
1554
+ ...campaign && { campaigns: { $in: [campaign] } }
1555
+ }
1556
+ },
1557
+ { $count: "count" }
1558
+ ]
1559
+ });
1560
+ const count = Number((counted == null ? void 0 : counted.count) || 0);
1561
+ const request2 = { campaign, count, days };
1562
+ if (!count) return { message: "No new leads in the period \u2014 digest skipped.", request: request2, response: { skipped: true }, skipped: true };
1563
+ const recipients = await teamRecipients({
1564
+ memberIds: ((_d = step.settings) == null ? void 0 : _d.members) || [],
1565
+ organization: workflow.organization,
1566
+ read
1567
+ });
1568
+ const values = { ...context, count };
1569
+ return {
1570
+ message: "Digest of " + count + " new lead(s) queued for " + recipients.length + " recipient(s).",
1571
+ request: request2,
1572
+ response: { count, notified: recipients.length },
1573
+ writes: recipients.map((member) => {
1574
+ var _a2, _b2;
1575
+ return queueNotification({
1576
+ audience: "member",
1577
+ message: interpolate((_a2 = step.settings) == null ? void 0 : _a2.message, values),
1578
+ organization: workflow.organization,
1579
+ send: { type: "email", email: member.email },
1580
+ title: interpolate((_b2 = step.settings) == null ? void 0 : _b2.subject, values),
1581
+ workflow: workflow.id
1582
+ });
1583
+ })
1584
+ };
1585
+ },
1586
+ // To the organization's OWN PEOPLE. Never suppressed, never
1587
+ // subscription-gated, no unsubscribe footer — telling an org's staff about
1588
+ // their own leads is not commercial mail to a stranger.
1589
+ //
1590
+ // FREE, permanently. The lead that triggered this run already consumed the
1591
+ // billable action, and `members` is a list — billing here would turn one
1592
+ // lead into five more charges and the org would be paying to read its own
1593
+ // mail. The declaration prices it at zero; the shell bills nothing for
1594
+ // zero.
1595
+ notify: async ({ context, step, workflow }, { read } = {}) => {
1596
+ var _a;
1597
+ const memberIds = ((_a = step.settings) == null ? void 0 : _a.members) || [];
1598
+ const request2 = { members: memberIds.length };
1599
+ const recipients = await teamRecipients({ memberIds, organization: workflow.organization, read });
1600
+ if (!recipients.length) {
1601
+ return {
1602
+ message: "No owner or accepted member with an email address \u2014 team notification skipped.",
1603
+ request: request2,
1604
+ response: { skipped: true },
1605
+ skipped: true
1606
+ };
1607
+ }
1608
+ const bucket = Math.floor(Date.now() / (15 * 60 * 1e3));
1609
+ return {
1610
+ message: "Team notification queued for " + recipients.length + " recipient(s).",
1611
+ request: request2,
1612
+ response: { notified: recipients.length },
1613
+ writes: recipients.map((member) => {
1614
+ var _a2, _b;
1615
+ return {
1616
+ ...queueNotification({
1617
+ audience: "member",
1618
+ // Per workflow, recipient AND bucket, so one recipient's damper
1619
+ // can never swallow another's mail and a later bucket is never
1620
+ // mistaken for a duplicate of an earlier one.
1621
+ key: "team.notify." + workflow.id + "." + member.id + "." + bucket,
1622
+ message: interpolate((_a2 = step.settings) == null ? void 0 : _a2.message, context),
1623
+ organization: workflow.organization,
1624
+ send: { type: "email", email: member.email },
1625
+ title: interpolate((_b = step.settings) == null ? void 0 : _b.subject, context),
1626
+ workflow: workflow.id
1627
+ }),
1628
+ // E11000 IS THE DAMPER WORKING: this recipient has already been
1629
+ // told within the bucket. Declared per write rather than assumed by
1630
+ // the shell, because on every other write here a duplicate key is a
1631
+ // real failure.
1632
+ ignoreDuplicate: true
1633
+ };
1634
+ })
1635
+ };
1636
+ },
1637
+ // Drawbridge sends lead-facing email itself — no merchant provider gates
1638
+ // it.
1639
+ //
1640
+ // This QUEUES rather than sends: queue/notification.js owns delivery, the
1641
+ // unsubscribe token and the CAN-SPAM footer. The step's job is to say who
1642
+ // and what, correctly, and to refuse early when it must not send at all.
1643
+ send: async ({ context, step, workflow }, { canSend, read } = {}) => {
1644
+ var _a, _b;
1645
+ const to = context == null ? void 0 : context.email;
1646
+ if (!to) throw new Error("No email address on context (context.email is required)");
1647
+ const request2 = { to };
1648
+ const { ok: sendable } = await canSend({ channel: "email", to });
1649
+ if (!sendable) return { message: "Recipient has opted out \u2014 skipped.", request: request2, response: { skipped: true }, skipped: true };
1650
+ const organization2 = await read.get({ collection: "organization", query: { id: workflow.organization } });
1651
+ const subscription = (organization2 == null ? void 0 : organization2.subscription) ? await read.get({ collection: "subscription", query: { id: organization2.subscription } }) : null;
1652
+ if ((subscription == null ? void 0 : subscription.status) !== "active") {
1653
+ return {
1654
+ message: "Organization has no active subscription \u2014 workflow-step email skipped.",
1655
+ request: request2,
1656
+ response: { skipped: true },
1657
+ skipped: true
1658
+ };
1659
+ }
1660
+ return {
1661
+ message: "Email queued for delivery to " + to + ".",
1662
+ request: request2,
1663
+ response: { queued: true },
1664
+ // NO `connection` FIELD, deliberately: the platform sends this.
1665
+ // `audience : 'lead'` states what the queue would otherwise infer from
1666
+ // shape.
1667
+ //
1668
+ // `campaign` is not decoration. queue/notification.js mints the
1669
+ // unsubscribe token with it, so it decides whether opting out is
1670
+ // scoped to this campaign or the whole organization, and it names the
1671
+ // campaign in the footer. Sending without it silently broadens every
1672
+ // opt-out to the entire organization.
1673
+ writes: [
1674
+ queueNotification({
1675
+ audience: "lead",
1676
+ campaign: (context == null ? void 0 : context.campaign) || null,
1677
+ lead: (context == null ? void 0 : context.lead) || null,
1678
+ message: interpolate((_a = step.settings) == null ? void 0 : _a.message, context),
1679
+ organization: workflow.organization,
1680
+ send: { type: "email", email: to },
1681
+ title: interpolate((_b = step.settings) == null ? void 0 : _b.subject, context),
1682
+ workflow: workflow.id
1683
+ })
1684
+ ]
1685
+ };
1686
+ }
1430
1687
  },
1431
1688
  inbound: false,
1432
1689
  lifecycle: false,
@@ -1436,8 +1693,174 @@ var drawbridge_default2 = {
1436
1693
  products: false,
1437
1694
  promotions: false
1438
1695
  },
1439
- segment: { sync: {} },
1440
- sms: { send: {} },
1696
+ segment: {
1697
+ // RECALCULATE SEGMENT MEMBERSHIP. The one FAN-OUT hook: it evaluates every
1698
+ // contact in an organization against every segment, which is too much for
1699
+ // one job, so it returns chunks and the shell defers completion.
1700
+ //
1701
+ // Returning `chunks` is the only thing that makes it different. The
1702
+ // declaration, the guards, the step document and the price are the shell's,
1703
+ // exactly as they are for a step that finishes in one go.
1704
+ sync: async ({ context, step }, { chunkSize, logger: logger2, read, resolveContact } = {}) => {
1705
+ var _a, _b, _c;
1706
+ if (!chunkSize) throw new Error("segment.sync needs chunkSize from the shell");
1707
+ const organization2 = context == null ? void 0 : context.organization;
1708
+ const configured = (_a = step == null ? void 0 : step.settings) == null ? void 0 : _a.segment;
1709
+ const request2 = { organization: organization2 || null, segmentId: configured || null };
1710
+ const release = (ids, status = "active") => {
1711
+ const released = (ids || []).filter(Boolean);
1712
+ return {
1713
+ events: organization2 ? released.map((id) => ({
1714
+ event: "organization.segments",
1715
+ payload: { id, status },
1716
+ room: "organization." + organization2
1717
+ })) : [],
1718
+ writes: released.map((id) => ({
1719
+ collection: "segment",
1720
+ data: { $set: { status } },
1721
+ operation: "update",
1722
+ query: { id }
1723
+ }))
1724
+ };
1725
+ };
1726
+ if (!organization2) {
1727
+ return {
1728
+ ...release([configured]),
1729
+ message: "Trigger data missing organization id \u2014 cannot sync segments.",
1730
+ request: request2,
1731
+ response: { skipped: true },
1732
+ skipped: true
1733
+ };
1734
+ }
1735
+ const segments = await read.aggregate({
1736
+ collection: "segment",
1737
+ pipeline: [{ $match: configured ? { id: configured, organization: organization2 } : { organization: organization2 } }]
1738
+ });
1739
+ if (!segments.length) {
1740
+ return {
1741
+ ...release([configured]),
1742
+ message: "No segments matched the request \u2014 nothing to sync.",
1743
+ request: request2,
1744
+ response: { skipped: true },
1745
+ skipped: true
1746
+ };
1747
+ }
1748
+ const segmentIds = segments.map((entry) => entry.id);
1749
+ try {
1750
+ let backfilled = 0;
1751
+ if (segments.some((entry) => entry.system)) {
1752
+ const contacted = await read.aggregate({
1753
+ collection: "contact",
1754
+ pipeline: [
1755
+ { $match: { organization: organization2 } },
1756
+ { $project: { _id: 0, leads: 1 } },
1757
+ { $unwind: "$leads" },
1758
+ { $group: { _id: null, ids: { $addToSet: "$leads" } } }
1759
+ ]
1760
+ });
1761
+ const uncontacted = await read.aggregate({
1762
+ collection: "lead",
1763
+ pipeline: [
1764
+ { $match: { id: { $nin: ((_b = contacted[0]) == null ? void 0 : _b.ids) || [] }, organization: organization2 } },
1765
+ { $project: { _id: 0, id: 1 } }
1766
+ ]
1767
+ });
1768
+ for (const lead of uncontacted) {
1769
+ try {
1770
+ await resolveContact({ leadId: lead.id });
1771
+ backfilled += 1;
1772
+ } catch (error) {
1773
+ if (error.code !== 11e3) throw error;
1774
+ }
1775
+ }
1776
+ (_c = logger2 == null ? void 0 : logger2.info) == null ? void 0 : _c.call(logger2, "segment.sync.backfill", { backfilled, organization: organization2, uncontacted: uncontacted.length });
1777
+ }
1778
+ const contacts2 = await read.aggregate({
1779
+ collection: "contact",
1780
+ pipeline: [
1781
+ { $match: { organization: organization2 } },
1782
+ { $project: { _id: 0, id: 1 } },
1783
+ { $sort: { id: 1 } }
1784
+ ]
1785
+ });
1786
+ if (!contacts2.length) {
1787
+ return {
1788
+ ...release(segmentIds),
1789
+ message: "Organization has no contacts to evaluate against segments.",
1790
+ request: request2,
1791
+ response: { skipped: true },
1792
+ skipped: true
1793
+ };
1794
+ }
1795
+ const contactIds = contacts2.map((contact) => contact.id);
1796
+ const org = await read.get({ collection: "organization", query: { id: organization2 } });
1797
+ const chunks = [];
1798
+ for (let index = 0; index < contactIds.length; index += chunkSize) {
1799
+ chunks.push({
1800
+ contactIds: contactIds.slice(index, index + chunkSize),
1801
+ organization: organization2,
1802
+ segments: segmentIds,
1803
+ // A BACKFILL IS NOT BILLABLE. It creates the contacts this run
1804
+ // then evaluates, so charging for it would bill an organization
1805
+ // for work its own history made necessary.
1806
+ usage: (context == null ? void 0 : context.billable) === true && backfilled === 0 ? (org == null ? void 0 : org.usage) || null : null
1807
+ });
1808
+ }
1809
+ return {
1810
+ chunks,
1811
+ ...configured && { extra: { segment: configured } },
1812
+ message: "Queued " + contactIds.length + " contacts across " + chunks.length + " chunks for segment evaluation.",
1813
+ queue: "segment",
1814
+ request: { ...request2, segments: segmentIds },
1815
+ response: { chunks: chunks.length, contacts: contactIds.length, segments: segments.length }
1816
+ };
1817
+ } catch (error) {
1818
+ throw Object.assign(error, release(segmentIds, "error"));
1819
+ }
1820
+ }
1821
+ },
1822
+ sms: {
1823
+ // SMS TO A LEAD, through the merchant's own Twilio connection.
1824
+ //
1825
+ // WITHDRAWN from the builder — twilio went, and a connection-gated step
1826
+ // with no connection to gate on could only ever render permanently
1827
+ // disabled. Stored workflows still carry it, so it still runs.
1828
+ //
1829
+ // It looks its own connection up rather than relying on the shell, because
1830
+ // the step is declared by the PRIVATE drawbridge connection (which has
1831
+ // none) while the credential belongs to twilio (which has no manifest).
1832
+ // Platform SMS will remove that split the way it did for email.
1833
+ send: async ({ context, step, workflow }, { canSend, read } = {}) => {
1834
+ var _a, _b, _c;
1835
+ const to = (_a = context == null ? void 0 : context.phone) == null ? void 0 : _a.number;
1836
+ if (!to) throw new Error("No phone number on context (context.phone.number is required)");
1837
+ const request2 = { to };
1838
+ const connection2 = await read.get({
1839
+ collection: "connection",
1840
+ query: { organization: workflow.organization, slug: "twilio", status: "active" }
1841
+ });
1842
+ if (!connection2) return { message: "No active Twilio SMS connection \u2014 workflow-step SMS skipped.", request: request2, response: { skipped: true }, skipped: true };
1843
+ const { ok: sendable } = await canSend({ channel: "sms", to: context.phone });
1844
+ if (!sendable) return { message: "Recipient has opted out \u2014 skipped.", request: request2, response: { skipped: true }, skipped: true };
1845
+ return {
1846
+ message: "SMS queued for delivery to " + to + " via twilio.",
1847
+ request: request2,
1848
+ response: { provider: "twilio", queued: true },
1849
+ // QUEUES rather than sends: queue/notification.js owns delivery, the
1850
+ // carrier opt-out line and the segment count this is billed on.
1851
+ writes: [
1852
+ queueNotification({
1853
+ connection: connection2.id,
1854
+ message: interpolate((_b = step.settings) == null ? void 0 : _b.message, context),
1855
+ organization: workflow.organization,
1856
+ send: { phone: { number: to }, type: "phone" },
1857
+ title: interpolate((_c = step.settings) == null ? void 0 : _c.subject, context),
1858
+ workflow: workflow.id
1859
+ })
1860
+ ]
1861
+ };
1862
+ }
1863
+ },
1441
1864
  webhook: false
1442
1865
  },
1443
1866
  icon: drawbridge_default,
@@ -1479,11 +1902,11 @@ var drawbridge_default2 = {
1479
1902
  key: "Email \u2014 Digest",
1480
1903
  queue: "notification",
1481
1904
  settings: {
1482
- // The organization OWNER is always a recipient, resolved in sync,
1483
- // so this is additional recipients rather than the list. It cannot
1484
- // be required: the members endpoint is owner-gated and the owner is
1485
- // not a member document, so a solo merchant has nothing to pick and
1486
- // could never save the step.
1905
+ // The organization OWNER is always a recipient, resolved by the
1906
+ // hook, so this is additional recipients rather than the list. It
1907
+ // cannot be required: the members endpoint is owner-gated and the
1908
+ // owner is not a member document, so a solo merchant has nothing to
1909
+ // pick and could never save the step.
1487
1910
  members: { of: "string", type: "array" },
1488
1911
  message: { required: true, type: "string" },
1489
1912
  subject: { required: true, type: "string" }
@@ -1583,7 +2006,7 @@ var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
1583
2006
  <path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
1584
2007
  </svg>`;
1585
2008
 
1586
- // lib/connections/klaviyo.js
2009
+ // lib/connections/providers/klaviyo.js
1587
2010
  var api = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
1588
2011
  const response = await fetcher("https://a.klaviyo.com/api" + path, {
1589
2012
  ...payload && { body: JSON.stringify(payload) },
@@ -1620,7 +2043,8 @@ var klaviyo_default2 = {
1620
2043
  // differently, and it says so in hooks.auth.token rather than as a flag here.
1621
2044
  auth: {
1622
2045
  oauth: {
1623
- // NAMES the env vars holding OUR application's client. One identity,
2046
+ // NAMES the credentials holding OUR application's client keys into
2047
+ // the stored provider credentials, not env vars. One identity,
1624
2048
  // every merchant — the token is the merchant's and arrives from their
1625
2049
  // own consent, which is what stops one organization reading another's
1626
2050
  // data.
@@ -1760,7 +2184,7 @@ var klaviyo_default2 = {
1760
2184
  // renders an empty "Klaviyo account" field, because the merchant is
1761
2185
  // never asked which account they connected — the consent already
1762
2186
  // decided it, and asking again would be a question we can answer.
1763
- connect: async ({ fetcher, tokens }) => {
2187
+ connect: async ({ tokens }, { fetcher } = {}) => {
1764
2188
  var _a, _b, _c;
1765
2189
  const body = await api("/accounts", { fetcher, token: tokens.accessToken });
1766
2190
  const account = (_a = body == null ? void 0 : body.data) == null ? void 0 : _a[0];
@@ -1775,7 +2199,7 @@ var klaviyo_default2 = {
1775
2199
  //
1776
2200
  // Basic auth with our client, exactly like the token exchange — the
1777
2201
  // token being revoked is the subject, not the credential.
1778
- disconnect: async ({ clientId, clientSecret, fetcher = fetch, manifest, settings }) => {
2202
+ disconnect: async ({ clientId, clientSecret, manifest, settings }, { fetcher = fetch } = {}) => {
1779
2203
  const token = (settings == null ? void 0 : settings.refreshToken) || (settings == null ? void 0 : settings.accessToken);
1780
2204
  if (!token) return { revoked: false };
1781
2205
  const response = await fetcher(manifest.auth.oauth.urls.revoke, {
@@ -1798,7 +2222,7 @@ var klaviyo_default2 = {
1798
2222
  // the refresh token is the only thing that asks Klaviyo.
1799
2223
  //
1800
2224
  // It also keeps the grant warm against the 90-day idle window above.
1801
- probe: async ({ clientId, clientSecret, fetcher, manifest, settings }) => {
2225
+ probe: async ({ clientId, clientSecret, manifest, settings }, { fetcher } = {}) => {
1802
2226
  const token = await accessToken({
1803
2227
  clientId,
1804
2228
  clientSecret,
@@ -1828,12 +2252,11 @@ var klaviyo_default2 = {
1828
2252
  // the store's.
1829
2253
  commerce: false,
1830
2254
  // The verb the contacts.sync step points at. It does the work — including
1831
- // writing the profile id back onto the lead — and returns what happened.
1832
2255
  contacts: {
1833
2256
  // Not yet. Suppression syncs an opt-out as unsubscribed, which is a
1834
2257
  // different thing from deleting the profile.
1835
2258
  remove: false,
1836
- sync: async ({ contact, fetcher, lead, settings, suppressed, token }) => {
2259
+ sync: async ({ contact, lead, settings, suppressed, token }, { fetcher } = {}) => {
1837
2260
  var _a, _b, _c;
1838
2261
  const list = settings == null ? void 0 : settings.list;
1839
2262
  if (!list) return { message: "No Klaviyo list is chosen for this connection.", skipped: true };
@@ -1925,7 +2348,7 @@ var klaviyo_default2 = {
1925
2348
  // it, so one call quietly returns the first ten lists and an account
1926
2349
  // with more shows a picker missing the one they wanted, with nothing to
1927
2350
  // indicate anything was cut.
1928
- audiences: async ({ cursor, fetcher, limit = 100, search, token }) => {
2351
+ audiences: async ({ cursor, limit = 100, search, token }, { fetcher } = {}) => {
1929
2352
  var _a, _b;
1930
2353
  const audiences = [];
1931
2354
  let next = cursor ? "/lists?page%5Bsize%5D=10&page%5Bcursor%5D=" + encodeURIComponent(cursor) : "/lists?page%5Bsize%5D=10";
@@ -2037,7 +2460,7 @@ var mailchimp_default = `<svg width="500" height="500" viewBox="0 0 500 500" fil
2037
2460
  <path d="M360.476 284.243C360.35 283.835 359.618 281.204 358.647 278.052L356.621 272.648C360.575 266.696 360.645 261.405 360.124 258.393C359.531 254.519 357.693 250.944 354.89 248.205C351.766 244.94 345.363 241.563 336.385 239.044L331.671 237.736C331.643 237.524 331.418 226.619 331.235 221.933C331.08 218.555 330.798 213.264 329.152 208.058C327.182 200.993 323.791 194.858 319.527 190.876C331.277 178.717 338.594 165.307 338.58 153.81C338.538 131.703 311.379 124.976 277.902 138.851L270.824 141.863C270.795 141.835 258.004 129.283 257.821 129.128C219.63 95.8334 100.327 228.476 138.49 260.687L146.835 267.737C144.581 273.775 143.781 280.259 144.499 286.664C145.414 295.543 149.973 304.029 157.375 310.6C164.411 316.82 173.684 320.788 182.648 320.774C197.494 354.998 231.408 375.965 271.175 377.161C313.842 378.427 349.641 358.403 364.67 322.435C365.641 319.916 369.806 308.546 369.806 298.513C369.792 288.409 364.093 284.229 360.476 284.243ZM185.913 311.149C184.613 311.381 183.293 311.48 181.973 311.445C169.083 311.079 155.166 299.483 153.787 285.735C152.253 270.537 160.02 258.829 173.783 256.071C175.415 255.72 177.414 255.537 179.552 255.635C187.264 256.085 198.606 261.996 201.209 278.784C203.517 293.63 199.858 308.785 185.913 311.149ZM171.545 246.953C163.179 248.499 155.744 253.244 150.817 260.18C148.045 257.873 142.909 253.426 142.008 251.681C134.635 237.693 150.043 210.478 160.823 195.111C187.405 157.145 229.086 128.424 248.393 133.603C251.517 134.503 261.902 146.563 261.902 146.563C261.902 146.563 242.623 157.244 224.724 172.16C200.646 190.735 182.423 217.697 171.545 246.953ZM306.792 305.464C306.937 305.403 307.057 305.295 307.134 305.157C307.211 305.019 307.239 304.86 307.214 304.704C307.205 304.61 307.178 304.519 307.133 304.436C307.088 304.353 307.027 304.28 306.954 304.221C306.88 304.162 306.796 304.118 306.705 304.092C306.614 304.066 306.519 304.059 306.426 304.071C306.426 304.071 286.246 307.054 267.179 300.089C269.247 293.348 274.792 295.754 283.137 296.444C296.108 297.209 309.116 295.802 321.623 292.279C330.25 289.788 341.592 284.905 350.401 277.953C353.384 284.497 354.425 291.674 354.425 291.674C354.425 291.674 356.719 291.265 358.647 292.447C360.476 293.573 361.799 295.895 360.898 301.89C359.027 313.119 354.271 322.224 346.235 330.611C341.236 336.036 335.277 340.492 328.659 343.754C324.983 345.691 321.151 347.32 317.205 348.623C286.964 358.487 256.006 347.638 246.029 324.321C245.224 322.535 244.556 320.691 244.03 318.804C239.781 303.438 243.383 285.032 254.655 273.408C255.372 272.676 256.09 271.804 256.09 270.706C256.09 269.806 255.499 268.835 255.007 268.131C251.066 262.418 237.374 252.666 240.132 233.795C242.088 220.23 253.951 210.689 265.012 211.252L267.826 211.421C272.611 211.702 276.79 212.307 280.73 212.49C287.344 212.758 293.268 211.801 300.304 205.947C302.683 203.949 304.582 202.246 307.791 201.711C308.128 201.627 308.973 201.359 310.647 201.416C312.365 201.485 314.032 202.015 315.474 202.949C321.103 206.693 321.905 215.783 322.214 222.439C322.383 226.225 322.848 235.414 322.988 238.031C323.354 244.054 324.944 244.912 328.125 245.954C329.94 246.573 331.615 246.995 334.077 247.713C341.521 249.781 345.968 251.934 348.754 254.65C350.198 256.049 351.13 257.893 351.4 259.885C352.315 266.316 346.432 274.252 330.925 281.457C313.954 289.324 293.367 291.322 279.154 289.732L274.173 289.169C262.774 287.649 256.315 302.34 263.14 312.402C267.545 318.889 279.52 323.11 291.523 323.11C319.006 323.139 340.142 311.402 348.023 301.242L348.642 300.356C349.008 299.765 348.712 299.469 348.22 299.779C341.817 304.169 313.279 321.619 282.771 316.384C282.771 316.384 279.056 315.765 275.678 314.442C273.005 313.429 267.362 310.811 266.686 305.042C291.27 312.683 306.792 305.478 306.792 305.464ZM220.671 194.971C230.127 184.051 241.765 174.538 252.206 169.219C252.558 169.022 252.938 169.43 252.741 169.754C251.46 172 250.476 174.403 249.814 176.902C249.73 177.282 250.138 177.592 250.461 177.353C256.963 172.934 268.248 168.192 278.155 167.601C278.251 167.584 278.351 167.601 278.436 167.65C278.521 167.698 278.586 167.775 278.621 167.866C278.656 167.957 278.658 168.058 278.627 168.151C278.596 168.244 278.533 168.323 278.451 168.375C276.809 169.634 275.342 171.105 274.088 172.751C273.891 173.032 274.074 173.44 274.426 173.44C281.378 173.483 291.186 175.903 297.56 179.491C297.982 179.745 297.673 180.575 297.209 180.462C287.527 178.253 271.724 176.564 255.288 180.575C240.597 184.149 229.396 189.666 221.248 195.618C220.826 195.899 220.333 195.351 220.671 194.971Z" fill="#231E15"/>
2038
2461
  </svg>`;
2039
2462
 
2040
- // lib/connections/mailchimp.js
2463
+ // lib/connections/providers/mailchimp.js
2041
2464
  var base = (dc) => {
2042
2465
  if (!dc) throw new Error("This Mailchimp connection has no data centre stored, so there is no host to call");
2043
2466
  return "https://" + dc + ".api.mailchimp.com/3.0";
@@ -2126,7 +2549,7 @@ var mailchimp_default2 = {
2126
2549
  // The header here is `OAuth <token>`, not Bearer — that is specific to
2127
2550
  // the metadata endpoint. Marketing API calls take Bearer; see the
2128
2551
  // audiences hook.
2129
- connect: async ({ fetcher = fetch, tokens }) => {
2552
+ connect: async ({ tokens }, { fetcher = fetch } = {}) => {
2130
2553
  const response = await fetcher("https://login.mailchimp.com/oauth2/metadata", {
2131
2554
  headers: {
2132
2555
  authorization: "OAuth " + (tokens == null ? void 0 : tokens.accessToken)
@@ -2178,7 +2601,7 @@ var mailchimp_default2 = {
2178
2601
  // successful — the same silent truncation Klaviyo has, at a different
2179
2602
  // number. Paged against total_items so an account past a thousand still
2180
2603
  // resolves.
2181
- audiences: async ({ cursor, fetcher = fetch, limit = 100, search, settings, token }) => {
2604
+ audiences: async ({ cursor, limit = 100, search, settings, token }, { fetcher = fetch } = {}) => {
2182
2605
  const dc = settings == null ? void 0 : settings.dc;
2183
2606
  const count = Math.min(limit, 1e3);
2184
2607
  const offset = Number(cursor || 0);
@@ -2257,6 +2680,10 @@ var mailchimp_default2 = {
2257
2680
  title: "Mailchimp"
2258
2681
  };
2259
2682
 
2683
+ // lib/connections/providers/shopify.js
2684
+ var import_node_crypto3 = require("crypto");
2685
+ var import_nanoid2 = require("nanoid");
2686
+
2260
2687
  // lib/connections/icons/shopify.js
2261
2688
  var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
2262
2689
  <rect width="500" height="500" fill="white"/>
@@ -2267,12 +2694,15 @@ var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
2267
2694
 
2268
2695
  // lib/connections/inbound.js
2269
2696
  var import_node_crypto2 = require("crypto");
2270
- var verifySignature = ({ body, descriptor, headers }) => {
2697
+ var verifySignature = ({ body, descriptor, headers, secret }) => {
2698
+ if (!secret) {
2699
+ throw Object.assign(new Error("Missing webhook secret: " + descriptor.signature.secret), { status: 500 });
2700
+ }
2271
2701
  const provided = headers[descriptor.headers.signature];
2272
2702
  if (!provided) {
2273
2703
  throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
2274
2704
  }
2275
- const digest = (0, import_node_crypto2.createHmac)(descriptor.signature.algorithm, process.env[descriptor.signature.secret]).update(body).digest(descriptor.signature.encoding);
2705
+ const digest = (0, import_node_crypto2.createHmac)(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
2276
2706
  const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
2277
2707
  const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
2278
2708
  if (digestBuffer.length !== providedBuffer.length || !(0, import_node_crypto2.timingSafeEqual)(digestBuffer, providedBuffer)) {
@@ -2282,7 +2712,72 @@ var verifySignature = ({ body, descriptor, headers }) => {
2282
2712
  };
2283
2713
  var readEventHeader = ({ descriptor, headers }) => headers[descriptor.headers.event];
2284
2714
 
2285
- // lib/connections/shopify.js
2715
+ // lib/email.js
2716
+ var GMAIL_DOMAINS = /* @__PURE__ */ new Set(["gmail.com", "googlemail.com"]);
2717
+ var toCanonicalEmail = (value) => {
2718
+ if (!value || typeof value !== "string") return null;
2719
+ const email = value.trim().toLowerCase();
2720
+ const at = email.lastIndexOf("@");
2721
+ if (at < 1 || at === email.length - 1) return null;
2722
+ let local = email.slice(0, at);
2723
+ const domain = email.slice(at + 1);
2724
+ const plus = local.indexOf("+");
2725
+ if (plus > 0) local = local.slice(0, plus);
2726
+ if (GMAIL_DOMAINS.has(domain)) local = local.replaceAll(".", "");
2727
+ if (!local) return null;
2728
+ return local + "@" + domain;
2729
+ };
2730
+
2731
+ // lib/phone.js
2732
+ var import_libphonenumber_js = require("libphonenumber-js");
2733
+ var toE164 = (value, country) => {
2734
+ if (!value) return null;
2735
+ try {
2736
+ const parsed = (0, import_libphonenumber_js.parsePhoneNumberFromString)(String(value), country);
2737
+ return parsed ? parsed.number : null;
2738
+ } catch {
2739
+ return null;
2740
+ }
2741
+ };
2742
+
2743
+ // lib/connections/providers/shopify.js
2744
+ var toLine = ({
2745
+ price,
2746
+ product_id: productId,
2747
+ quantity,
2748
+ title,
2749
+ variant_id: variantId,
2750
+ variant_title: variantTitle
2751
+ }) => ({
2752
+ price: parseFloat(price) || 0,
2753
+ productId: productId ? "gid://shopify/Product/" + productId : null,
2754
+ quantity: quantity || 1,
2755
+ title: title || null,
2756
+ variantId: variantId ? "gid://shopify/ProductVariant/" + variantId : null,
2757
+ variantTitle: variantTitle || null
2758
+ });
2759
+ var attributeLineItems = (lineItems = []) => lineItems.reduce(
2760
+ (acc, item) => {
2761
+ const attrs = (item.properties || []).reduce(
2762
+ (map, { name, value }) => {
2763
+ map[name] = value;
2764
+ return map;
2765
+ },
2766
+ {}
2767
+ );
2768
+ if (!attrs["_drwbrdg_ca"]) return acc;
2769
+ if (!Object.keys(acc.attrMap).length) acc.attrMap = attrs;
2770
+ const line = toLine(item);
2771
+ acc.attributedGross += line.price * line.quantity;
2772
+ acc.attributedLines.push(line);
2773
+ return acc;
2774
+ },
2775
+ { attrMap: {}, attributedGross: 0, attributedLines: [] }
2776
+ );
2777
+ var generateDiscountCode = (0, import_nanoid2.customAlphabet)("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
2778
+ var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
2779
+ var OAUTH_ERROR_SOURCE = "oauth";
2780
+ var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
2286
2781
  var inbound = {
2287
2782
  headers: {
2288
2783
  event: "x-shopify-topic",
@@ -2399,20 +2894,476 @@ var shopify_default2 = {
2399
2894
  //
2400
2895
  // `shopify` is injected for the same reason it is everywhere else — this
2401
2896
  // package cannot import @drawbridge/shopify, which depends on it.
2402
- scopes: ({ scope, shopify }) => scope ? shopify.oauth.missingScopes(scope) : null,
2897
+ scopes: ({ scope }, { shopify } = {}) => scope ? shopify.oauth.missingScopes(scope) : null,
2403
2898
  // Shopify's install grant is exchanged inside its own app flow, not
2404
2899
  // through the shared OAuth runner.
2405
2900
  token: false
2406
2901
  },
2407
- // Implemented in drawbridge-sync, which owns the attribution and the
2408
- // controllers it needs. Declared here so the steps below can point at them:
2409
- // a step naming a hook the vendor does not implement is a workflow that
2410
- // accepts the step and then silently does nothing.
2902
+ // THE VENDOR'S OWN WORK, here in full. Every body describes its writes,
2903
+ // enqueues and events for the shell to perform see contract.js and
2904
+ // everything it needs arrives as an argument: `read` (the controller's
2905
+ // read methods, nothing that writes), `shopify` (the SDK, injected because
2906
+ // this package cannot import what depends on it), `adminToken` (minted by
2907
+ // the shell, which persists rotations), `mintId` (so one described write
2908
+ // can reference another), `dispatch` (the caller's own coordinator table,
2909
+ // for the hooks that are dispatches).
2411
2910
  commerce: {
2412
- code: {},
2413
- customer: {},
2414
- order: {},
2415
- product: {}
2911
+ // MINT A DISCOUNT CODE against the merchant's chosen discount, mapped to
2912
+ // one lead — which is what lets an order that redeems it be attributed
2913
+ // back.
2914
+ code: async ({ connection: connection2, context, step }, { adminToken, shopify } = {}) => {
2915
+ var _a;
2916
+ const discount = (_a = step.settings) == null ? void 0 : _a.discount;
2917
+ const request2 = { email: (context == null ? void 0 : context.email) || null, lead: (context == null ? void 0 : context.lead) || null, shop: connection2.shop };
2918
+ if (!(context == null ? void 0 : context.email)) return { message: "Lead email is missing.", request: request2, response: { skipped: true }, skipped: true };
2919
+ if (!(context == null ? void 0 : context.lead)) return { message: "Lead id is missing.", request: request2, response: { skipped: true }, skipped: true };
2920
+ if (!(discount == null ? void 0 : discount.id)) return { message: "Discount is not configured on this step.", request: request2, response: { skipped: true }, skipped: true };
2921
+ const adminAccessToken = await adminToken();
2922
+ if (!context.shopifyCustomerId) {
2923
+ const customer = await shopify.admin.getOrCreateCustomer({ adminAccessToken, domain: connection2.shop, email: context.email });
2924
+ if (!(customer == null ? void 0 : customer.id)) return { message: "Shopify did not return a customer id \u2014 create/lookup failed.", request: request2, response: { skipped: true }, skipped: true };
2925
+ }
2926
+ const discountCode = await shopify.admin.createDiscountCode({
2927
+ adminAccessToken,
2928
+ code: "DB-" + generateDiscountCode(),
2929
+ discountId: discount.id,
2930
+ domain: connection2.shop
2931
+ });
2932
+ if (!discountCode) return { message: "Shopify did not return a discount code \u2014 create failed.", request: request2, response: { skipped: true }, skipped: true };
2933
+ return {
2934
+ context: {
2935
+ shopifyDiscountCode: discountCode.code,
2936
+ shopifyDiscountId: String(discountCode.id)
2937
+ },
2938
+ message: "Discount code created and linked to lead.",
2939
+ request: request2,
2940
+ response: { code: discountCode.code, id: String(discountCode.id) },
2941
+ // bypassDocumentValidation because these are vendor ids on a
2942
+ // Drawbridge document the schema does not declare — the
2943
+ // canonical-identity work resolves it properly.
2944
+ writes: [{
2945
+ collection: "lead",
2946
+ data: {
2947
+ $set: {
2948
+ shopifyDiscountCode: discountCode.code,
2949
+ shopifyDiscountId: String(discountCode.id)
2950
+ }
2951
+ },
2952
+ operation: "update",
2953
+ options: { bypassDocumentValidation: true },
2954
+ query: { id: context.lead }
2955
+ }]
2956
+ };
2957
+ },
2958
+ // CREATE THE BUYER AT THE STORE, so an order can be attributed to them.
2959
+ //
2960
+ // IDEMPOTENT THREE WAYS, because this runs on every entry and a duplicate
2961
+ // customer at the store is a support ticket: the context may already
2962
+ // carry the id from an earlier step, the lead may already be linked from
2963
+ // an earlier run, and Shopify's own get-or-create settles the rest.
2964
+ customer: async ({ connection: connection2, context }, { adminToken, read, shopify } = {}) => {
2965
+ const request2 = { email: (context == null ? void 0 : context.email) || null, lead: (context == null ? void 0 : context.lead) || null, shop: connection2.shop };
2966
+ if (!(context == null ? void 0 : context.email)) return { message: "Lead email is missing \u2014 cannot create Shopify customer.", request: request2, response: { skipped: true }, skipped: true };
2967
+ if (!(context == null ? void 0 : context.lead)) return { message: "Lead id is missing \u2014 cannot create Shopify customer.", request: request2, response: { skipped: true }, skipped: true };
2968
+ if (context.shopifyCustomerId) {
2969
+ return {
2970
+ context: { shopifyCustomerId: context.shopifyCustomerId },
2971
+ message: "Trigger data already includes a Shopify customer id \u2014 reusing.",
2972
+ request: request2,
2973
+ response: { shopifyCustomerId: context.shopifyCustomerId },
2974
+ // Reusing an id is not a creation, so it does not bill.
2975
+ skipped: true
2976
+ };
2977
+ }
2978
+ const lead = await read.get({ collection: "lead", query: { id: context.lead } });
2979
+ if (lead == null ? void 0 : lead.shopifyCustomerId) {
2980
+ return {
2981
+ context: { shopifyCustomerId: lead.shopifyCustomerId },
2982
+ message: "Lead already has a Shopify customer id \u2014 reusing.",
2983
+ request: request2,
2984
+ response: { shopifyCustomerId: lead.shopifyCustomerId },
2985
+ skipped: true
2986
+ };
2987
+ }
2988
+ const adminAccessToken = await adminToken();
2989
+ const parts = ((lead == null ? void 0 : lead.name) || "").trim().split(/\s+/).filter(Boolean);
2990
+ const customer = await shopify.admin.getOrCreateCustomer({
2991
+ adminAccessToken,
2992
+ domain: connection2.shop,
2993
+ email: context.email,
2994
+ firstName: parts.length ? parts[0] : null,
2995
+ lastName: parts.length > 1 ? parts.slice(1).join(" ") : null,
2996
+ source: "drawbridge"
2997
+ });
2998
+ if (!(customer == null ? void 0 : customer.id)) return { message: "Shopify did not return a customer id \u2014 create/lookup failed.", request: request2, response: { skipped: true }, skipped: true };
2999
+ return {
3000
+ context: { shopifyCustomerId: customer.id },
3001
+ message: "Shopify customer created/linked to lead.",
3002
+ request: request2,
3003
+ response: { shopifyCustomerId: customer.id },
3004
+ // The hook's own result, described beside the call that produced it.
3005
+ writes: [{
3006
+ collection: "lead",
3007
+ data: { $set: { shopifyCustomerId: customer.id } },
3008
+ operation: "update",
3009
+ options: { bypassDocumentValidation: true },
3010
+ query: { id: context.lead }
3011
+ }]
3012
+ };
3013
+ },
3014
+ // AN ORDER ARRIVED AT THE STORE. The largest hook in the family, because
3015
+ // attribution genuinely is: an order can reach Drawbridge two ways and
3016
+ // they bill differently.
3017
+ //
3018
+ // CONVERSION — a `_drwbrdg_ca` line-item property, injected at
3019
+ // add-to-cart. Causal: the campaign produced the sale, so
3020
+ // it carries a fee.
3021
+ // REDEMPTION — a DB- discount code matched to a lead. Associative: we
3022
+ // cannot claim we caused the purchase, so it is fee-free.
3023
+ //
3024
+ // Both can be true, and an order already recorded as a conversion can
3025
+ // later have a redemption backfilled onto it — `backfill` below.
3026
+ //
3027
+ // IDEMPOTENT THROUGH THE RETRY, one layer up: two deliveries of the same
3028
+ // order race, the loser's transaction hits a duplicate key, the step
3029
+ // fails and BullMQ redelivers — and the re-run's read at the top finds
3030
+ // what the winner wrote and skips instead of double-billing a merchant
3031
+ // for one purchase. The hook used to loop for this itself; describing
3032
+ // the writes moved the retry to the queue, with the same guarantee.
3033
+ order: async ({ connection: connection2, context }, { logger: logger2, mintId, read } = {}) => {
3034
+ var _a, _b, _c, _d, _e, _f;
3035
+ const {
3036
+ advertisement,
3037
+ created_at: createdAt,
3038
+ currency,
3039
+ customer: orderCustomer,
3040
+ email,
3041
+ id: orderId,
3042
+ line_items: lineItems = [],
3043
+ organization: organization2,
3044
+ phone
3045
+ } = context || {};
3046
+ const request2 = { orderId: orderId ? String(orderId) : null, organization: organization2 };
3047
+ const [existingOrder, existingRedemption] = await Promise.all([
3048
+ read.get({ collection: "order", query: { "provider.id": String(orderId), "provider.slug": "shopify" } }),
3049
+ read.get({ collection: "redemption", query: { "provider.id": String(orderId), "provider.slug": "shopify" } })
3050
+ ]);
3051
+ if (existingRedemption) {
3052
+ return {
3053
+ message: "Order/redemption already recorded \u2014 skipping duplicate.",
3054
+ request: request2,
3055
+ response: {
3056
+ existingOrderId: (existingOrder == null ? void 0 : existingOrder.id) || null,
3057
+ existingRedemptionId: existingRedemption.id,
3058
+ skipped: true
3059
+ },
3060
+ skipped: true
3061
+ };
3062
+ }
3063
+ const backfill = !!existingOrder;
3064
+ const { attrMap, attributedGross, attributedLines } = attributeLineItems(lineItems);
3065
+ const campaign = attrMap["_drwbrdg_ca"] || null;
3066
+ const discountCodes = Array.isArray(context == null ? void 0 : context.discount_codes) ? context.discount_codes : [];
3067
+ const codes = [...new Set(discountCodes.map((dc) => dc == null ? void 0 : dc.code).filter(Boolean))];
3068
+ const matchedLeads = codes.length ? await read.aggregate({
3069
+ collection: "lead",
3070
+ pipeline: [{ $match: { organization: organization2, shopifyDiscountCode: { $in: codes } } }]
3071
+ }) : [];
3072
+ const codeToLead = {};
3073
+ for (const found of matchedLeads) {
3074
+ if (found.shopifyDiscountCode) codeToLead[found.shopifyDiscountCode] = found;
3075
+ }
3076
+ const matchedDiscounts = discountCodes.filter((dc) => (dc == null ? void 0 : dc.code) && codeToLead[dc.code]).map((dc) => ({
3077
+ amount: parseFloat(dc.amount) || 0,
3078
+ code: dc.code,
3079
+ id: codeToLead[dc.code].shopifyDiscountId || null
3080
+ }));
3081
+ const matchedLead = matchedDiscounts.length ? codeToLead[matchedDiscounts[0].code] : null;
3082
+ const discount = matchedDiscounts.length ? {
3083
+ amount: matchedDiscounts.reduce((sum, entry) => sum + entry.amount, 0),
3084
+ codes: matchedDiscounts
3085
+ } : null;
3086
+ const matchedCodes = new Set(matchedDiscounts.map((entry) => entry.code));
3087
+ const unmatched = codes.filter((code2) => code2.startsWith("DB-") && !matchedCodes.has(code2));
3088
+ if (unmatched.length) {
3089
+ (_a = logger2 == null ? void 0 : logger2.warn) == null ? void 0 : _a.call(logger2, "shopify.order.discount.unmatched", {
3090
+ campaign: campaign || null,
3091
+ codes: JSON.stringify(unmatched),
3092
+ isConversion: !!campaign,
3093
+ orderId: String(orderId),
3094
+ organization: organization2
3095
+ });
3096
+ }
3097
+ if (!campaign && !discount || backfill && !discount) {
3098
+ return {
3099
+ message: backfill ? "Order already recorded and no Drawbridge discount code matched \u2014 nothing to backfill." : "Order has no Drawbridge attribution \u2014 not recording.",
3100
+ request: request2,
3101
+ response: { skipped: true },
3102
+ skipped: true
3103
+ };
3104
+ }
3105
+ let advertisementId = null;
3106
+ let affiliateId = null;
3107
+ let campaignOrganization = organization2;
3108
+ let gross = 0;
3109
+ let leadId = null;
3110
+ let lines = [];
3111
+ let orderCampaign = null;
3112
+ let pageId = null;
3113
+ const isConversion = !!campaign;
3114
+ const customerPhone = toE164((orderCustomer == null ? void 0 : orderCustomer.phone) || phone) || null;
3115
+ const matchPhones = [...new Set([
3116
+ customerPhone,
3117
+ toE164((_b = context == null ? void 0 : context.billing_address) == null ? void 0 : _b.phone),
3118
+ toE164((_c = context == null ? void 0 : context.shipping_address) == null ? void 0 : _c.phone)
3119
+ ].filter(Boolean))];
3120
+ if (isConversion) {
3121
+ const campaignDoc = await read.get({ collection: "campaign", query: { id: campaign } });
3122
+ if (!campaignDoc || campaignDoc.organization !== organization2) {
3123
+ return {
3124
+ message: "Order carried a campaign attribution that does not belong to this store \u2014 not recording.",
3125
+ request: request2,
3126
+ response: { skipped: true },
3127
+ skipped: true
3128
+ };
3129
+ }
3130
+ advertisementId = attrMap["_drwbrdg_ad"] || advertisement || null;
3131
+ affiliateId = attrMap["_drwbrdg_af"] || null;
3132
+ campaignOrganization = campaignDoc.organization;
3133
+ gross = attributedGross;
3134
+ lines = attributedLines;
3135
+ orderCampaign = campaign;
3136
+ pageId = attrMap["_drwbrdg_pg"] || null;
3137
+ const identifiers = [];
3138
+ const canonicalEmail = toCanonicalEmail(email);
3139
+ if (email) identifiers.push({ email: email.toLowerCase() });
3140
+ if (canonicalEmail) identifiers.push({ "canonical.email.value": canonicalEmail });
3141
+ if (matchPhones.length) identifiers.push({ "phone.number": { $in: matchPhones } });
3142
+ if (matchPhones.length) identifiers.push({ "canonical.phone.value": { $in: matchPhones } });
3143
+ if (identifiers.length) {
3144
+ const lead = await read.get({
3145
+ collection: "lead",
3146
+ query: {
3147
+ campaigns: { $in: [campaign] },
3148
+ organization: campaignOrganization,
3149
+ $or: identifiers
3150
+ }
3151
+ });
3152
+ leadId = (lead == null ? void 0 : lead.id) || null;
3153
+ if (!leadId) {
3154
+ const orgLead = await read.get({
3155
+ collection: "lead",
3156
+ query: { organization: campaignOrganization, $or: identifiers }
3157
+ });
3158
+ leadId = (orgLead == null ? void 0 : orgLead.id) || null;
3159
+ }
3160
+ }
3161
+ } else {
3162
+ leadId = matchedLead.id;
3163
+ orderCampaign = (matchedLead.campaigns || []).length === 1 ? matchedLead.campaigns[0] : null;
3164
+ gross = lineItems.reduce((sum, item) => {
3165
+ const line = toLine(item);
3166
+ return sum + line.price * line.quantity;
3167
+ }, 0);
3168
+ lines = lineItems.map(toLine);
3169
+ }
3170
+ const org = await read.get({ collection: "organization", query: { id: campaignOrganization } });
3171
+ let rate = 0;
3172
+ if (isConversion) {
3173
+ const subscription = await read.get({ collection: "subscription", query: { id: org == null ? void 0 : org.subscription } });
3174
+ rate = conversionRate(subscription);
3175
+ }
3176
+ const fee = isConversion ? Math.round(gross * rate) / 100 : 0;
3177
+ const net2 = Math.round((gross - fee) * 100) / 100;
3178
+ const currencyCode = (currency || "usd").toLowerCase();
3179
+ const purchasedAt = new Date(createdAt || Date.now());
3180
+ const customer = orderCustomer || email || phone ? {
3181
+ acceptsMarketing: ((_d = orderCustomer == null ? void 0 : orderCustomer.email_marketing_consent) == null ? void 0 : _d.state) ? orderCustomer.email_marketing_consent.state === "subscribed" : typeof (orderCustomer == null ? void 0 : orderCustomer.accepts_marketing) === "boolean" ? orderCustomer.accepts_marketing : null,
3182
+ email: (orderCustomer == null ? void 0 : orderCustomer.email) || email || null,
3183
+ firstName: (orderCustomer == null ? void 0 : orderCustomer.first_name) || null,
3184
+ id: (orderCustomer == null ? void 0 : orderCustomer.id) ? String(orderCustomer.id) : null,
3185
+ lastName: (orderCustomer == null ? void 0 : orderCustomer.last_name) || null,
3186
+ phone: customerPhone
3187
+ } : null;
3188
+ const source = (connection2 == null ? void 0 : connection2.source) ? { domain: connection2.source.domain, id: connection2.source.id } : void 0;
3189
+ const orderDocId = (existingOrder == null ? void 0 : existingOrder.id) || (isConversion && !backfill ? mintId() : null);
3190
+ const writes = [];
3191
+ if (isConversion && !backfill) {
3192
+ writes.push({
3193
+ collection: "order",
3194
+ data: {
3195
+ advertisement: advertisementId,
3196
+ affiliate: affiliateId,
3197
+ campaign: orderCampaign,
3198
+ currency: currencyCode,
3199
+ customer,
3200
+ discount,
3201
+ fee,
3202
+ gross,
3203
+ id: orderDocId,
3204
+ lead: leadId,
3205
+ lines,
3206
+ net: net2,
3207
+ organization: campaignOrganization,
3208
+ page: pageId,
3209
+ provider: { id: String(orderId), slug: "shopify" },
3210
+ purchasedAt,
3211
+ rate,
3212
+ source,
3213
+ status: "completed"
3214
+ },
3215
+ operation: "create"
3216
+ });
3217
+ if (org == null ? void 0 : org.usage) {
3218
+ writes.push({
3219
+ collection: "usage",
3220
+ data: { $inc: { "totals.revenue": gross } },
3221
+ operation: "update",
3222
+ query: { id: org.usage }
3223
+ });
3224
+ }
3225
+ if (leadId) {
3226
+ writes.push({
3227
+ collection: "lead",
3228
+ data: { $inc: { "totals.orders": 1 } },
3229
+ operation: "update",
3230
+ options: { bypassDocumentValidation: true },
3231
+ query: { id: leadId }
3232
+ });
3233
+ }
3234
+ }
3235
+ if (discount) {
3236
+ writes.push({
3237
+ collection: "redemption",
3238
+ data: {
3239
+ advertisement: advertisementId,
3240
+ affiliate: affiliateId,
3241
+ campaign: orderCampaign,
3242
+ code: ((_e = matchedDiscounts[0]) == null ? void 0 : _e.code) || null,
3243
+ currency: currencyCode,
3244
+ customer,
3245
+ discount,
3246
+ gross,
3247
+ lead: leadId,
3248
+ order: orderDocId,
3249
+ organization: campaignOrganization,
3250
+ page: pageId,
3251
+ provider: { id: String(orderId), slug: "shopify" },
3252
+ purchasedAt,
3253
+ source,
3254
+ status: "completed"
3255
+ },
3256
+ operation: "create"
3257
+ });
3258
+ if (org == null ? void 0 : org.usage) {
3259
+ writes.push({
3260
+ collection: "usage",
3261
+ data: { $inc: { "totals.redemptions": 1 } },
3262
+ operation: "update",
3263
+ query: { id: org.usage }
3264
+ });
3265
+ }
3266
+ if (leadId) {
3267
+ writes.push({
3268
+ collection: "lead",
3269
+ data: { $inc: { "totals.redemptions": 1 } },
3270
+ operation: "update",
3271
+ options: { bypassDocumentValidation: true },
3272
+ query: { id: leadId }
3273
+ });
3274
+ }
3275
+ }
3276
+ const enqueues = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && ((_f = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _f.id) && !backfill ? [{
3277
+ data: {
3278
+ idempotencyKey: String(orderId),
3279
+ orderDocId,
3280
+ orderId: String(orderId),
3281
+ rate,
3282
+ shopId: connection2.source.id,
3283
+ // The App Events API returns no event id, so one is generated
3284
+ // here — the event handle plus the order id — and sent as the
3285
+ // event's `reference`. queue/usage.js stamps the same id onto
3286
+ // the order as billed.transaction.
3287
+ transaction: "drawbridge-orders." + orderId,
3288
+ value: Math.round(fee * 100)
3289
+ },
3290
+ name: "billing",
3291
+ options: { jobId: "shopify.usage." + orderId },
3292
+ queue: "usage"
3293
+ }] : [];
3294
+ return {
3295
+ enqueues,
3296
+ message: backfill ? "Redemption backfilled for an already-recorded order." : isConversion ? "Order recorded." : "Discount redemption recorded (fee-free).",
3297
+ request: request2,
3298
+ response: {
3299
+ campaign: orderCampaign,
3300
+ currency: currencyCode,
3301
+ discount,
3302
+ fee,
3303
+ gross,
3304
+ lead: leadId,
3305
+ lines: lines.length,
3306
+ net: net2,
3307
+ orderId: String(orderId)
3308
+ },
3309
+ // ONE TRANSACTION. The order, the redemption and both totals
3310
+ // counters land together or not at all — a half-written attribution
3311
+ // is revenue counted twice or not at all, and neither is
3312
+ // recoverable by hand.
3313
+ transaction: writes.length > 0,
3314
+ writes
3315
+ };
3316
+ },
3317
+ // A PRODUCT CHANGED AT THE STORE. Upserts the product row and hands it to
3318
+ // the product pipeline; the actual field sync happens there.
3319
+ //
3320
+ // The shell has already refused a missing or inactive Shopify connection,
3321
+ // so what is left is the two things only this hook can know are wrong.
3322
+ product: async ({ connection: connection2, context, workflow }, { mintId } = {}) => {
3323
+ const request2 = {
3324
+ numericId: (context == null ? void 0 : context.id) || null,
3325
+ organizationId: workflow.organization,
3326
+ title: (context == null ? void 0 : context.title) || null
3327
+ };
3328
+ if (!(context == null ? void 0 : context.id)) return { message: "Skipped \u2014 product webhook payload had no id.", request: request2, response: { skipped: true }, skipped: true };
3329
+ if (!connection2.shop) return { message: "Skipped \u2014 Shopify connection is missing shop domain.", request: request2, response: { skipped: true }, skipped: true };
3330
+ const providerId = "gid://shopify/Product/" + context.id;
3331
+ const productId = mintId();
3332
+ return {
3333
+ enqueues: [{
3334
+ data: { product: productId, providerId, shop: connection2.shop },
3335
+ name: "workflow",
3336
+ options: { jobId: "product.workflow.shopify." + providerId + "." + Date.now() },
3337
+ queue: "product.shopify"
3338
+ }],
3339
+ message: "Product sync queued from Shopify webhook.",
3340
+ request: request2,
3341
+ response: { productId, providerId, title: (context == null ? void 0 : context.title) || null },
3342
+ // KEYED ON PROVIDER + SHOP, so the same product in two stores stays
3343
+ // two rows. `connections` accumulates rather than replaces: one
3344
+ // store can be linked to several organizations, and each keeps its
3345
+ // own claim on the row.
3346
+ writes: [{
3347
+ collection: "product",
3348
+ data: {
3349
+ $addToSet: { connections: connection2.id },
3350
+ $setOnInsert: {
3351
+ id: productId,
3352
+ provider: { id: providerId, slug: "shopify" },
3353
+ "source.id": connection2.id,
3354
+ status: "active"
3355
+ }
3356
+ },
3357
+ operation: "update",
3358
+ options: { upsert: true },
3359
+ query: {
3360
+ "provider.id": providerId,
3361
+ "provider.slug": "shopify",
3362
+ "source.domain": connection2.shop
3363
+ }
3364
+ }]
3365
+ };
3366
+ }
2416
3367
  },
2417
3368
  contacts: { remove: false, sync: false },
2418
3369
  // verify and event lean entirely on the shared HMAC helper — Shopify's
@@ -2430,7 +3381,16 @@ var shopify_default2 = {
2430
3381
  sms: false,
2431
3382
  inbound: {
2432
3383
  event: (args) => readEventHeader({ ...args, descriptor: inbound }),
2433
- process: {},
3384
+ // One hook over the whole topic table, because that is what this
3385
+ // manifest declares: Shopify processes its own buffered events. The
3386
+ // topic rides in on the context rather than being a second hook name per
3387
+ // topic; the caller's handler table arrives as a prop.
3388
+ process: async ({ context }, { dispatch } = {}) => {
3389
+ const key = "shopify." + (context == null ? void 0 : context.topic);
3390
+ const handled = await dispatch({ data: context == null ? void 0 : context.data, handler: key });
3391
+ if (!handled) return { message: "No handler for " + key, skipped: true };
3392
+ return { message: "Processed " + key, request: { topic: context == null ? void 0 : context.topic } };
3393
+ },
2434
3394
  receive: ({ channel, event, headers, payload }) => {
2435
3395
  if (channel === "compliance" && !COMPLIANCE_TOPICS.has(event)) {
2436
3396
  throw Object.assign(new Error("Unrecognized compliance topic: " + event), { status: 401 });
@@ -2446,7 +3406,107 @@ var shopify_default2 = {
2446
3406
  },
2447
3407
  verify: (args) => verifySignature({ ...args, descriptor: inbound })
2448
3408
  },
2449
- lifecycle: { cleanup: {}, health: {}, register: {}, rehydrate: {} },
3409
+ lifecycle: {
3410
+ // DISPATCHES INTO THE CALLER'S OWN COORDINATORS. These three are
3411
+ // declarations made true: the work is queue orchestration over
3412
+ // Drawbridge's own collections, which is coordinator work and stays in
3413
+ // the repo that owns the queues. The hook receives the dispatch table as
3414
+ // a prop and picks the entry, so the manifest owns the SEAM — asking
3415
+ // Shopify whether it handles its own lifecycle now gets a real function
3416
+ // instead of `unimplemented` while the work happened anyway.
3417
+ cleanup: async ({ context }, { dispatch } = {}) => {
3418
+ await dispatch({ data: context, handler: "cleanup" });
3419
+ return { message: "Ran shopify lifecycle.cleanup", request: context || null };
3420
+ },
3421
+ // KEEP STORE ACCESS WORKING. Not a webhook monitor, despite the name the
3422
+ // step once carried — webhooks are declarative, declared in the app's
3423
+ // toml and applied by Shopify to every install, so nothing here registers
3424
+ // or checks them.
3425
+ //
3426
+ // It rotates the refresh token before its window closes, proves the
3427
+ // access token still works, reconciles the scopes the store granted
3428
+ // against the ones the app now needs, and queues a webhook
3429
+ // reconciliation.
3430
+ health: async ({ connection: connection2, workflow }, { adminToken, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {}) => {
3431
+ const request2 = {
3432
+ connectionId: workflow.connection,
3433
+ organizationId: workflow.organization,
3434
+ shop: connection2.shop
3435
+ };
3436
+ const refreshTokenAtStart = (await resolveSettings()).refreshToken || null;
3437
+ try {
3438
+ const adminAccessToken = await adminToken();
3439
+ const settings = await resolveSettings();
3440
+ const refreshTokenExpiresAt = settings.refreshTokenExpiresAt;
3441
+ const needsRotation = refreshTokenExpiresAt && new Date(refreshTokenExpiresAt) < new Date(Date.now() + REFRESH_TOKEN_FRESHNESS_BUFFER_MS);
3442
+ let refreshTokenRotated = false;
3443
+ if (needsRotation) {
3444
+ await rotateToken();
3445
+ refreshTokenRotated = true;
3446
+ }
3447
+ await shopify.oauth.ping({ adminAccessToken, domain: connection2.shop });
3448
+ const scopesMissing = await reconcileScopes({ shop: connection2.shop });
3449
+ return {
3450
+ enqueues: [{
3451
+ data: {
3452
+ data: {
3453
+ connectionId: workflow.connection,
3454
+ organizationId: workflow.organization
3455
+ },
3456
+ event: "shopify.register.webhooks"
3457
+ },
3458
+ name: "register",
3459
+ options: { jobId: "connection.update.register." + workflow.connection + "." + (0, import_node_crypto3.randomUUID)() },
3460
+ queue: "connection"
3461
+ }],
3462
+ message: (scopesMissing == null ? void 0 : scopesMissing.length) ? "Health check: ping ok, webhooks reconciled \u2014 connection errored, granted scopes are missing: " + scopesMissing.join(", ") + "." : refreshTokenRotated ? "Health check passed \u2014 refresh token rotated, ping ok, webhooks reconciled." : "Health check passed \u2014 ping ok, webhooks reconciled.",
3463
+ request: request2,
3464
+ response: {
3465
+ pingedAt: /* @__PURE__ */ new Date(),
3466
+ refreshTokenExpiresAt: refreshTokenExpiresAt || null,
3467
+ refreshTokenRotated,
3468
+ scopesMissing,
3469
+ webhookReconciliationQueued: true
3470
+ }
3471
+ };
3472
+ } catch (error) {
3473
+ if (OAUTH_GRANT_REVOKED_CODES.includes(error.code)) {
3474
+ const current = await read.get({ collection: "connection", query: { id: connection2.id } });
3475
+ const refreshTokenStored = current ? (await resolveSettings(current)).refreshToken || null : null;
3476
+ const rotated = error.code === "invalid_grant" && refreshTokenStored !== refreshTokenAtStart;
3477
+ if (current && !rotated) {
3478
+ const others = (current.errors || []).filter((entry) => entry.source !== OAUTH_ERROR_SOURCE);
3479
+ error.writes = [{
3480
+ collection: "connection",
3481
+ data: {
3482
+ $set: {
3483
+ errors: [
3484
+ ...others,
3485
+ {
3486
+ message: "Shopify disconnected this store. Open the Drawbridge app in your Shopify admin to reconnect.",
3487
+ source: OAUTH_ERROR_SOURCE
3488
+ }
3489
+ ],
3490
+ status: "error"
3491
+ }
3492
+ },
3493
+ operation: "update",
3494
+ query: { id: connection2.id }
3495
+ }];
3496
+ }
3497
+ }
3498
+ throw error;
3499
+ }
3500
+ },
3501
+ register: async ({ context }, { dispatch } = {}) => {
3502
+ await dispatch({ data: context, handler: "register" });
3503
+ return { message: "Ran shopify lifecycle.register", request: context || null };
3504
+ },
3505
+ rehydrate: async ({ context }, { dispatch } = {}) => {
3506
+ await dispatch({ data: context, handler: "rehydrate" });
3507
+ return { message: "Ran shopify lifecycle.rehydrate", request: context || null };
3508
+ }
3509
+ },
2450
3510
  resources: {
2451
3511
  audiences: false,
2452
3512
  // Shopify has no separate price resource — a price belongs to a product
@@ -2466,7 +3526,7 @@ var shopify_default2 = {
2466
3526
  // credential is the caller's job because it is Drawbridge's job: the
2467
3527
  // admin token refreshes and writes itself back, which is service work,
2468
3528
  // not vendor work.
2469
- products: async ({ cursor, limit = 100, search, settings, shopify, sort }) => {
3529
+ products: async ({ cursor, limit = 100, search, settings, sort }, { shopify } = {}) => {
2470
3530
  var _a, _b, _c, _d;
2471
3531
  const products = await shopify.storefront.getProducts({
2472
3532
  cursor,
@@ -2486,7 +3546,7 @@ var shopify_default2 = {
2486
3546
  }
2487
3547
  };
2488
3548
  },
2489
- promotions: async ({ cursor, limit = 100, search, settings, shopify }) => {
3549
+ promotions: async ({ cursor, limit = 100, search, settings }, { shopify } = {}) => {
2490
3550
  var _a, _b;
2491
3551
  const discounts = await shopify.admin.getDiscounts({
2492
3552
  adminAccessToken: settings == null ? void 0 : settings.adminAccessToken,
@@ -2565,9 +3625,6 @@ var shopify_default2 = {
2565
3625
  // workflow document, and those strings cannot be renamed without a backfill.
2566
3626
  //
2567
3627
  // EVERY LEAF IS A FUNCTION so a step can read the merchant's own connection.
2568
- // The bodies these point at still live in drawbridge-sync; moving them is the
2569
- // next unit, and commerce.order.record is the one that decides whether the
2570
- // shape holds — 569 lines and 15 controller calls.
2571
3628
  steps: {
2572
3629
  commerce: {
2573
3630
  code: {
@@ -2672,8 +3729,8 @@ var shopify_default2 = {
2672
3729
  title: "Shopify"
2673
3730
  };
2674
3731
 
2675
- // lib/connections/webhook.js
2676
- var import_node_crypto3 = __toESM(require("crypto"), 1);
3732
+ // lib/connections/providers/webhook.js
3733
+ var import_node_crypto4 = __toESM(require("crypto"), 1);
2677
3734
 
2678
3735
  // lib/safe-http.js
2679
3736
  var import_dns2 = __toESM(require("dns"), 1);
@@ -2810,7 +3867,7 @@ var safeRequest = async ({
2810
3867
  }
2811
3868
  };
2812
3869
 
2813
- // lib/connections/webhook.js
3870
+ // lib/connections/providers/webhook.js
2814
3871
  var webhook_default = {
2815
3872
  // Connecting GENERATES the secret rather than storing one the merchant typed,
2816
3873
  // so the buttons say what actually happens.
@@ -2897,16 +3954,15 @@ var webhook_default = {
2897
3954
  // That is the rule the whole split runs on: a hook lives in sync only if it
2898
3955
  // needs Drawbridge's own database, sockets or queues. This one does not.
2899
3956
  webhook: {
2900
- send: async ({ context, controller, request: send2 = safeRequest, settings, step }) => {
3957
+ send: async ({ context, lead, settings, step }, { request: send2 = safeRequest } = {}) => {
2901
3958
  const { headers = {}, method = "POST", url } = step.settings || {};
2902
3959
  const request2 = { method, url: url || null };
2903
3960
  if (!url) return { message: "Outgoing webhook URL is not configured for this step.", request: request2, response: { skipped: true }, skipped: true };
2904
- const lead = (context == null ? void 0 : context.lead) ? await controller.get({ collection: "lead", query: { id: context.lead } }) : null;
2905
3961
  const body = lead || context;
2906
3962
  request2.body = body;
2907
3963
  const outgoing = { ...headers };
2908
3964
  if (settings == null ? void 0 : settings.secret) {
2909
- outgoing["X-Drawbridge-Signature"] = "sha256=" + import_node_crypto3.default.createHmac("sha256", settings.secret).update(JSON.stringify(body)).digest("hex");
3965
+ outgoing["X-Drawbridge-Signature"] = "sha256=" + import_node_crypto4.default.createHmac("sha256", settings.secret).update(JSON.stringify(body)).digest("hex");
2910
3966
  }
2911
3967
  const response = await send2({ body, headers: outgoing, method, url });
2912
3968
  return { message: "Webhook POSTed to " + url + ".", request: request2, response: response || { delivered: true } };
@@ -3258,19 +4314,24 @@ var isLive = (slug, settings) => {
3258
4314
  };
3259
4315
  var mask = (value) => {
3260
4316
  if (!value) return null;
3261
- if (String(value).length < 12) return "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
4317
+ if (String(value).length < 16) return "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
3262
4318
  return String(value).slice(0, 3) + "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" + String(value).slice(-4);
3263
4319
  };
3264
- var cacheKey = (slug) => ["provider", slug];
3265
- var providerSettings = async ({ cache, controller, slug }) => {
3266
- const read = async () => controller.get({
4320
+ var providerMemo = /* @__PURE__ */ new Map();
4321
+ var MEMO_TTL_MS = 60 * 1e3;
4322
+ var clearProviderMemo = () => providerMemo.clear();
4323
+ var providerSettings = async ({ controller, slug }) => {
4324
+ const memoized = providerMemo.get(slug);
4325
+ if (memoized && Date.now() - memoized.at < MEMO_TTL_MS) return memoized.value;
4326
+ const row = await controller.get({
3267
4327
  collection: "provider",
3268
4328
  query: { slug }
3269
4329
  });
3270
- const row = cache ? await cache.use(cacheKey(slug), read, 30) : await read();
3271
- return (row == null ? void 0 : row.settings) ? decrypt(row.settings) : {};
4330
+ const value = (row == null ? void 0 : row.settings) ? decrypt(row.settings) : {};
4331
+ providerMemo.set(slug, { at: Date.now(), value });
4332
+ return value;
3272
4333
  };
3273
- var saveProviderSettings = async ({ authenticated, cache, clear, controller, settings, slug }) => {
4334
+ var saveProviderSettings = async ({ authenticated, clear, controller, settings, slug }) => {
3274
4335
  const fields2 = providerFields(slug);
3275
4336
  if (!fields2.length) return null;
3276
4337
  const existing = await controller.get({
@@ -3298,38 +4359,36 @@ var saveProviderSettings = async ({ authenticated, cache, clear, controller, set
3298
4359
  },
3299
4360
  query: { slug }
3300
4361
  });
3301
- await cache.delete(cacheKey(slug));
4362
+ providerMemo.delete(slug);
3302
4363
  return result;
3303
4364
  };
3304
4365
  var providerEnvNames = () => new Set(
3305
4366
  providerSlugs().flatMap((slug) => providerFields(slug)).map((field2) => field2.env).filter(Boolean)
3306
4367
  );
3307
- var providerCredentials = async ({ cache, controller }) => {
4368
+ var providerCredentials = async ({ controller }) => {
3308
4369
  const credentials2 = {};
3309
4370
  for (const slug of providerSlugs()) {
3310
- const settings = await providerSettings({ cache, controller, slug });
3311
- for (const field2 of providerFields(slug)) {
3312
- const value = settings == null ? void 0 : settings[field2.key];
3313
- if (field2.env && value) credentials2[field2.env] = value;
4371
+ try {
4372
+ const settings = await providerSettings({ controller, slug });
4373
+ for (const field2 of providerFields(slug)) {
4374
+ const value = settings == null ? void 0 : settings[field2.key];
4375
+ if (field2.env && value) credentials2[field2.env] = value;
4376
+ }
4377
+ } catch {
4378
+ continue;
3314
4379
  }
3315
4380
  }
3316
4381
  return credentials2;
3317
4382
  };
3318
- var hydrateEnvironment = async ({ cache, controller, env = process.env }) => {
3319
- const credentials2 = await providerCredentials({ cache, controller });
3320
- for (const name of providerEnvNames()) delete env[name];
3321
- Object.assign(env, credentials2);
3322
- return Object.keys(credentials2).sort();
3323
- };
3324
4383
  // Annotate the CommonJS export names for ESM import in node:
3325
4384
  0 && (module.exports = {
3326
- cacheKey,
3327
- hydrateEnvironment,
4385
+ clearProviderMemo,
3328
4386
  isLive,
3329
4387
  mask,
3330
4388
  providerCredentials,
3331
4389
  providerEnvNames,
3332
4390
  providerFields,
4391
+ providerMemo,
3333
4392
  providerSettings,
3334
4393
  providerSlugs,
3335
4394
  saveProviderSettings