@drawbridge/drawbridge-utils 0.0.167 → 0.0.169

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/providers.js CHANGED
@@ -129,7 +129,20 @@ var HOOKS = Object.freeze({
129
129
  "digest"
130
130
  ]),
131
131
  sms: Object.freeze(["send"]),
132
- segment: Object.freeze(["sync"]),
132
+ segment: Object.freeze([
133
+ // Make this segment's object exist at the vendor, carrying the segment's
134
+ // current title, and describe the row that points at it. IDEMPOTENT: the
135
+ // same call creates it, renames it after an edit, and backfills a segment
136
+ // that predates the connection — so one hook serves every path and there
137
+ // is no create-vs-update branch to keep in step.
138
+ "register",
139
+ // Remove the vendor object this connection's row points at. Called with
140
+ // the pre-image on a segment delete, because by then the document is gone.
141
+ "remove",
142
+ // Recalculate Drawbridge-side membership. Private to the drawbridge
143
+ // manifest; a vendor does not own who is in a Drawbridge segment.
144
+ "sync"
145
+ ]),
133
146
  // OUTBOUND DELIVERY to an address the merchant owns, rather than to a vendor.
134
147
  // The Webhooks connection is the only thing here with no third party behind
135
148
  // it, and the destination is per STEP rather than per connection.
@@ -238,6 +251,8 @@ var STEPS = Object.freeze({
238
251
  "email.digest": "Digest",
239
252
  "email.notify": "Notification",
240
253
  "email.send": "Send email",
254
+ "segment.register": "Register segment",
255
+ "segment.remove": "Remove segment",
241
256
  "segment.sync": "Sync segment",
242
257
  "sms.send": "Send SMS",
243
258
  "webhook.send": "Send webhook"
@@ -373,7 +388,16 @@ var tokenSettings = ({ existing = {}, now = Date.now(), tokens }) => ({
373
388
  ...tokens.expiresIn && {
374
389
  expiresAt: new Date(now + tokens.expiresIn * 1e3).toISOString()
375
390
  },
376
- ...tokens.scope && { scope: tokens.scope }
391
+ // A VENDOR NEED NOT RETURN `scope` ON A REFRESH. Klaviyo documents it on the
392
+ // authorization_code response and documents no response body at all for the
393
+ // refresh grant, so taking the minted value alone drops the stored one. That
394
+ // matters because `scope` is load-bearing: the segment hooks gate on
395
+ // `segments:write` and answer `skipped` when it is absent, so a connection
396
+ // that dropped it disables its whole segment half without failing anything
397
+ // and shows a reconnect task that reconnecting has already fixed.
398
+ ...(tokens.scope || existing.scope) && {
399
+ scope: tokens.scope || existing.scope
400
+ }
377
401
  });
378
402
  var accessToken = async ({
379
403
  clientId,
@@ -436,6 +460,98 @@ var detectCountry = (value) => {
436
460
  }
437
461
  };
438
462
 
463
+ // lib/connections/segment-rows.js
464
+ import { randomUUID } from "crypto";
465
+ var row = ({ connection: connection2, data: data2, manifest, row: described }) => {
466
+ var _a, _b;
467
+ return {
468
+ connection: connection2 == null ? void 0 : connection2.id,
469
+ // ALWAYS A STRING. Mailchimp tag ids are integers, Klaviyo segment ids are
470
+ // strings, and one type in the schema is one comparison in the $or below.
471
+ id: String(described == null ? void 0 : described.id),
472
+ slug: connection2 == null ? void 0 : connection2.slug,
473
+ type: described == null ? void 0 : described.type,
474
+ // NULL, NEVER UNDEFINED: undefined drops the key, and the schema requires it.
475
+ // The url is built HERE, while the settings are decrypted and the vendor
476
+ // facts are in hand — an api reading the row later has neither.
477
+ url: ((_b = (_a = manifest == null ? void 0 : manifest.urls) == null ? void 0 : _a.segment) == null ? void 0 : _b.call(_a, { ...described, id: String(described == null ? void 0 : described.id) }, data2)) || null
478
+ };
479
+ };
480
+ var segmentRowWrites = ({ connection: connection2, data: data2, manifest, row: described, segment }) => {
481
+ const built = row({ connection: connection2, data: data2, manifest, row: described });
482
+ return [
483
+ // PUSH IF ABSENT. The $ne guard is what makes a second concurrent register
484
+ // add nothing rather than a duplicate row for one connection.
485
+ {
486
+ collection: "segment",
487
+ data: { $push: { connections: built } },
488
+ operation: "update",
489
+ query: {
490
+ id: segment == null ? void 0 : segment.id,
491
+ "connections.connection": { $ne: connection2 == null ? void 0 : connection2.id }
492
+ }
493
+ },
494
+ // SET IF DIFFERENT. $elemMatch selects this connection's row only when one
495
+ // of its three mutable fields disagrees, so the steady state — the same
496
+ // vendor object, the same url — matches nothing and writes nothing.
497
+ {
498
+ collection: "segment",
499
+ data: { $set: { "connections.$": built } },
500
+ operation: "update",
501
+ query: {
502
+ id: segment == null ? void 0 : segment.id,
503
+ connections: {
504
+ $elemMatch: {
505
+ connection: connection2 == null ? void 0 : connection2.id,
506
+ $or: [
507
+ { id: { $ne: built.id } },
508
+ { type: { $ne: built.type } },
509
+ { url: { $ne: built.url } }
510
+ ]
511
+ }
512
+ }
513
+ }
514
+ }
515
+ ];
516
+ };
517
+ var segmentRowRemoveWrites = ({ connection: connection2, segment }) => [
518
+ {
519
+ collection: "segment",
520
+ data: { $pull: { connections: { connection: connection2 == null ? void 0 : connection2.id } } },
521
+ operation: "update",
522
+ query: { id: segment == null ? void 0 : segment.id }
523
+ }
524
+ ];
525
+ var segmentRowFor = ({ connection: connection2, segment }) => ((segment == null ? void 0 : segment.connections) || []).find((entry) => (entry == null ? void 0 : entry.connection) === (connection2 == null ? void 0 : connection2.id));
526
+ var currentSegment = async ({ read, segment }) => {
527
+ if (!(read == null ? void 0 : read.get) || !(segment == null ? void 0 : segment.id)) return segment;
528
+ return read.get({
529
+ collection: "segment",
530
+ query: { id: segment.id }
531
+ });
532
+ };
533
+ var driftEnqueues = async ({ applied, read, segment, workflow }) => {
534
+ if (!(workflow == null ? void 0 : workflow.id)) return [];
535
+ const fresh = await currentSegment({ read, segment });
536
+ if (!(fresh == null ? void 0 : fresh.id) || fresh.title === applied) return [];
537
+ return [{
538
+ data: {
539
+ triggerData: {
540
+ organization: fresh.organization || (workflow == null ? void 0 : workflow.organization),
541
+ segment: fresh
542
+ },
543
+ workflowId: workflow == null ? void 0 : workflow.id
544
+ },
545
+ name: "execute",
546
+ options: {
547
+ jobId: "workflow.insert.execute." + (workflow == null ? void 0 : workflow.id) + ".segment.register." + fresh.id + ".drift." + Date.now() + "." + randomUUID().slice(0, 8),
548
+ removeOnComplete: true,
549
+ removeOnFail: true
550
+ },
551
+ queue: "workflow"
552
+ }];
553
+ };
554
+
439
555
  // lib/connections/providers/attentive.js
440
556
  var api = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
441
557
  const response = await fetcher("https://api.attentivemobile.com" + path, {
@@ -488,7 +604,7 @@ var attentive_default2 = {
488
604
  // has to say so rather than let them believe otherwise.
489
605
  confirm: "Disconnecting removes Drawbridge's stored Attentive token. Attentive does not offer a way for us to revoke it, so remove the Drawbridge integration in Attentive as well if you want its access fully withdrawn. Your subscribers stay in both Attentive and Drawbridge \u2014 neither list is deleted.",
490
606
  description: [
491
- "Attentive is where your SMS marketing lives, and this connection syncs the contacts your campaigns collect into an Attentive segment \u2014 subscribed for marketing and added to the segment you choose.",
607
+ "This connection syncs the contacts your campaigns collect into your Attentive account \u2014 subscribed for marketing, and added to the segment you choose.",
492
608
  "You authorize Drawbridge from inside Attentive and can revoke that access there at any time. Drawbridge never sees or stores your Attentive password.",
493
609
  "Anyone who has opted out in Drawbridge is sent to Attentive as an unsubscribe rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.",
494
610
  "Attentive accepts these updates and applies them in the background, so a contact appears in your segment shortly after the sync rather than the instant it runs."
@@ -530,10 +646,9 @@ var attentive_default2 = {
530
646
  }
531
647
  ],
532
648
  group: "contacts",
533
- // A HOOK'S VALUE IS ITS ANSWER. The consent is stored and can be revoked;
534
- // nothing else is built yet, because subscriber sync has not shipped. Every
535
- // false here is "not yet" rather than "never" — when the sync lands, probe
536
- // and contacts.sync are the first to flip.
649
+ // WHAT THIS VENDOR DOES AND DOES NOT DO is the value of each hook below, not
650
+ // a paragraph up here that goes stale the moment one of them is implemented
651
+ // which is exactly what happened to the note this replaces.
537
652
  hooks: {
538
653
  auth: {
539
654
  // FALSE, NOT {}. `{}` means "supported, implemented in the repo with the
@@ -546,7 +661,7 @@ var attentive_default2 = {
546
661
  // nobody re-derives it. Klaviyo's connect reads the account name back so
547
662
  // the card is not blank; Attentive's card stays blank. There IS an
548
663
  // endpoint — GET https://api.attentivemobile.com/v1/me, Bearer, described
549
- // on docs.attentive.com/pages/authentication/ as returning "information
664
+ // on docs.attentive.com/docs/authentication as returning "information
550
665
  // specific to your company" — but its RESPONSE SCHEMA is published
551
666
  // nowhere we can read: the docs show the curl and no body. Reading
552
667
  // `body.name` would be a guess, and a guess here fails at the worst
@@ -713,7 +828,62 @@ var attentive_default2 = {
713
828
  products: false,
714
829
  promotions: false
715
830
  },
716
- segment: false,
831
+ segment: {
832
+ // A FOUNDATION, AND HONEST ABOUT IT. Attentive's segments API can create
833
+ // one with an externalId we choose (docs.attentive.com/reference/
834
+ // createsegment.md, fetched 2026-09-11: POST /v2/segments, `name`
835
+ // required, `externalId` optional and "auto-generated if not supplied"),
836
+ // which would give a real per-segment object — but it takes
837
+ // segments:write, and scopes ride on the app registration, which does not
838
+ // exist yet.
839
+ //
840
+ // So the row points at the connection-level segment the merchant chose,
841
+ // `type` says so, and turning this into a per-segment object later is a
842
+ // change to this file and nothing else: create with
843
+ // externalId = segment.id, PATCH to rename, archive on remove. Their
844
+ // update and archive endpoints are BOTH keyed by external id
845
+ // (docs.attentive.com/reference/patchsegmentbyexternalid.md and
846
+ // /deletesegmentbyexternalid.md, fetched 2026-09-11), so the segment id we
847
+ // already hold addresses every one of the three calls.
848
+ //
849
+ // NO DRIFT CHECK, unlike the other two: the row points at the
850
+ // connection's own segment and the link is the index page, so nothing
851
+ // here depends on the Drawbridge segment's title — a rename has nothing
852
+ // to apply and nothing to race with. That comes back with the
853
+ // per-segment object.
854
+ //
855
+ // THE RE-READ STAYS ALL THE SAME. It changes nothing today, and this is
856
+ // the simplest of the three registers and therefore the one the next
857
+ // vendor gets copied from — one job id serves four dispatch sites, so a
858
+ // copy that trusts context.segment applies whichever trigger data won
859
+ // the race, at a vendor where the title does matter.
860
+ register: async ({ connection: connection2, context, manifest, settings }, { read } = {}) => {
861
+ const segment = await currentSegment({ read, segment: context == null ? void 0 : context.segment });
862
+ if (!(segment == null ? void 0 : segment.id) || segment.system) return { message: "That segment is not one this connection publishes.", skipped: true };
863
+ if (!(settings == null ? void 0 : settings.segment)) return { message: "No Attentive segment is chosen for this connection.", skipped: true };
864
+ return {
865
+ events: [{
866
+ event: "organization.segments",
867
+ payload: { id: segment.id },
868
+ room: "organization." + (connection2 == null ? void 0 : connection2.organization)
869
+ }],
870
+ message: "Contacts in this segment are added to the Attentive segment chosen on this connection.",
871
+ writes: segmentRowWrites({
872
+ connection: connection2,
873
+ data: { ...connection2, settings },
874
+ manifest,
875
+ row: { id: settings.segment, type: "segment" },
876
+ segment
877
+ })
878
+ };
879
+ },
880
+ // NOT OURS TO DELETE. The segment on this connection is the merchant's,
881
+ // and it is where every Drawbridge segment's contacts go — removing it
882
+ // because one Drawbridge segment was deleted would empty the others.
883
+ remove: false,
884
+ // Drawbridge-side membership belongs to the private manifest.
885
+ sync: false
886
+ },
717
887
  sms: false,
718
888
  webhook: false
719
889
  },
@@ -742,6 +912,21 @@ var attentive_default2 = {
742
912
  "ATTENTIVE_OAUTH_CLIENT_ID",
743
913
  "ATTENTIVE_OAUTH_CLIENT_SECRET"
744
914
  ],
915
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
916
+ review: {
917
+ api: "https://docs.attentive.com/reference/listsegments",
918
+ dashboard: "https://docs.attentive.com/docs/segments",
919
+ // THIS PAGE DOES NOT LIST EVERY SCOPE. Its table names five —
920
+ // events:write, ecommerce:write, subscriptions:write, attributes:write,
921
+ // privacy_requests:write — and says nothing about segments:read or
922
+ // segments:write, which the segments API this manifest calls does take.
923
+ // The header at the top of this file carries that distinction; it is
924
+ // repeated here so a reviewer following the link is not misled by what the
925
+ // table omits (fetched 2026-09-11).
926
+ scopes: "https://docs.attentive.com/docs/authentication",
927
+ content: "2026-09-11",
928
+ verified: null
929
+ },
745
930
  slug: "attentive",
746
931
  // A consent with no segment chosen is authenticated and inert — the sync needs
747
932
  // somewhere to put people — so the card says Pending rather than Active over
@@ -779,6 +964,24 @@ var attentive_default2 = {
779
964
  triggers: ["lead.insert", "segment.contact.add"],
780
965
  usage: { actions: 1 }
781
966
  })
967
+ },
968
+ segment: {
969
+ // SYSTEM, so the builder never offers it and a merchant POST refuses it:
970
+ // this fires from the segment's own lifecycle, not from a workflow
971
+ // somebody assembled. The trigger is declared here rather than hard-coded
972
+ // in drawbridge-sync.
973
+ //
974
+ // REGISTER ONLY. There is no remove step because hooks.segment.remove is
975
+ // declined, and build() refuses a step pointing at a hook this vendor does
976
+ // not implement — so the two are one decision, enforced at import.
977
+ register: () => ({
978
+ description: "Records which Attentive segment a Drawbridge segment's contacts are added to.",
979
+ hook: "segment.register",
980
+ key: "Attentive Segment Register",
981
+ queue: "connection",
982
+ system: true,
983
+ trigger: { event: "segment.register", type: "event" }
984
+ })
782
985
  }
783
986
  },
784
987
  // WHY, in the merchant's words, and what to do about it.
@@ -795,7 +998,21 @@ var attentive_default2 = {
795
998
  }
796
999
  ];
797
1000
  },
798
- title: "Attentive"
1001
+ title: "Attentive",
1002
+ // ATTENTIVE'S SEGMENTS INDEX. There is no documented per-segment url, and the
1003
+ // only identifier we hold is the API's externalId, which their UI may not
1004
+ // path by — so this lands on the list, where the merchant finds it by name.
1005
+ // A per-segment link arrives with the per-segment object (see hooks.segment).
1006
+ //
1007
+ // THE PATH ITSELF IS NOT PUBLISHED ANYWHERE CITABLE, the same gap Klaviyo's
1008
+ // segment url carries. What is on record is that the segments area lives at
1009
+ // ui.attentivemobile.com/segments — its /segments/create/ and /segments/manual
1010
+ // sub-routes are publicly indexed pages (fetched 2026-09-11) — while the /all
1011
+ // tab is not, and Attentive's help centre refuses automated fetches. The dev
1012
+ // walk-through confirms this against a real account before promote.
1013
+ urls: {
1014
+ segment: () => "https://ui.attentivemobile.com/segments/all"
1015
+ }
799
1016
  };
800
1017
 
801
1018
  // lib/connections/providers/drawbridge.js
@@ -1474,10 +1691,10 @@ var free = {
1474
1691
  };
1475
1692
  var plans = {
1476
1693
  DB00002: {
1477
- // A verified sending domain is a PAID capability: free plans cannot send
1478
- // lead-facing email at all (the send path gates on an active
1479
- // subscription), so granting it there would offer a domain that can
1480
- // never send from.
1694
+ // A verified sending domain is a PAID capability. Every plan sends
1695
+ // lead-facing email from the platform address — the send is billed as an
1696
+ // action, so the allowance is the entitlement and sending from your own
1697
+ // domain is what the paid tiers add on top.
1481
1698
  features: all.features([organization.networking.key, organization.members.key]),
1482
1699
  limits: all.limits({ actions: 5e3, members: 3, storage: gigabyte * 10 }),
1483
1700
  marketing: {
@@ -1915,7 +2132,7 @@ var drawbridge_default2 = {
1915
2132
  content: {
1916
2133
  confirm: "This connection is part of Drawbridge and cannot be disconnected.",
1917
2134
  description: [
1918
- "Drawbridge sends your notification email and SMS, keeps your segments in sync, and posts to your own endpoints. These are built in rather than connected, so there is nothing here to set up."
2135
+ "Drawbridge sends your notification emails and your entrants' emails, and recalculates segment membership on a schedule. It is always on and needs nothing connected."
1919
2136
  ],
1920
2137
  excerpt: "The steps Drawbridge runs itself.",
1921
2138
  guide: [
@@ -2081,16 +2298,6 @@ var drawbridge_default2 = {
2081
2298
  const request2 = { to };
2082
2299
  const { ok: sendable } = await canSend({ channel: "email", to });
2083
2300
  if (!sendable) return { message: "Recipient has opted out \u2014 skipped.", request: request2, response: { skipped: true }, skipped: true };
2084
- const organization2 = await read.get({ collection: "organization", query: { id: workflow.organization } });
2085
- const subscription = (organization2 == null ? void 0 : organization2.subscription) ? await read.get({ collection: "subscription", query: { id: organization2.subscription } }) : null;
2086
- if ((subscription == null ? void 0 : subscription.status) !== "active") {
2087
- return {
2088
- message: "Organization has no active subscription \u2014 workflow-step email skipped.",
2089
- request: request2,
2090
- response: { skipped: true },
2091
- skipped: true
2092
- };
2093
- }
2094
2301
  return {
2095
2302
  message: "Email queued for delivery to " + to + ".",
2096
2303
  request: request2,
@@ -2238,6 +2445,11 @@ var drawbridge_default2 = {
2238
2445
  promotions: false
2239
2446
  },
2240
2447
  segment: {
2448
+ // NOT DRAWBRIDGE'S. Registering a segment means creating an object at a
2449
+ // vendor, and this manifest has no vendor behind it — the three that do
2450
+ // implement these.
2451
+ register: false,
2452
+ remove: false,
2241
2453
  // RECALCULATE SEGMENT MEMBERSHIP. The one FAN-OUT hook: it evaluates every
2242
2454
  // contact in an organization against every segment, which is too much for
2243
2455
  // one job, so it returns chunks and the shell defers completion.
@@ -2513,6 +2725,33 @@ var drawbridge_default2 = {
2513
2725
  // caught exactly that: availableConnections({}) went from [ 'drawbridge' ] to
2514
2726
  // empty the moment this was added.
2515
2727
  requires: [],
2728
+ // PRIVATE, NOT VENDORLESS. SendGrid, Twilio and HubSpot are behind this
2729
+ // manifest, so `false` would be a lie about which reads were made.
2730
+ //
2731
+ // ONE ENTRY PER VENDOR, because three vendors are three reads. A single
2732
+ // citation here would evidence one of them and read as though it covered all
2733
+ // three, which is the omission this key exists to catch.
2734
+ review: {
2735
+ api: {
2736
+ // lib/hubspot.js posts to /crm/v3/objects/contacts.
2737
+ hubspot: "https://developers.hubspot.com/docs/reference/api/crm/objects/contacts",
2738
+ // lib/sendgrid.js posts to /v3/mail/send.
2739
+ sendgrid: "https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send",
2740
+ // lib/twilio.js posts to /2010-04-01/Accounts/{Sid}/Messages.json, and
2741
+ // hooks.inbound.verify reads the MessageStatus this resource documents.
2742
+ twilio: "https://www.twilio.com/docs/messaging/api/message-resource"
2743
+ },
2744
+ dashboard: {
2745
+ hubspot: "https://knowledge.hubspot.com/contacts/create-contacts",
2746
+ sendgrid: "https://www.twilio.com/docs/sendgrid/ui/analytics-and-reporting/email-activity-feed",
2747
+ twilio: "https://www.twilio.com/docs/messaging/guides/debugging-tools"
2748
+ },
2749
+ // An admin types these keys in; there is no merchant consent and no scope
2750
+ // model on any of the three.
2751
+ scopes: false,
2752
+ content: "2026-09-11",
2753
+ verified: null
2754
+ },
2516
2755
  slug: "drawbridge",
2517
2756
  // Always on. There is no credential that could go bad and no configuration a
2518
2757
  // merchant could leave half-finished.
@@ -2704,6 +2943,8 @@ var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) =>
2704
2943
  }
2705
2944
  return response.status === 204 ? null : response.json();
2706
2945
  };
2946
+ var segmentName = (title) => "Drawbridge: " + title;
2947
+ var canManageSegments = (settings) => String((settings == null ? void 0 : settings.scope) || "").split(/\s+/).includes("segments:write");
2707
2948
  var klaviyo_default2 = {
2708
2949
  // OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
2709
2950
  // exchange without a code_verifier matching the challenge the consent
@@ -2733,9 +2974,21 @@ var klaviyo_default2 = {
2733
2974
  // exchange, and a copy here would be a second answer that goes stale.
2734
2975
  expiry: 90 * 24 * 60 * 60,
2735
2976
  pkce: true,
2977
+ // EVERY SCOPE THE MANIFEST'S HOOKS NEED, not the ones today's hooks use.
2978
+ // Klaviyo holds scopes on the APP — "Pinpoint which scopes your app uses
2979
+ // and set them using a space-separated list"
2980
+ // (developers.klaviyo.com/en/docs/create_a_public_oauth_app, fetched
2981
+ // 2026-09-11) — and a merchant's token only ever carries what they
2982
+ // consented to, so a scope added later is a reconnect for every one of
2983
+ // them. That is what segments cost when they were left out here.
2984
+ //
2736
2985
  // Space separated. accounts:read is required by Klaviyo on every app
2737
- // and must stay in the list; the rest are what a contact sync needs.
2738
- scopes: "accounts:read lists:read lists:write profiles:read profiles:write",
2986
+ // and must stay in the list; the rest are what a contact sync and the
2987
+ // segment hooks need — Get Segments lists `segments:read`, Create,
2988
+ // Update and Delete Segment each list `segments:write`
2989
+ // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
2990
+ // revision 2026-07-15, fetched 2026-09-11).
2991
+ scopes: "accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write",
2739
2992
  // EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
2740
2993
  // the disconnect hook — three vendor addresses, two of them declared,
2741
2994
  // which is exactly the kind of split that goes unnoticed.
@@ -2784,9 +3037,10 @@ var klaviyo_default2 = {
2784
3037
  // Shown at disconnect, so it says what is lost and what is not.
2785
3038
  confirm: "Disconnecting revokes Drawbridge's access to your Klaviyo account. Your profiles and lists stay in both Klaviyo and Drawbridge \u2014 neither is deleted.",
2786
3039
  description: [
2787
- "Connecting Klaviyo lets Drawbridge sync the contacts your campaigns collect into a Klaviyo list, so the people who enter a giveaway can be marketed to alongside the rest of your audience.",
3040
+ "Connecting Klaviyo lets Drawbridge sync the contacts your campaigns collect into a Klaviyo list, so you can market to the people who enter a giveaway alongside the rest of your audience.",
2788
3041
  "You authorize Drawbridge from inside Klaviyo and can revoke that access there at any time. Drawbridge never sees or stores your Klaviyo password, and only asks for the permissions listed on the consent screen.",
2789
- "Anyone who has opted out in Drawbridge is synced as unsubscribed rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing."
3042
+ "Anyone who has opted out in Drawbridge is synced as unsubscribed rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.",
3043
+ "Drawbridge writes its own properties onto the profiles it syncs: how many of your campaigns someone entered, their entries, draws and orders, the revenue their orders attributed to your campaigns, and which Drawbridge segments they are in. You can build Klaviyo segments and flows on any of them."
2790
3044
  ],
2791
3045
  // KEYED BY WHAT FAILED, not nested inside it. Errors are the thing most
2792
3046
  // likely to grow — resources.* has already earned somewhere to put "we
@@ -2981,7 +3235,14 @@ var klaviyo_default2 = {
2981
3235
  // `segments` is null when the run carried no contact document,
2982
3236
  // meaning nobody looked — different from [], which means they
2983
3237
  // are in none. Null omits the key and merge leaves it alone.
2984
- ...segments && { drawbridge_segments: segments.map((entry) => entry.title).filter(Boolean) }
3238
+ ...segments && { drawbridge_segments: segments.map((entry) => entry.title).filter(Boolean) },
3239
+ // THE IDS, which is what a Drawbridge-made segment's definition
3240
+ // filters on. Ids rather than titles, so renaming a segment is a
3241
+ // name change at Klaviyo and not a resync of every profile.
3242
+ //
3243
+ // The titles stay beside them: merchants have been building
3244
+ // their own segments on that array since it shipped.
3245
+ ...segments && { drawbridge_segment_ids: segments.map((entry) => entry.id).filter(Boolean) }
2985
3246
  }
2986
3247
  },
2987
3248
  type: "profile"
@@ -3025,17 +3286,141 @@ var klaviyo_default2 = {
3025
3286
  };
3026
3287
  }
3027
3288
  },
3028
- // A WHOLE DOMAIN CAN DECLINE AT ONCE. Klaviyo sends us nothing and we
3029
- // register nothing with it, so listing four falses would be noise around a
3030
- // single decision. Still explicit absence would not say whether anybody
3031
- // considered it.
3032
- // Drawbridge sends its own notification email and SMS, and owns its own
3033
- // segments — see the private `drawbridge` manifest. A vendor answering
3034
- // these would be a second sender, which is the arrangement the platform
3035
- // sender replaced.
3289
+ // Drawbridge sends its own notification email. A vendor answering this
3290
+ // would be a second sender, which is the arrangement the platform sender
3291
+ // replaced. Declined as one line rather than one per verb, because the whole
3292
+ // domain is one decision — still explicit, since absence would not say
3293
+ // whether anybody considered it.
3036
3294
  email: false,
3037
- segment: false,
3295
+ // SEGMENTS ARE SHARED, not owned by one side. Drawbridge holds the
3296
+ // membership — see the private `drawbridge` manifest, and `sync : false`
3297
+ // below — while register and remove keep a Klaviyo segment standing for
3298
+ // each Drawbridge segment, so the merchant can target one in their own
3299
+ // flows.
3300
+ segment: {
3301
+ // THE KLAVIYO SEGMENT THIS DRAWBRIDGE SEGMENT BECOMES.
3302
+ //
3303
+ // Klaviyo owns no writable membership — its segments are computed from
3304
+ // rules — so the segment we create is DEFINED BY the profile property
3305
+ // contacts.sync writes. The definition filters on the Drawbridge
3306
+ // segment's ID, never its title, which is what makes a rename one PATCH
3307
+ // instead of a resync of every profile in it.
3308
+ register: async ({ connection: connection2, context, manifest, settings, token, workflow }, { fetcher, read } = {}) => {
3309
+ var _a, _b, _c, _d, _e;
3310
+ const segment = await currentSegment({ read, segment: context == null ? void 0 : context.segment });
3311
+ if (!(segment == null ? void 0 : segment.id) || segment.system) return { message: "That segment is not one this connection publishes.", skipped: true };
3312
+ if (!canManageSegments(settings)) {
3313
+ return {
3314
+ message: "Reconnect Klaviyo to let Drawbridge manage segments \u2014 this connection was made before that permission was asked for.",
3315
+ skipped: true
3316
+ };
3317
+ }
3318
+ const name = segmentName(segment.title);
3319
+ const existing = segmentRowFor({ connection: connection2, segment });
3320
+ let id = null;
3321
+ if (existing == null ? void 0 : existing.id) {
3322
+ try {
3323
+ const found = await api2("/segments/" + existing.id, { fetcher, token });
3324
+ id = ((_a = found == null ? void 0 : found.data) == null ? void 0 : _a.id) ?? existing.id;
3325
+ if (((_c = (_b = found == null ? void 0 : found.data) == null ? void 0 : _b.attributes) == null ? void 0 : _c.name) !== name) {
3326
+ await api2("/segments/" + existing.id, {
3327
+ fetcher,
3328
+ method: "PATCH",
3329
+ payload: { data: { attributes: { name }, id: existing.id, type: "segment" } },
3330
+ token
3331
+ });
3332
+ }
3333
+ } catch (error) {
3334
+ if (error.status !== 404) throw error;
3335
+ id = null;
3336
+ }
3337
+ }
3338
+ if (!id) {
3339
+ const search = await api2("/segments?filter=" + encodeURIComponent('equals(name,"' + name.replace(/"/g, '\\"') + '")'), { fetcher, token });
3340
+ id = ((_d = ((search == null ? void 0 : search.data) || []).find((entry) => {
3341
+ var _a2;
3342
+ return ((_a2 = entry == null ? void 0 : entry.attributes) == null ? void 0 : _a2.name) === name;
3343
+ })) == null ? void 0 : _d.id) ?? null;
3344
+ }
3345
+ if (!id) {
3346
+ const created = await api2("/segments", {
3347
+ fetcher,
3348
+ method: "POST",
3349
+ // THE DEFINITION IS THE MEMBERSHIP. Create Segment requires one
3350
+ // — `name` and `definition` are both required on its attributes
3351
+ // — and a custom profile property is addressed as
3352
+ // "properties['property name']", tested with a list filter whose
3353
+ // operator is `contains`
3354
+ // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
3355
+ // revision 2026-07-15, fetched 2026-09-11).
3356
+ payload: {
3357
+ data: {
3358
+ attributes: {
3359
+ definition: {
3360
+ condition_groups: [{
3361
+ conditions: [{
3362
+ filter: { operator: "contains", type: "list", value: segment.id },
3363
+ property: "properties['drawbridge_segment_ids']",
3364
+ type: "profile-property"
3365
+ }]
3366
+ }]
3367
+ },
3368
+ name
3369
+ },
3370
+ type: "segment"
3371
+ }
3372
+ },
3373
+ token
3374
+ });
3375
+ id = (_e = created == null ? void 0 : created.data) == null ? void 0 : _e.id;
3376
+ }
3377
+ if (!id) return { message: "Klaviyo returned no segment id.", skipped: true };
3378
+ return {
3379
+ // A RENAME THAT ARRIVED WHILE THIS RAN was dropped by the
3380
+ // coalescing job id, so the last thing this does is look again.
3381
+ enqueues: await driftEnqueues({ applied: segment.title, read, segment, workflow }),
3382
+ events: [{
3383
+ event: "organization.segments",
3384
+ payload: { id: segment.id },
3385
+ room: "organization." + (connection2 == null ? void 0 : connection2.organization)
3386
+ }],
3387
+ message: 'Klaviyo is carrying this segment as "' + name + '".',
3388
+ writes: segmentRowWrites({
3389
+ connection: connection2,
3390
+ data: { ...connection2, settings },
3391
+ manifest,
3392
+ row: { id, type: "segment" },
3393
+ segment
3394
+ })
3395
+ };
3396
+ },
3397
+ // NO RE-READ. The segment is already deleted; the pre-image is the only
3398
+ // copy, and it carries the row naming what to delete.
3399
+ remove: async ({ connection: connection2, context, settings, token }, { fetcher } = {}) => {
3400
+ const segment = context == null ? void 0 : context.segment;
3401
+ const existing = segmentRowFor({ connection: connection2, segment });
3402
+ if (!(existing == null ? void 0 : existing.id)) return { message: "Klaviyo was never carrying this segment.", skipped: true };
3403
+ if (!canManageSegments(settings)) {
3404
+ return { message: "Reconnect Klaviyo to let Drawbridge manage segments.", skipped: true };
3405
+ }
3406
+ try {
3407
+ await api2("/segments/" + existing.id, { fetcher, method: "DELETE", token });
3408
+ } catch (error) {
3409
+ if (error.status !== 404) throw error;
3410
+ }
3411
+ return {
3412
+ message: "Klaviyo is no longer carrying this segment.",
3413
+ writes: segmentRowRemoveWrites({ connection: connection2, segment })
3414
+ };
3415
+ },
3416
+ // Drawbridge-side membership belongs to the private manifest.
3417
+ sync: false
3418
+ },
3419
+ // Declined for the same reason as `email` above: Drawbridge sends its own
3420
+ // notification SMS, and a vendor answering this would be a second sender.
3038
3421
  sms: false,
3422
+ // Klaviyo sends us nothing — no inbound message to receive, no signature
3423
+ // to verify.
3039
3424
  inbound: false,
3040
3425
  // Nothing to set up or tear down at the vendor: the grant is the whole
3041
3426
  // integration. What CAN rot is the grant itself, so health is the one
@@ -3156,6 +3541,18 @@ var klaviyo_default2 = {
3156
3541
  "KLAVIYO_OAUTH_CLIENT_ID",
3157
3542
  "KLAVIYO_OAUTH_CLIENT_SECRET"
3158
3543
  ],
3544
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
3545
+ review: {
3546
+ api: "https://developers.klaviyo.com/en/reference/api_overview",
3547
+ dashboard: "https://help.klaviyo.com/hc/en-us/articles/115005078647",
3548
+ // THE SCOPE TABLE, not the OAuth walk-through. set_up_oauth carries one
3549
+ // example scope string and nothing to check a manifest against; this page
3550
+ // lists the scopes each API takes, segments:read and segments:write among
3551
+ // them (fetched 2026-09-11).
3552
+ scopes: "https://developers.klaviyo.com/en/docs/authenticate_",
3553
+ content: "2026-09-11",
3554
+ verified: null
3555
+ },
3159
3556
  slug: "klaviyo",
3160
3557
  // ONE OF THE FOUR STATES AND NOTHING ELSE — the reason sits in `tasks`, which
3161
3558
  // is already the merchant-facing copy channel and is already rendered.
@@ -3186,7 +3583,8 @@ var klaviyo_default2 = {
3186
3583
  hook: "lifecycle.health",
3187
3584
  key: "Klaviyo Connection Health",
3188
3585
  queue: "connection",
3189
- system: true
3586
+ system: true,
3587
+ trigger: { event: "day", type: "schedule" }
3190
3588
  })
3191
3589
  }
3192
3590
  },
@@ -3230,6 +3628,28 @@ var klaviyo_default2 = {
3230
3628
  usage: { actions: 1 }
3231
3629
  };
3232
3630
  }
3631
+ },
3632
+ segment: {
3633
+ // SYSTEM, so the builder never offers it and a merchant POST refuses it:
3634
+ // these fire from the segment's own lifecycle, not from a workflow
3635
+ // somebody assembled. The trigger is declared here rather than hard-coded
3636
+ // in drawbridge-sync.
3637
+ register: () => ({
3638
+ description: "Keeps a matching Klaviyo segment for each Drawbridge segment, built on the segment ids Drawbridge writes onto your profiles.",
3639
+ hook: "segment.register",
3640
+ key: "Klaviyo Segment Register",
3641
+ queue: "connection",
3642
+ system: true,
3643
+ trigger: { event: "segment.register", type: "event" }
3644
+ }),
3645
+ remove: () => ({
3646
+ description: "Deletes the Klaviyo segment for a Drawbridge segment when the segment is deleted.",
3647
+ hook: "segment.remove",
3648
+ key: "Klaviyo Segment Remove",
3649
+ queue: "connection",
3650
+ system: true,
3651
+ trigger: { event: "segment.remove", type: "event" }
3652
+ })
3233
3653
  }
3234
3654
  },
3235
3655
  // WHY, in the merchant's words, and what to do about it.
@@ -3239,14 +3659,32 @@ var klaviyo_default2 = {
3239
3659
  // moment: the grant is good and the list is the missing half.
3240
3660
  tasks: (data2) => {
3241
3661
  var _a;
3242
- return !["active", "pending"].includes(data2 == null ? void 0 : data2.status) || ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? [] : [
3243
- {
3662
+ if (!["active", "pending"].includes(data2 == null ? void 0 : data2.status)) return [];
3663
+ return [
3664
+ // A connection made before segments were requested is authenticated and
3665
+ // cannot manage them, and no error surfaces anywhere else — the register
3666
+ // runs skip rather than fail.
3667
+ ...canManageSegments(data2 == null ? void 0 : data2.settings) ? [] : [{
3668
+ message: "Drawbridge now keeps a Klaviyo segment in step with each of your Drawbridge segments. Reconnect Klaviyo to allow it.",
3669
+ title: "Reconnect Klaviyo"
3670
+ }],
3671
+ ...((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? [] : [{
3244
3672
  message: "Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.",
3245
3673
  title: "Choose a list"
3246
- }
3674
+ }]
3247
3675
  ];
3248
3676
  },
3249
- title: "Klaviyo"
3677
+ title: "Klaviyo",
3678
+ // KLAVIYO PUBLISHES NO DASHBOARD URLS in its API reference. What is on record
3679
+ // is its own help centre on a list: "you can find a list's ID in the URL in
3680
+ // your browser when viewing this list"
3681
+ // (help.klaviyo.com/hc/en-us/articles/115005078647, fetched 2026-09-11), and a
3682
+ // segment's page is the sibling form of it. The path itself is NOT published
3683
+ // anywhere citable, so the dev walk-through confirms this against a real
3684
+ // account before promote.
3685
+ urls: {
3686
+ segment: (row2) => (row2 == null ? void 0 : row2.id) ? "https://www.klaviyo.com/segment/" + row2.id : null
3687
+ }
3250
3688
  };
3251
3689
 
3252
3690
  // lib/connections/providers/mailchimp.js
@@ -3283,6 +3721,7 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
3283
3721
  return response.status === 204 ? null : response.json();
3284
3722
  };
3285
3723
  var subscriberHash = (email) => createHash2("md5").update(String(email).trim().toLowerCase()).digest("hex");
3724
+ var tagName = (title) => "Drawbridge: " + title;
3286
3725
  var mailchimp_default2 = {
3287
3726
  // OAUTH 2, authorization code. Every url below is quoted from
3288
3727
  // mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ rather
@@ -3325,7 +3764,7 @@ var mailchimp_default2 = {
3325
3764
  confirm: "Disconnecting removes Drawbridge's stored Mailchimp access. You can also remove Drawbridge from the Authorized Apps page in your Mailchimp account. Your contacts stay in both Drawbridge and Mailchimp \u2014 neither list is deleted.",
3326
3765
  description: [
3327
3766
  "Drawbridge no longer sends email through Mailchimp. Notification email now sends from Drawbridge itself, and verifying a domain under Messaging in your organization settings puts your own brand in the from line.",
3328
- "Connecting Mailchimp lets Drawbridge sync the contacts your campaigns collect into a Mailchimp audience, so the people who enter a giveaway can be marketed to alongside the rest of your list.",
3767
+ "Connecting Mailchimp lets Drawbridge sync the contacts your campaigns collect into a Mailchimp audience, so you can market to the people who enter a giveaway alongside the rest of your list.",
3329
3768
  "Anyone who has opted out in Drawbridge is synced as unsubscribed rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.",
3330
3769
  "Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before."
3331
3770
  ],
@@ -3338,6 +3777,7 @@ var mailchimp_default2 = {
3338
3777
  "Sign in to Mailchimp if you are not already, and choose the account to connect.",
3339
3778
  "You come back here to pick the audience your contacts should sync into.",
3340
3779
  "The connection shows Pending until you pick an audience, then Active.",
3780
+ 'Each of your Drawbridge segments appears in that audience as a tag named "Drawbridge: " plus the segment name. Renaming a segment in Drawbridge renames its tag, and deleting the segment deletes the tag.',
3341
3781
  "You can remove Drawbridge at any time from the Authorized Apps page in your Mailchimp account."
3342
3782
  ]
3343
3783
  },
@@ -3363,10 +3803,9 @@ var mailchimp_default2 = {
3363
3803
  }
3364
3804
  ],
3365
3805
  group: "contacts",
3366
- // A HOOK'S VALUE IS ITS ANSWER. A key is stored and can be removed; nothing
3367
- // else is built yet, because audience sync has not shipped. Every false here
3368
- // is "not yet" rather than "never" when the sync lands, probe and
3369
- // contacts.sync are the first to flip.
3806
+ // WHAT THIS VENDOR DOES AND DOES NOT DO is the value of each hook below, not
3807
+ // a paragraph up here that goes stale the moment one of them is implemented
3808
+ // which is exactly what happened to the note this replaces.
3370
3809
  hooks: {
3371
3810
  auth: {
3372
3811
  // WHERE THE ACCOUNT LIVES. Not enrichment — without this the connection
@@ -3420,26 +3859,32 @@ var mailchimp_default2 = {
3420
3859
  // why there is no create-or-update branch here. Quoted from Mailchimp's
3421
3860
  // Marketing API reference for the list-members resource.
3422
3861
  sync: async ({ connection: connection2, lead, segments, settings, suppressed, token }, { fetcher, read } = {}) => {
3423
- var _a, _b;
3862
+ var _a, _b, _c;
3424
3863
  const audience = settings == null ? void 0 : settings.audience;
3425
3864
  if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
3426
3865
  const email = ((_b = (_a = lead == null ? void 0 : lead.canonical) == null ? void 0 : _a.email) == null ? void 0 : _b.value) || (lead == null ? void 0 : lead.email);
3427
3866
  if (!email) return { message: "That lead has no email address to sync.", skipped: true };
3428
3867
  const hash = subscriberHash(email);
3868
+ const [firstName, ...restOfName] = String((lead == null ? void 0 : lead.name) || "").trim().split(/\s+/).filter(Boolean);
3869
+ const lastName = restOfName.join(" ");
3870
+ const phone = ((_c = lead == null ? void 0 : lead.phone) == null ? void 0 : _c.number) || null;
3871
+ const mergeFields = {
3872
+ ...firstName && { FNAME: firstName },
3873
+ ...lastName && { LNAME: lastName },
3874
+ ...phone && { PHONE: phone }
3875
+ };
3429
3876
  const member = await api3("/lists/" + audience + "/members/" + hash, {
3430
3877
  dc: settings == null ? void 0 : settings.dc,
3431
3878
  fetcher,
3432
3879
  method: "PUT",
3433
3880
  payload: {
3434
3881
  email_address: email,
3435
- // FNAME ONLY. Unlike Klaviyo, Mailchimp's custom fields are not
3436
- // schemaless a merge tag that does not exist on the audience is
3437
- // refused, taking the whole request with it and FNAME is one of
3438
- // the two tags every audience is created with. The Drawbridge
3439
- // totals Klaviyo receives cannot travel until something registers
3440
- // merge fields on the chosen audience, which is lifecycle.register's
3441
- // job and is not built.
3442
- ...(lead == null ? void 0 : lead.name) && { merge_fields: { FNAME: String(lead.name).trim().split(/\s+/)[0] } },
3882
+ // Built above. Omitted entirely when there is nothing to say, so a
3883
+ // lead with only an address does not send an empty object. The
3884
+ // Drawbridge totals Klaviyo receives still cannot travel this way
3885
+ // those are custom tags, and registering them on the chosen audience
3886
+ // is lifecycle.register's job and is not built.
3887
+ ...Object.keys(mergeFields).length > 0 && { merge_fields: mergeFields },
3443
3888
  ...suppressed && { status: "unsubscribed" },
3444
3889
  status_if_new: suppressed ? "unsubscribed" : "subscribed"
3445
3890
  },
@@ -3462,7 +3907,7 @@ var mailchimp_default2 = {
3462
3907
  });
3463
3908
  const joined = new Set(segments.map((entry) => entry.title));
3464
3909
  const tags = (owned || []).map((entry) => entry.title).filter(Boolean).map((title) => ({
3465
- name: "Drawbridge: " + title,
3910
+ name: tagName(title),
3466
3911
  status: joined.has(title) ? "active" : "inactive"
3467
3912
  }));
3468
3913
  if (tags.length > 0) {
@@ -3485,12 +3930,120 @@ var mailchimp_default2 = {
3485
3930
  };
3486
3931
  }
3487
3932
  },
3488
- // Drawbridge sends its own notification email and SMS, and owns its own
3489
- // segments see the private `drawbridge` manifest. A vendor answering
3490
- // these would be a second sender, which is the arrangement the platform
3491
- // sender replaced.
3933
+ // Drawbridge sends its own notification email. A vendor answering this
3934
+ // would be a second sender, which is the arrangement the platform sender
3935
+ // replaced.
3492
3936
  email: false,
3493
- segment: false,
3937
+ // SEGMENTS ARE SHARED, not owned by one side. Drawbridge holds the
3938
+ // membership — see the private `drawbridge` manifest, and `sync : false`
3939
+ // below — while register and remove keep a Mailchimp tag standing for each
3940
+ // Drawbridge segment, so the merchant can target one in their own audience.
3941
+ segment: {
3942
+ // THE TAG THIS SEGMENT IS, held by id at last.
3943
+ //
3944
+ // Tags ARE static segments in Mailchimp's model — same collection, same
3945
+ // ids — so this creates one through /segments and the member write goes
3946
+ // on attaching people to it by name. Both address the same object. The
3947
+ // segment schema says it outright: "The type of segment. Static segments
3948
+ // are now known as tags"
3949
+ // (api.mailchimp.com/schema/3.0/Swagger.json, fetched 2026-09-11).
3950
+ //
3951
+ // IDEMPOTENT ON EVERY PATH: called on create, on rename, on the boot
3952
+ // sweep and on backfill, it converges. That is what lets one hook serve
3953
+ // all four without a create-vs-update branch anywhere else.
3954
+ register: async ({ connection: connection2, context, manifest, settings, token, workflow }, { fetcher, read } = {}) => {
3955
+ var _a;
3956
+ const audience = settings == null ? void 0 : settings.audience;
3957
+ if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
3958
+ const segment = await currentSegment({ read, segment: context == null ? void 0 : context.segment });
3959
+ if (!(segment == null ? void 0 : segment.id) || segment.system) return { message: "That segment is not one this connection publishes.", skipped: true };
3960
+ const name = tagName(segment.title);
3961
+ const existing = segmentRowFor({ connection: connection2, segment });
3962
+ let id = null;
3963
+ if (existing == null ? void 0 : existing.id) {
3964
+ try {
3965
+ const found = await api3("/lists/" + audience + "/segments/" + existing.id, { dc: settings == null ? void 0 : settings.dc, fetcher, token });
3966
+ id = (found == null ? void 0 : found.id) ?? existing.id;
3967
+ if ((found == null ? void 0 : found.name) !== name) {
3968
+ await api3("/lists/" + audience + "/segments/" + existing.id, {
3969
+ dc: settings == null ? void 0 : settings.dc,
3970
+ fetcher,
3971
+ method: "PATCH",
3972
+ payload: { name },
3973
+ token
3974
+ });
3975
+ }
3976
+ } catch (error) {
3977
+ if (error.status !== 404) throw error;
3978
+ id = null;
3979
+ }
3980
+ }
3981
+ if (!id) {
3982
+ const search = await api3("/lists/" + audience + "/tag-search?name=" + encodeURIComponent(name), { dc: settings == null ? void 0 : settings.dc, fetcher, token });
3983
+ id = ((_a = ((search == null ? void 0 : search.tags) || []).find((tag) => (tag == null ? void 0 : tag.name) === name)) == null ? void 0 : _a.id) ?? null;
3984
+ }
3985
+ if (!id) {
3986
+ const created = await api3("/lists/" + audience + "/segments", {
3987
+ dc: settings == null ? void 0 : settings.dc,
3988
+ fetcher,
3989
+ method: "POST",
3990
+ // STATIC WITH NO MEMBERS. The member sync attaches people by
3991
+ // name; this call only has to make the object exist. Mailchimp's
3992
+ // own wording for the empty array: "Passing an empty array will
3993
+ // create a static segment without any subscribers."
3994
+ payload: { name, static_segment: [] },
3995
+ token
3996
+ });
3997
+ id = created == null ? void 0 : created.id;
3998
+ }
3999
+ if (!id) return { message: "Mailchimp returned no tag id.", skipped: true };
4000
+ const audienceDetail = await api3("/lists/" + audience + "?fields=web_id", { dc: settings == null ? void 0 : settings.dc, fetcher, token });
4001
+ return {
4002
+ // A RENAME THAT ARRIVED WHILE THIS RAN was dropped by the
4003
+ // coalescing job id, so the last thing this does is look again.
4004
+ enqueues: await driftEnqueues({ applied: segment.title, read, segment, workflow }),
4005
+ events: [{
4006
+ event: "organization.segments",
4007
+ payload: { id: segment.id },
4008
+ room: "organization." + (connection2 == null ? void 0 : connection2.organization)
4009
+ }],
4010
+ message: 'Mailchimp is carrying this segment as the tag "' + name + '".',
4011
+ writes: segmentRowWrites({
4012
+ connection: connection2,
4013
+ data: { ...connection2, settings },
4014
+ manifest,
4015
+ row: { id, type: "tag", webId: audienceDetail == null ? void 0 : audienceDetail.web_id },
4016
+ segment
4017
+ })
4018
+ };
4019
+ },
4020
+ // THE TAG GOES WITH THE SEGMENT. Leaving it behind is the orphan this
4021
+ // whole pair exists to stop — every member would keep a label for a
4022
+ // segment that no longer exists.
4023
+ remove: async ({ connection: connection2, context, settings, token }, { fetcher } = {}) => {
4024
+ const segment = context == null ? void 0 : context.segment;
4025
+ const existing = segmentRowFor({ connection: connection2, segment });
4026
+ if (!(existing == null ? void 0 : existing.id)) return { message: "Mailchimp was never carrying this segment.", skipped: true };
4027
+ try {
4028
+ await api3("/lists/" + (settings == null ? void 0 : settings.audience) + "/segments/" + existing.id, {
4029
+ dc: settings == null ? void 0 : settings.dc,
4030
+ fetcher,
4031
+ method: "DELETE",
4032
+ token
4033
+ });
4034
+ } catch (error) {
4035
+ if (error.status !== 404) throw error;
4036
+ }
4037
+ return {
4038
+ message: "Mailchimp is no longer carrying this segment.",
4039
+ writes: segmentRowRemoveWrites({ connection: connection2, segment })
4040
+ };
4041
+ },
4042
+ // Drawbridge-side membership belongs to the private manifest.
4043
+ sync: false
4044
+ },
4045
+ // Declined for the same reason as `email` above: Drawbridge sends its own
4046
+ // notification SMS, and a vendor answering this would be a second sender.
3494
4047
  sms: false,
3495
4048
  inbound: false,
3496
4049
  lifecycle: false,
@@ -3553,6 +4106,16 @@ var mailchimp_default2 = {
3553
4106
  "MAILCHIMP_OAUTH_CLIENT_ID",
3554
4107
  "MAILCHIMP_OAUTH_CLIENT_SECRET"
3555
4108
  ],
4109
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
4110
+ review: {
4111
+ api: "https://mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/",
4112
+ dashboard: "https://mailchimp.com/help/manage-tags/",
4113
+ // NO SCOPES EXIST. Mailchimp's OAuth guide describes none, and a token is
4114
+ // account-wide — so there is nothing to request and nothing to re-consent.
4115
+ scopes: false,
4116
+ content: "2026-09-11",
4117
+ verified: null
4118
+ },
3556
4119
  slug: "mailchimp",
3557
4120
  // A grant with no audience chosen is authenticated and useless — the sync has
3558
4121
  // nowhere to put anyone — so the card must say Pending rather than Active over
@@ -3598,6 +4161,30 @@ var mailchimp_default2 = {
3598
4161
  // adds this step, and what is charged when it runs.
3599
4162
  usage: { actions: 1 }
3600
4163
  })
4164
+ },
4165
+ segment: {
4166
+ // SYSTEM, so the builder never offers it and a merchant POST refuses it:
4167
+ // these fire from the segment's own lifecycle, not from a workflow
4168
+ // somebody assembled.
4169
+ //
4170
+ // The trigger is declared HERE rather than hard-coded in drawbridge-sync,
4171
+ // which is what lets a vendor arrive with its own without a queue edit.
4172
+ register: () => ({
4173
+ description: "Keeps a matching tag in your Mailchimp audience for each Drawbridge segment, and renames it when the segment is renamed.",
4174
+ hook: "segment.register",
4175
+ key: "Mailchimp Segment Register",
4176
+ queue: "connection",
4177
+ system: true,
4178
+ trigger: { event: "segment.register", type: "event" }
4179
+ }),
4180
+ remove: () => ({
4181
+ description: "Deletes the Mailchimp tag for a Drawbridge segment when the segment is deleted.",
4182
+ hook: "segment.remove",
4183
+ key: "Mailchimp Segment Remove",
4184
+ queue: "connection",
4185
+ system: true,
4186
+ trigger: { event: "segment.remove", type: "event" }
4187
+ })
3601
4188
  }
3602
4189
  },
3603
4190
  // WHY, in the merchant's words, and what to do about it.
@@ -3614,11 +4201,27 @@ var mailchimp_default2 = {
3614
4201
  }
3615
4202
  ];
3616
4203
  },
3617
- title: "Mailchimp"
4204
+ title: "Mailchimp",
4205
+ // THE MERCHANT'S OWN ADMIN. Mailchimp's list schema states the shape outright:
4206
+ // the web_id field is "The ID used in the Mailchimp web application. View this
4207
+ // list in your Mailchimp account at
4208
+ // https://{dc}.admin.mailchimp.com/lists/members/?id={web_id}"
4209
+ // (api.mailchimp.com/schema/3.0/Definitions/Lists/Response.json, fetched
4210
+ // 2026-09-11).
4211
+ //
4212
+ // It lands on the audience's contacts, where the Drawbridge tag is one filter
4213
+ // away. Mailchimp documents no url that pre-selects a tag, so this stops one
4214
+ // click short rather than guessing at one that could break silently.
4215
+ urls: {
4216
+ segment: (row2, data2) => {
4217
+ var _a;
4218
+ return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.dc) && (row2 == null ? void 0 : row2.webId) ? "https://" + data2.settings.dc + ".admin.mailchimp.com/lists/members/?id=" + row2.webId : null;
4219
+ }
4220
+ }
3618
4221
  };
3619
4222
 
3620
4223
  // lib/connections/providers/shopify.js
3621
- import { randomUUID } from "crypto";
4224
+ import { randomUUID as randomUUID2 } from "crypto";
3622
4225
  import { customAlphabet as customAlphabet2 } from "nanoid";
3623
4226
 
3624
4227
  // lib/connections/icons/shopify.js
@@ -3734,6 +4337,28 @@ var attributeLineItems = (lineItems = []) => lineItems.reduce(
3734
4337
  { attrMap: {}, attributedGross: 0, attributedLines: [] }
3735
4338
  );
3736
4339
  var generateDiscountCode = customAlphabet2("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
4340
+ var blockedReason = (discount) => {
4341
+ var _a;
4342
+ if ((discount == null ? void 0 : discount.status) === "EXPIRED") return "This discount has expired.";
4343
+ const buyers = (_a = discount == null ? void 0 : discount.context) == null ? void 0 : _a.__typename;
4344
+ if (buyers && buyers !== "DiscountBuyerSelectionAll") {
4345
+ return "This discount is limited to specific buyers in Shopify, so a code issued to an entrant won't work. Set it to all customers to use it here.";
4346
+ }
4347
+ ;
4348
+ if (typeof (discount == null ? void 0 : discount.usageLimit) === "number" && discount.usageLimit > 0 && ((discount == null ? void 0 : discount.asyncUsageCount) || 0) >= discount.usageLimit) {
4349
+ return "This discount has reached its total usage limit.";
4350
+ }
4351
+ ;
4352
+ return null;
4353
+ };
4354
+ var discountWarning = (discount) => {
4355
+ if ((discount == null ? void 0 : discount.status) === "SCHEDULED") {
4356
+ return "This discount hasn't started yet, so codes issued before it does won't work until then.";
4357
+ }
4358
+ ;
4359
+ if (discount == null ? void 0 : discount.appliesOncePerCustomer) return "Each customer can use this discount only once.";
4360
+ return null;
4361
+ };
3737
4362
  var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
3738
4363
  var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
3739
4364
  var OAUTH_ERROR_SOURCE = "oauth";
@@ -3781,7 +4406,7 @@ var shopify_default2 = {
3781
4406
  description: [
3782
4407
  "Installing the Drawbridge app from the Shopify App Store links your store to a single Drawbridge organization and makes your product catalog available inside Drawbridge, so you can feature products in your campaigns and advertisements.",
3783
4408
  "Drawbridge attributes orders that originate from your campaigns \u2014 matched through cart parameters and lead-mapped discount codes \u2014 so you can see the revenue each campaign drives.",
3784
- "On connect, Drawbridge registers webhooks for product and order updates to keep your catalog and revenue in sync. Disconnecting removes those webhooks and unlinks the resources."
4409
+ "Drawbridge reads your products, records orders placed through your campaigns, and can issue discount codes. Order and product updates reach Drawbridge through the app's own webhooks, which Shopify applies when the app is installed."
3785
4410
  ],
3786
4411
  errors: {
3787
4412
  connect: {
@@ -3795,7 +4420,7 @@ var shopify_default2 = {
3795
4420
  "Open the Drawbridge listing on the Shopify App Store.",
3796
4421
  "Install the app on the store you want to connect. It opens in Shopify admin and stays there.",
3797
4422
  "Approve the Drawbridge plan when prompted \u2014 during install, or from the connection page here. The connection shows Pending until you do, then Active.",
3798
- "Come back here \u2014 the connections list updates on its own once the install lands."
4423
+ "Come back here \u2014 the connections list updates on its own once the install finishes."
3799
4424
  ],
3800
4425
  // Names where the link GOES rather than what it does: installing happens on
3801
4426
  // the App Store listing, and the dashboard must never imply a store can be
@@ -4171,9 +4796,11 @@ var shopify_default2 = {
4171
4796
  phone: customerPhone
4172
4797
  } : null;
4173
4798
  const source = (connection2 == null ? void 0 : connection2.source) ? { domain: connection2.source.domain, id: connection2.source.id } : void 0;
4174
- const orderDocId = (existingOrder == null ? void 0 : existingOrder.id) || (isConversion && !backfill ? mintId() : null);
4799
+ const createsOrder = !backfill && (isConversion || Boolean(discount));
4800
+ const orderDocId = (existingOrder == null ? void 0 : existingOrder.id) || (createsOrder ? mintId() : null);
4801
+ const redemptionDocId = discount ? mintId() : null;
4175
4802
  const writes = [];
4176
- if (isConversion && !backfill) {
4803
+ if (createsOrder) {
4177
4804
  writes.push({
4178
4805
  collection: "order",
4179
4806
  data: {
@@ -4194,15 +4821,25 @@ var shopify_default2 = {
4194
4821
  provider: { id: String(orderId), slug: "shopify" },
4195
4822
  purchasedAt,
4196
4823
  rate,
4824
+ // Null on a conversion that matched no code of ours; the
4825
+ // backfill branch below sets it when one arrives later.
4826
+ redemption: redemptionDocId,
4197
4827
  source,
4198
- status: "completed"
4828
+ status: "completed",
4829
+ type: isConversion ? "conversion" : "redemption"
4199
4830
  },
4200
4831
  operation: "create"
4201
4832
  });
4202
4833
  if (org == null ? void 0 : org.usage) {
4203
4834
  writes.push({
4204
4835
  collection: "usage",
4205
- data: { $inc: { "totals.revenue": gross } },
4836
+ // TWO METERS, NOT ONE SUMMED. `revenue` has always meant
4837
+ // conversion revenue and is the figure the fee is charged
4838
+ // against, so redemption money gets its own key rather than
4839
+ // changing what an existing number means.
4840
+ data: {
4841
+ $inc: isConversion ? { "totals.revenue": gross } : { "totals.redemptionRevenue": gross }
4842
+ },
4206
4843
  operation: "update",
4207
4844
  query: { id: org.usage }
4208
4845
  });
@@ -4210,7 +4847,12 @@ var shopify_default2 = {
4210
4847
  if (leadId) {
4211
4848
  writes.push({
4212
4849
  collection: "lead",
4213
- data: { $inc: { "totals.orders": 1 } },
4850
+ // Same grouped shape the contact carries, so a lead and the
4851
+ // contact built from it cannot be read two different ways.
4852
+ data: { $inc: {
4853
+ "totals.orders.total": 1,
4854
+ ...isConversion ? { "totals.orders.conversions": 1 } : { "totals.orders.redemptions": 1 }
4855
+ } },
4214
4856
  operation: "update",
4215
4857
  options: { bypassDocumentValidation: true },
4216
4858
  query: { id: leadId }
@@ -4229,6 +4871,7 @@ var shopify_default2 = {
4229
4871
  customer,
4230
4872
  discount,
4231
4873
  gross,
4874
+ id: redemptionDocId,
4232
4875
  lead: leadId,
4233
4876
  order: orderDocId,
4234
4877
  organization: campaignOrganization,
@@ -4257,6 +4900,14 @@ var shopify_default2 = {
4257
4900
  query: { id: leadId }
4258
4901
  });
4259
4902
  }
4903
+ if (backfill && orderDocId && redemptionDocId) {
4904
+ writes.push({
4905
+ collection: "order",
4906
+ data: { $set: { redemption: redemptionDocId } },
4907
+ operation: "update",
4908
+ query: { id: orderDocId }
4909
+ });
4910
+ }
4260
4911
  }
4261
4912
  const billable = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && !backfill;
4262
4913
  const providerRow2 = billable ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
@@ -4635,7 +5286,7 @@ var shopify_default2 = {
4635
5286
  event: "shopify.register.webhooks"
4636
5287
  },
4637
5288
  name: "register",
4638
- options: { jobId: "connection.update.register." + workflow.connection + "." + randomUUID() },
5289
+ options: { jobId: "connection.update.register." + workflow.connection + "." + randomUUID2() },
4639
5290
  queue: "connection"
4640
5291
  }],
4641
5292
  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.") + (sourceRepaired ? " Billing source repaired from the live shop." : "") + (metered === null ? " No usage line on the store's approved plan \u2014 order billing needs the merchant to approve the updated plan." : ""),
@@ -4749,10 +5400,19 @@ var shopify_default2 = {
4749
5400
  // picker stores, which is why the tail is taken here rather than by
4750
5401
  // each caller that happened to remember.
4751
5402
  items: ((discounts == null ? void 0 : discounts.edges) || []).map((edge) => {
4752
- var _a2, _b2, _c;
5403
+ var _a2, _b2;
5404
+ const node = ((_a2 = edge == null ? void 0 : edge.node) == null ? void 0 : _a2.codeDiscount) || {};
4753
5405
  return {
4754
- id: String(((_a2 = edge == null ? void 0 : edge.node) == null ? void 0 : _a2.id) || "").split("/").pop(),
4755
- title: (_c = (_b2 = edge == null ? void 0 : edge.node) == null ? void 0 : _b2.codeDiscount) == null ? void 0 : _c.title
5406
+ // Null when the discount can be used, a sentence when it cannot.
5407
+ // The picker greys the row and shows this instead of hiding it:
5408
+ // a discount the merchant can see in Shopify admin, missing here
5409
+ // with no explanation, reads as a bug in us.
5410
+ blocked: blockedReason(node),
5411
+ id: String(((_b2 = edge == null ? void 0 : edge.node) == null ? void 0 : _b2.id) || "").split("/").pop(),
5412
+ // Usable, but not in the way the merchant probably expects.
5413
+ // Shown beside the row without stopping them.
5414
+ warning: discountWarning(node),
5415
+ title: node.title
4756
5416
  };
4757
5417
  }),
4758
5418
  pageInfo: {
@@ -4767,20 +5427,6 @@ var shopify_default2 = {
4767
5427
  },
4768
5428
  icon: shopify_default,
4769
5429
  inbound,
4770
- // THE DEEP LINK into this store's Drawbridge app inside Shopify admin.
4771
- //
4772
- // Here rather than in drawbridge-api, which had `slug === 'shopify' && {...}`
4773
- // in the shared resolver — a hardcoded vendor branch in code every vendor runs
4774
- // through, which is the arrangement these manifests exist to remove.
4775
- //
4776
- // Undefined until a shop is linked, so the Manage button only appears on a
4777
- // connected connection. The app handle is NAMED by `requires` and read from
4778
- // the env the resolver passes, never from process.env here.
4779
- manage: (data2, env) => {
4780
- var _a;
4781
- const shop = (data2 == null ? void 0 : data2.shop) || ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.domain);
4782
- return shop ? "https://admin.shopify.com/store/" + String(shop).replace(".myshopify.com", "") + "/apps/" + (env == null ? void 0 : env.SHOPIFY_APP_HANDLE) : void 0;
4783
- },
4784
5430
  // DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
4785
5431
  // what an admin types on the provider screen. The four names below are exactly
4786
5432
  // what `requires` gates on, which is the point of declaring them together: a
@@ -4826,6 +5472,14 @@ var shopify_default2 = {
4826
5472
  "SHOPIFY_APP_LISTING_URL",
4827
5473
  "SHOPIFY_APP_HANDLE"
4828
5474
  ],
5475
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
5476
+ review: {
5477
+ api: "https://shopify.dev/docs/api/admin-graphql",
5478
+ dashboard: "https://help.shopify.com/en/manual/apps",
5479
+ scopes: "https://shopify.dev/docs/api/usage/access-scopes",
5480
+ content: "2026-09-11",
5481
+ verified: null
5482
+ },
4829
5483
  slug: "shopify",
4830
5484
  // The install is the whole configuration — Shopify hands back the shop and
4831
5485
  // there is nothing further to choose. `shop` absent means the install did not
@@ -4913,8 +5567,11 @@ var shopify_default2 = {
4913
5567
  })
4914
5568
  },
4915
5569
  // SYSTEM STEPS: dispatched by drawbridge-sync itself rather than offered
4916
- // in the builder, so they carry no trigger and no usage. Declared because
4917
- // the routing table and the system-workflow descriptions both read here.
5570
+ // in the builder, so they carry no usage. These two are fired by a webhook
5571
+ // arriving rather than by a workflow trigger, so they name none either —
5572
+ // and naming none is what stops a workflow being provisioned for them.
5573
+ // Declared because the routing table and the system-workflow descriptions
5574
+ // both read here.
4918
5575
  order: {
4919
5576
  record: () => ({
4920
5577
  description: "Records an order and billing charge when a purchase is made via a Drawbridge campaign link.",
@@ -4946,7 +5603,8 @@ var shopify_default2 = {
4946
5603
  hook: "lifecycle.health",
4947
5604
  key: "Shopify Connection Health",
4948
5605
  queue: "connection",
4949
- system: true
5606
+ system: true,
5607
+ trigger: { event: "day", type: "schedule" }
4950
5608
  })
4951
5609
  },
4952
5610
  // Audit-only. The "Shopify Token Activity" system workflow lists these
@@ -5007,7 +5665,30 @@ var shopify_default2 = {
5007
5665
  ] : []
5008
5666
  ];
5009
5667
  },
5010
- title: "Shopify"
5668
+ title: "Shopify",
5669
+ // THE VENDOR'S OWN ADMIN, one function per thing worth linking to. It lives
5670
+ // here rather than at the top level so a second link (a product, an order)
5671
+ // is a key in this object instead of a new manifest key nobody agreed on.
5672
+ //
5673
+ // AND HERE RATHER THAN IN drawbridge-api, which had `slug === 'shopify' &&
5674
+ // {...}` in the shared resolver — a hardcoded vendor branch in code every
5675
+ // vendor runs through, which is the arrangement these manifests exist to
5676
+ // remove.
5677
+ //
5678
+ // Never projected: the api composes connect.manage from it, and
5679
+ // resolveConnection drops the object, because a url built from settings is
5680
+ // built where the settings are already decrypted.
5681
+ urls: {
5682
+ // Undefined until a shop is linked, so the Manage button only appears on a
5683
+ // connected connection. The app handle is NAMED by `requires` and read from
5684
+ // the env its caller passes — the api's resolve() hands it the stored
5685
+ // credentials, never process.env.
5686
+ manage: (data2, env) => {
5687
+ var _a;
5688
+ const shop = (data2 == null ? void 0 : data2.shop) || ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.domain);
5689
+ return shop ? "https://admin.shopify.com/store/" + String(shop).replace(".myshopify.com", "") + "/apps/" + (env == null ? void 0 : env.SHOPIFY_APP_HANDLE) : void 0;
5690
+ }
5691
+ }
5011
5692
  };
5012
5693
 
5013
5694
  // lib/connections/providers/webhook.js
@@ -5187,7 +5868,7 @@ var webhook_default = {
5187
5868
  content: {
5188
5869
  confirm: "Disconnecting stops Drawbridge from sending signed webhook payloads to your endpoint.",
5189
5870
  description: [
5190
- "Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react in real time.",
5871
+ "Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react to it.",
5191
5872
  "Generate a signing secret and Drawbridge signs every request with it. Your endpoint recomputes the signature to confirm each payload genuinely came from Drawbridge before acting on it."
5192
5873
  ],
5193
5874
  excerpt: "Sign outgoing webhook payloads with an HMAC secret to verify authenticity.",
@@ -5288,6 +5969,9 @@ var webhook_default = {
5288
5969
  // Gated on the encryption secret: without it the signing secret could not be
5289
5970
  // stored safely, so the connection must not be offered at all.
5290
5971
  requires: ["ENCRYPT_CONNECTION_SECRET"],
5972
+ // NO THIRD PARTY AT ALL. There is no vendor reference to read, no dashboard
5973
+ // to link to and no scope to request: connecting mints a secret.
5974
+ review: false,
5291
5975
  // Outbound only. inbound.* is false because the direction is the point: we
5292
5976
  // sign and POST to the merchant's endpoint, they never call us. Every other
5293
5977
  // false follows from there being no third party to authenticate against —
@@ -5594,7 +6278,7 @@ var mergeSettings = ({ existing, incoming }) => {
5594
6278
  var publicConnectionKeys = Object.freeze([
5595
6279
  "actions",
5596
6280
  // API-COMPOSED, not manifest-declared: the api's resolve() builds it from
5597
- // auth.type, content.redirect and the manifest's manage() — the client reads
6281
+ // auth.type, content.redirect and the manifest's urls.manage() — the client reads
5598
6282
  // connect.type to choose entered-vs-installed, connect.redirect for the App
5599
6283
  // Store link, connect.manage for the admin deep link. It was dropped from
5600
6284
  // this list when the manifests stopped declaring it, which stripped the
@@ -5653,20 +6337,20 @@ var clearProviderMemo = () => providerMemo.clear();
5653
6337
  var providerRow = async ({ controller, slug: slug2 }) => {
5654
6338
  const memoized = providerMemo.get(slug2);
5655
6339
  if (memoized && Date.now() - memoized.at < MEMO_TTL_MS) return memoized.value;
5656
- const row = await controller.get({
6340
+ const row2 = await controller.get({
5657
6341
  collection: "provider",
5658
6342
  query: { slug: slug2 }
5659
6343
  });
5660
6344
  const value = {
5661
- enabled: (row == null ? void 0 : row.enabled) !== false,
5662
- settings: (row == null ? void 0 : row.settings) ? decrypt(row.settings) : {}
6345
+ enabled: (row2 == null ? void 0 : row2.enabled) !== false,
6346
+ settings: (row2 == null ? void 0 : row2.settings) ? decrypt(row2.settings) : {}
5663
6347
  };
5664
6348
  providerMemo.set(slug2, { at: Date.now(), value });
5665
6349
  return value;
5666
6350
  };
5667
6351
  var providerSettings = async ({ controller, includeDisabled = false, slug: slug2 }) => {
5668
- const row = await providerRow({ controller, slug: slug2 });
5669
- return row.enabled || includeDisabled ? row.settings : {};
6352
+ const row2 = await providerRow({ controller, slug: slug2 });
6353
+ return row2.enabled || includeDisabled ? row2.settings : {};
5670
6354
  };
5671
6355
  var vendorEnabled = async ({ controller, vendor }) => (await providerRow({ controller, slug: vendor })).enabled;
5672
6356
  var vendorSettings = async ({ controller, slug: slug2 }) => {