@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.
@@ -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.
@@ -274,6 +287,8 @@ var STEPS = Object.freeze({
274
287
  "email.digest": "Digest",
275
288
  "email.notify": "Notification",
276
289
  "email.send": "Send email",
290
+ "segment.register": "Register segment",
291
+ "segment.remove": "Remove segment",
277
292
  "segment.sync": "Sync segment",
278
293
  "sms.send": "Send SMS",
279
294
  "webhook.send": "Send webhook"
@@ -437,7 +452,16 @@ var tokenSettings = ({ existing = {}, now = Date.now(), tokens }) => ({
437
452
  ...tokens.expiresIn && {
438
453
  expiresAt: new Date(now + tokens.expiresIn * 1e3).toISOString()
439
454
  },
440
- ...tokens.scope && { scope: tokens.scope }
455
+ // A VENDOR NEED NOT RETURN `scope` ON A REFRESH. Klaviyo documents it on the
456
+ // authorization_code response and documents no response body at all for the
457
+ // refresh grant, so taking the minted value alone drops the stored one. That
458
+ // matters because `scope` is load-bearing: the segment hooks gate on
459
+ // `segments:write` and answer `skipped` when it is absent, so a connection
460
+ // that dropped it disables its whole segment half without failing anything
461
+ // and shows a reconnect task that reconnecting has already fixed.
462
+ ...(tokens.scope || existing.scope) && {
463
+ scope: tokens.scope || existing.scope
464
+ }
441
465
  });
442
466
  var accessToken = async ({
443
467
  clientId,
@@ -500,6 +524,98 @@ var detectCountry = (value) => {
500
524
  }
501
525
  };
502
526
 
527
+ // lib/connections/segment-rows.js
528
+ import { randomUUID } from "crypto";
529
+ var row = ({ connection: connection2, data: data2, manifest, row: described }) => {
530
+ var _a, _b;
531
+ return {
532
+ connection: connection2 == null ? void 0 : connection2.id,
533
+ // ALWAYS A STRING. Mailchimp tag ids are integers, Klaviyo segment ids are
534
+ // strings, and one type in the schema is one comparison in the $or below.
535
+ id: String(described == null ? void 0 : described.id),
536
+ slug: connection2 == null ? void 0 : connection2.slug,
537
+ type: described == null ? void 0 : described.type,
538
+ // NULL, NEVER UNDEFINED: undefined drops the key, and the schema requires it.
539
+ // The url is built HERE, while the settings are decrypted and the vendor
540
+ // facts are in hand — an api reading the row later has neither.
541
+ 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
542
+ };
543
+ };
544
+ var segmentRowWrites = ({ connection: connection2, data: data2, manifest, row: described, segment }) => {
545
+ const built = row({ connection: connection2, data: data2, manifest, row: described });
546
+ return [
547
+ // PUSH IF ABSENT. The $ne guard is what makes a second concurrent register
548
+ // add nothing rather than a duplicate row for one connection.
549
+ {
550
+ collection: "segment",
551
+ data: { $push: { connections: built } },
552
+ operation: "update",
553
+ query: {
554
+ id: segment == null ? void 0 : segment.id,
555
+ "connections.connection": { $ne: connection2 == null ? void 0 : connection2.id }
556
+ }
557
+ },
558
+ // SET IF DIFFERENT. $elemMatch selects this connection's row only when one
559
+ // of its three mutable fields disagrees, so the steady state — the same
560
+ // vendor object, the same url — matches nothing and writes nothing.
561
+ {
562
+ collection: "segment",
563
+ data: { $set: { "connections.$": built } },
564
+ operation: "update",
565
+ query: {
566
+ id: segment == null ? void 0 : segment.id,
567
+ connections: {
568
+ $elemMatch: {
569
+ connection: connection2 == null ? void 0 : connection2.id,
570
+ $or: [
571
+ { id: { $ne: built.id } },
572
+ { type: { $ne: built.type } },
573
+ { url: { $ne: built.url } }
574
+ ]
575
+ }
576
+ }
577
+ }
578
+ }
579
+ ];
580
+ };
581
+ var segmentRowRemoveWrites = ({ connection: connection2, segment }) => [
582
+ {
583
+ collection: "segment",
584
+ data: { $pull: { connections: { connection: connection2 == null ? void 0 : connection2.id } } },
585
+ operation: "update",
586
+ query: { id: segment == null ? void 0 : segment.id }
587
+ }
588
+ ];
589
+ 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));
590
+ var currentSegment = async ({ read, segment }) => {
591
+ if (!(read == null ? void 0 : read.get) || !(segment == null ? void 0 : segment.id)) return segment;
592
+ return read.get({
593
+ collection: "segment",
594
+ query: { id: segment.id }
595
+ });
596
+ };
597
+ var driftEnqueues = async ({ applied, read, segment, workflow }) => {
598
+ if (!(workflow == null ? void 0 : workflow.id)) return [];
599
+ const fresh = await currentSegment({ read, segment });
600
+ if (!(fresh == null ? void 0 : fresh.id) || fresh.title === applied) return [];
601
+ return [{
602
+ data: {
603
+ triggerData: {
604
+ organization: fresh.organization || (workflow == null ? void 0 : workflow.organization),
605
+ segment: fresh
606
+ },
607
+ workflowId: workflow == null ? void 0 : workflow.id
608
+ },
609
+ name: "execute",
610
+ options: {
611
+ jobId: "workflow.insert.execute." + (workflow == null ? void 0 : workflow.id) + ".segment.register." + fresh.id + ".drift." + Date.now() + "." + randomUUID().slice(0, 8),
612
+ removeOnComplete: true,
613
+ removeOnFail: true
614
+ },
615
+ queue: "workflow"
616
+ }];
617
+ };
618
+
503
619
  // lib/connections/providers/attentive.js
504
620
  var api = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
505
621
  const response = await fetcher("https://api.attentivemobile.com" + path, {
@@ -552,7 +668,7 @@ var attentive_default2 = {
552
668
  // has to say so rather than let them believe otherwise.
553
669
  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.",
554
670
  description: [
555
- "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.",
671
+ "This connection syncs the contacts your campaigns collect into your Attentive account \u2014 subscribed for marketing, and added to the segment you choose.",
556
672
  "You authorize Drawbridge from inside Attentive and can revoke that access there at any time. Drawbridge never sees or stores your Attentive password.",
557
673
  "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.",
558
674
  "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."
@@ -594,10 +710,9 @@ var attentive_default2 = {
594
710
  }
595
711
  ],
596
712
  group: "contacts",
597
- // A HOOK'S VALUE IS ITS ANSWER. The consent is stored and can be revoked;
598
- // nothing else is built yet, because subscriber sync has not shipped. Every
599
- // false here is "not yet" rather than "never" — when the sync lands, probe
600
- // and contacts.sync are the first to flip.
713
+ // WHAT THIS VENDOR DOES AND DOES NOT DO is the value of each hook below, not
714
+ // a paragraph up here that goes stale the moment one of them is implemented
715
+ // which is exactly what happened to the note this replaces.
601
716
  hooks: {
602
717
  auth: {
603
718
  // FALSE, NOT {}. `{}` means "supported, implemented in the repo with the
@@ -610,7 +725,7 @@ var attentive_default2 = {
610
725
  // nobody re-derives it. Klaviyo's connect reads the account name back so
611
726
  // the card is not blank; Attentive's card stays blank. There IS an
612
727
  // endpoint — GET https://api.attentivemobile.com/v1/me, Bearer, described
613
- // on docs.attentive.com/pages/authentication/ as returning "information
728
+ // on docs.attentive.com/docs/authentication as returning "information
614
729
  // specific to your company" — but its RESPONSE SCHEMA is published
615
730
  // nowhere we can read: the docs show the curl and no body. Reading
616
731
  // `body.name` would be a guess, and a guess here fails at the worst
@@ -777,7 +892,62 @@ var attentive_default2 = {
777
892
  products: false,
778
893
  promotions: false
779
894
  },
780
- segment: false,
895
+ segment: {
896
+ // A FOUNDATION, AND HONEST ABOUT IT. Attentive's segments API can create
897
+ // one with an externalId we choose (docs.attentive.com/reference/
898
+ // createsegment.md, fetched 2026-09-11: POST /v2/segments, `name`
899
+ // required, `externalId` optional and "auto-generated if not supplied"),
900
+ // which would give a real per-segment object — but it takes
901
+ // segments:write, and scopes ride on the app registration, which does not
902
+ // exist yet.
903
+ //
904
+ // So the row points at the connection-level segment the merchant chose,
905
+ // `type` says so, and turning this into a per-segment object later is a
906
+ // change to this file and nothing else: create with
907
+ // externalId = segment.id, PATCH to rename, archive on remove. Their
908
+ // update and archive endpoints are BOTH keyed by external id
909
+ // (docs.attentive.com/reference/patchsegmentbyexternalid.md and
910
+ // /deletesegmentbyexternalid.md, fetched 2026-09-11), so the segment id we
911
+ // already hold addresses every one of the three calls.
912
+ //
913
+ // NO DRIFT CHECK, unlike the other two: the row points at the
914
+ // connection's own segment and the link is the index page, so nothing
915
+ // here depends on the Drawbridge segment's title — a rename has nothing
916
+ // to apply and nothing to race with. That comes back with the
917
+ // per-segment object.
918
+ //
919
+ // THE RE-READ STAYS ALL THE SAME. It changes nothing today, and this is
920
+ // the simplest of the three registers and therefore the one the next
921
+ // vendor gets copied from — one job id serves four dispatch sites, so a
922
+ // copy that trusts context.segment applies whichever trigger data won
923
+ // the race, at a vendor where the title does matter.
924
+ register: async ({ connection: connection2, context, manifest, settings }, { read } = {}) => {
925
+ const segment = await currentSegment({ read, segment: context == null ? void 0 : context.segment });
926
+ if (!(segment == null ? void 0 : segment.id) || segment.system) return { message: "That segment is not one this connection publishes.", skipped: true };
927
+ if (!(settings == null ? void 0 : settings.segment)) return { message: "No Attentive segment is chosen for this connection.", skipped: true };
928
+ return {
929
+ events: [{
930
+ event: "organization.segments",
931
+ payload: { id: segment.id },
932
+ room: "organization." + (connection2 == null ? void 0 : connection2.organization)
933
+ }],
934
+ message: "Contacts in this segment are added to the Attentive segment chosen on this connection.",
935
+ writes: segmentRowWrites({
936
+ connection: connection2,
937
+ data: { ...connection2, settings },
938
+ manifest,
939
+ row: { id: settings.segment, type: "segment" },
940
+ segment
941
+ })
942
+ };
943
+ },
944
+ // NOT OURS TO DELETE. The segment on this connection is the merchant's,
945
+ // and it is where every Drawbridge segment's contacts go — removing it
946
+ // because one Drawbridge segment was deleted would empty the others.
947
+ remove: false,
948
+ // Drawbridge-side membership belongs to the private manifest.
949
+ sync: false
950
+ },
781
951
  sms: false,
782
952
  webhook: false
783
953
  },
@@ -806,6 +976,21 @@ var attentive_default2 = {
806
976
  "ATTENTIVE_OAUTH_CLIENT_ID",
807
977
  "ATTENTIVE_OAUTH_CLIENT_SECRET"
808
978
  ],
979
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
980
+ review: {
981
+ api: "https://docs.attentive.com/reference/listsegments",
982
+ dashboard: "https://docs.attentive.com/docs/segments",
983
+ // THIS PAGE DOES NOT LIST EVERY SCOPE. Its table names five —
984
+ // events:write, ecommerce:write, subscriptions:write, attributes:write,
985
+ // privacy_requests:write — and says nothing about segments:read or
986
+ // segments:write, which the segments API this manifest calls does take.
987
+ // The header at the top of this file carries that distinction; it is
988
+ // repeated here so a reviewer following the link is not misled by what the
989
+ // table omits (fetched 2026-09-11).
990
+ scopes: "https://docs.attentive.com/docs/authentication",
991
+ content: "2026-09-11",
992
+ verified: null
993
+ },
809
994
  slug: "attentive",
810
995
  // A consent with no segment chosen is authenticated and inert — the sync needs
811
996
  // somewhere to put people — so the card says Pending rather than Active over
@@ -843,6 +1028,24 @@ var attentive_default2 = {
843
1028
  triggers: ["lead.insert", "segment.contact.add"],
844
1029
  usage: { actions: 1 }
845
1030
  })
1031
+ },
1032
+ segment: {
1033
+ // SYSTEM, so the builder never offers it and a merchant POST refuses it:
1034
+ // this fires from the segment's own lifecycle, not from a workflow
1035
+ // somebody assembled. The trigger is declared here rather than hard-coded
1036
+ // in drawbridge-sync.
1037
+ //
1038
+ // REGISTER ONLY. There is no remove step because hooks.segment.remove is
1039
+ // declined, and build() refuses a step pointing at a hook this vendor does
1040
+ // not implement — so the two are one decision, enforced at import.
1041
+ register: () => ({
1042
+ description: "Records which Attentive segment a Drawbridge segment's contacts are added to.",
1043
+ hook: "segment.register",
1044
+ key: "Attentive Segment Register",
1045
+ queue: "connection",
1046
+ system: true,
1047
+ trigger: { event: "segment.register", type: "event" }
1048
+ })
846
1049
  }
847
1050
  },
848
1051
  // WHY, in the merchant's words, and what to do about it.
@@ -859,7 +1062,21 @@ var attentive_default2 = {
859
1062
  }
860
1063
  ];
861
1064
  },
862
- title: "Attentive"
1065
+ title: "Attentive",
1066
+ // ATTENTIVE'S SEGMENTS INDEX. There is no documented per-segment url, and the
1067
+ // only identifier we hold is the API's externalId, which their UI may not
1068
+ // path by — so this lands on the list, where the merchant finds it by name.
1069
+ // A per-segment link arrives with the per-segment object (see hooks.segment).
1070
+ //
1071
+ // THE PATH ITSELF IS NOT PUBLISHED ANYWHERE CITABLE, the same gap Klaviyo's
1072
+ // segment url carries. What is on record is that the segments area lives at
1073
+ // ui.attentivemobile.com/segments — its /segments/create/ and /segments/manual
1074
+ // sub-routes are publicly indexed pages (fetched 2026-09-11) — while the /all
1075
+ // tab is not, and Attentive's help centre refuses automated fetches. The dev
1076
+ // walk-through confirms this against a real account before promote.
1077
+ urls: {
1078
+ segment: () => "https://ui.attentivemobile.com/segments/all"
1079
+ }
863
1080
  };
864
1081
 
865
1082
  // lib/connections/providers/drawbridge.js
@@ -1538,10 +1755,10 @@ var free = {
1538
1755
  };
1539
1756
  var plans = {
1540
1757
  DB00002: {
1541
- // A verified sending domain is a PAID capability: free plans cannot send
1542
- // lead-facing email at all (the send path gates on an active
1543
- // subscription), so granting it there would offer a domain that can
1544
- // never send from.
1758
+ // A verified sending domain is a PAID capability. Every plan sends
1759
+ // lead-facing email from the platform address — the send is billed as an
1760
+ // action, so the allowance is the entitlement and sending from your own
1761
+ // domain is what the paid tiers add on top.
1545
1762
  features: all.features([organization.networking.key, organization.members.key]),
1546
1763
  limits: all.limits({ actions: 5e3, members: 3, storage: gigabyte * 10 }),
1547
1764
  marketing: {
@@ -1979,7 +2196,7 @@ var drawbridge_default2 = {
1979
2196
  content: {
1980
2197
  confirm: "This connection is part of Drawbridge and cannot be disconnected.",
1981
2198
  description: [
1982
- "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."
2199
+ "Drawbridge sends your notification emails and your entrants' emails, and recalculates segment membership on a schedule. It is always on and needs nothing connected."
1983
2200
  ],
1984
2201
  excerpt: "The steps Drawbridge runs itself.",
1985
2202
  guide: [
@@ -2145,16 +2362,6 @@ var drawbridge_default2 = {
2145
2362
  const request2 = { to };
2146
2363
  const { ok: sendable } = await canSend({ channel: "email", to });
2147
2364
  if (!sendable) return { message: "Recipient has opted out \u2014 skipped.", request: request2, response: { skipped: true }, skipped: true };
2148
- const organization2 = await read.get({ collection: "organization", query: { id: workflow.organization } });
2149
- const subscription = (organization2 == null ? void 0 : organization2.subscription) ? await read.get({ collection: "subscription", query: { id: organization2.subscription } }) : null;
2150
- if ((subscription == null ? void 0 : subscription.status) !== "active") {
2151
- return {
2152
- message: "Organization has no active subscription \u2014 workflow-step email skipped.",
2153
- request: request2,
2154
- response: { skipped: true },
2155
- skipped: true
2156
- };
2157
- }
2158
2365
  return {
2159
2366
  message: "Email queued for delivery to " + to + ".",
2160
2367
  request: request2,
@@ -2302,6 +2509,11 @@ var drawbridge_default2 = {
2302
2509
  promotions: false
2303
2510
  },
2304
2511
  segment: {
2512
+ // NOT DRAWBRIDGE'S. Registering a segment means creating an object at a
2513
+ // vendor, and this manifest has no vendor behind it — the three that do
2514
+ // implement these.
2515
+ register: false,
2516
+ remove: false,
2305
2517
  // RECALCULATE SEGMENT MEMBERSHIP. The one FAN-OUT hook: it evaluates every
2306
2518
  // contact in an organization against every segment, which is too much for
2307
2519
  // one job, so it returns chunks and the shell defers completion.
@@ -2577,6 +2789,33 @@ var drawbridge_default2 = {
2577
2789
  // caught exactly that: availableConnections({}) went from [ 'drawbridge' ] to
2578
2790
  // empty the moment this was added.
2579
2791
  requires: [],
2792
+ // PRIVATE, NOT VENDORLESS. SendGrid, Twilio and HubSpot are behind this
2793
+ // manifest, so `false` would be a lie about which reads were made.
2794
+ //
2795
+ // ONE ENTRY PER VENDOR, because three vendors are three reads. A single
2796
+ // citation here would evidence one of them and read as though it covered all
2797
+ // three, which is the omission this key exists to catch.
2798
+ review: {
2799
+ api: {
2800
+ // lib/hubspot.js posts to /crm/v3/objects/contacts.
2801
+ hubspot: "https://developers.hubspot.com/docs/reference/api/crm/objects/contacts",
2802
+ // lib/sendgrid.js posts to /v3/mail/send.
2803
+ sendgrid: "https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send",
2804
+ // lib/twilio.js posts to /2010-04-01/Accounts/{Sid}/Messages.json, and
2805
+ // hooks.inbound.verify reads the MessageStatus this resource documents.
2806
+ twilio: "https://www.twilio.com/docs/messaging/api/message-resource"
2807
+ },
2808
+ dashboard: {
2809
+ hubspot: "https://knowledge.hubspot.com/contacts/create-contacts",
2810
+ sendgrid: "https://www.twilio.com/docs/sendgrid/ui/analytics-and-reporting/email-activity-feed",
2811
+ twilio: "https://www.twilio.com/docs/messaging/guides/debugging-tools"
2812
+ },
2813
+ // An admin types these keys in; there is no merchant consent and no scope
2814
+ // model on any of the three.
2815
+ scopes: false,
2816
+ content: "2026-09-11",
2817
+ verified: null
2818
+ },
2580
2819
  slug: "drawbridge",
2581
2820
  // Always on. There is no credential that could go bad and no configuration a
2582
2821
  // merchant could leave half-finished.
@@ -2768,6 +3007,8 @@ var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) =>
2768
3007
  }
2769
3008
  return response.status === 204 ? null : response.json();
2770
3009
  };
3010
+ var segmentName = (title) => "Drawbridge: " + title;
3011
+ var canManageSegments = (settings) => String((settings == null ? void 0 : settings.scope) || "").split(/\s+/).includes("segments:write");
2771
3012
  var klaviyo_default2 = {
2772
3013
  // OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
2773
3014
  // exchange without a code_verifier matching the challenge the consent
@@ -2797,9 +3038,21 @@ var klaviyo_default2 = {
2797
3038
  // exchange, and a copy here would be a second answer that goes stale.
2798
3039
  expiry: 90 * 24 * 60 * 60,
2799
3040
  pkce: true,
3041
+ // EVERY SCOPE THE MANIFEST'S HOOKS NEED, not the ones today's hooks use.
3042
+ // Klaviyo holds scopes on the APP — "Pinpoint which scopes your app uses
3043
+ // and set them using a space-separated list"
3044
+ // (developers.klaviyo.com/en/docs/create_a_public_oauth_app, fetched
3045
+ // 2026-09-11) — and a merchant's token only ever carries what they
3046
+ // consented to, so a scope added later is a reconnect for every one of
3047
+ // them. That is what segments cost when they were left out here.
3048
+ //
2800
3049
  // Space separated. accounts:read is required by Klaviyo on every app
2801
- // and must stay in the list; the rest are what a contact sync needs.
2802
- scopes: "accounts:read lists:read lists:write profiles:read profiles:write",
3050
+ // and must stay in the list; the rest are what a contact sync and the
3051
+ // segment hooks need — Get Segments lists `segments:read`, Create,
3052
+ // Update and Delete Segment each list `segments:write`
3053
+ // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
3054
+ // revision 2026-07-15, fetched 2026-09-11).
3055
+ scopes: "accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write",
2803
3056
  // EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
2804
3057
  // the disconnect hook — three vendor addresses, two of them declared,
2805
3058
  // which is exactly the kind of split that goes unnoticed.
@@ -2848,9 +3101,10 @@ var klaviyo_default2 = {
2848
3101
  // Shown at disconnect, so it says what is lost and what is not.
2849
3102
  confirm: "Disconnecting revokes Drawbridge's access to your Klaviyo account. Your profiles and lists stay in both Klaviyo and Drawbridge \u2014 neither is deleted.",
2850
3103
  description: [
2851
- "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.",
3104
+ "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.",
2852
3105
  "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.",
2853
- "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."
3106
+ "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.",
3107
+ "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."
2854
3108
  ],
2855
3109
  // KEYED BY WHAT FAILED, not nested inside it. Errors are the thing most
2856
3110
  // likely to grow — resources.* has already earned somewhere to put "we
@@ -3045,7 +3299,14 @@ var klaviyo_default2 = {
3045
3299
  // `segments` is null when the run carried no contact document,
3046
3300
  // meaning nobody looked — different from [], which means they
3047
3301
  // are in none. Null omits the key and merge leaves it alone.
3048
- ...segments && { drawbridge_segments: segments.map((entry) => entry.title).filter(Boolean) }
3302
+ ...segments && { drawbridge_segments: segments.map((entry) => entry.title).filter(Boolean) },
3303
+ // THE IDS, which is what a Drawbridge-made segment's definition
3304
+ // filters on. Ids rather than titles, so renaming a segment is a
3305
+ // name change at Klaviyo and not a resync of every profile.
3306
+ //
3307
+ // The titles stay beside them: merchants have been building
3308
+ // their own segments on that array since it shipped.
3309
+ ...segments && { drawbridge_segment_ids: segments.map((entry) => entry.id).filter(Boolean) }
3049
3310
  }
3050
3311
  },
3051
3312
  type: "profile"
@@ -3089,17 +3350,141 @@ var klaviyo_default2 = {
3089
3350
  };
3090
3351
  }
3091
3352
  },
3092
- // A WHOLE DOMAIN CAN DECLINE AT ONCE. Klaviyo sends us nothing and we
3093
- // register nothing with it, so listing four falses would be noise around a
3094
- // single decision. Still explicit absence would not say whether anybody
3095
- // considered it.
3096
- // Drawbridge sends its own notification email and SMS, and owns its own
3097
- // segments — see the private `drawbridge` manifest. A vendor answering
3098
- // these would be a second sender, which is the arrangement the platform
3099
- // sender replaced.
3353
+ // Drawbridge sends its own notification email. A vendor answering this
3354
+ // would be a second sender, which is the arrangement the platform sender
3355
+ // replaced. Declined as one line rather than one per verb, because the whole
3356
+ // domain is one decision — still explicit, since absence would not say
3357
+ // whether anybody considered it.
3100
3358
  email: false,
3101
- segment: false,
3359
+ // SEGMENTS ARE SHARED, not owned by one side. Drawbridge holds the
3360
+ // membership — see the private `drawbridge` manifest, and `sync : false`
3361
+ // below — while register and remove keep a Klaviyo segment standing for
3362
+ // each Drawbridge segment, so the merchant can target one in their own
3363
+ // flows.
3364
+ segment: {
3365
+ // THE KLAVIYO SEGMENT THIS DRAWBRIDGE SEGMENT BECOMES.
3366
+ //
3367
+ // Klaviyo owns no writable membership — its segments are computed from
3368
+ // rules — so the segment we create is DEFINED BY the profile property
3369
+ // contacts.sync writes. The definition filters on the Drawbridge
3370
+ // segment's ID, never its title, which is what makes a rename one PATCH
3371
+ // instead of a resync of every profile in it.
3372
+ register: async ({ connection: connection2, context, manifest, settings, token, workflow }, { fetcher, read } = {}) => {
3373
+ var _a, _b, _c, _d, _e;
3374
+ const segment = await currentSegment({ read, segment: context == null ? void 0 : context.segment });
3375
+ if (!(segment == null ? void 0 : segment.id) || segment.system) return { message: "That segment is not one this connection publishes.", skipped: true };
3376
+ if (!canManageSegments(settings)) {
3377
+ return {
3378
+ message: "Reconnect Klaviyo to let Drawbridge manage segments \u2014 this connection was made before that permission was asked for.",
3379
+ skipped: true
3380
+ };
3381
+ }
3382
+ const name = segmentName(segment.title);
3383
+ const existing = segmentRowFor({ connection: connection2, segment });
3384
+ let id = null;
3385
+ if (existing == null ? void 0 : existing.id) {
3386
+ try {
3387
+ const found = await api2("/segments/" + existing.id, { fetcher, token });
3388
+ id = ((_a = found == null ? void 0 : found.data) == null ? void 0 : _a.id) ?? existing.id;
3389
+ if (((_c = (_b = found == null ? void 0 : found.data) == null ? void 0 : _b.attributes) == null ? void 0 : _c.name) !== name) {
3390
+ await api2("/segments/" + existing.id, {
3391
+ fetcher,
3392
+ method: "PATCH",
3393
+ payload: { data: { attributes: { name }, id: existing.id, type: "segment" } },
3394
+ token
3395
+ });
3396
+ }
3397
+ } catch (error) {
3398
+ if (error.status !== 404) throw error;
3399
+ id = null;
3400
+ }
3401
+ }
3402
+ if (!id) {
3403
+ const search = await api2("/segments?filter=" + encodeURIComponent('equals(name,"' + name.replace(/"/g, '\\"') + '")'), { fetcher, token });
3404
+ id = ((_d = ((search == null ? void 0 : search.data) || []).find((entry) => {
3405
+ var _a2;
3406
+ return ((_a2 = entry == null ? void 0 : entry.attributes) == null ? void 0 : _a2.name) === name;
3407
+ })) == null ? void 0 : _d.id) ?? null;
3408
+ }
3409
+ if (!id) {
3410
+ const created = await api2("/segments", {
3411
+ fetcher,
3412
+ method: "POST",
3413
+ // THE DEFINITION IS THE MEMBERSHIP. Create Segment requires one
3414
+ // — `name` and `definition` are both required on its attributes
3415
+ // — and a custom profile property is addressed as
3416
+ // "properties['property name']", tested with a list filter whose
3417
+ // operator is `contains`
3418
+ // (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
3419
+ // revision 2026-07-15, fetched 2026-09-11).
3420
+ payload: {
3421
+ data: {
3422
+ attributes: {
3423
+ definition: {
3424
+ condition_groups: [{
3425
+ conditions: [{
3426
+ filter: { operator: "contains", type: "list", value: segment.id },
3427
+ property: "properties['drawbridge_segment_ids']",
3428
+ type: "profile-property"
3429
+ }]
3430
+ }]
3431
+ },
3432
+ name
3433
+ },
3434
+ type: "segment"
3435
+ }
3436
+ },
3437
+ token
3438
+ });
3439
+ id = (_e = created == null ? void 0 : created.data) == null ? void 0 : _e.id;
3440
+ }
3441
+ if (!id) return { message: "Klaviyo returned no segment id.", skipped: true };
3442
+ return {
3443
+ // A RENAME THAT ARRIVED WHILE THIS RAN was dropped by the
3444
+ // coalescing job id, so the last thing this does is look again.
3445
+ enqueues: await driftEnqueues({ applied: segment.title, read, segment, workflow }),
3446
+ events: [{
3447
+ event: "organization.segments",
3448
+ payload: { id: segment.id },
3449
+ room: "organization." + (connection2 == null ? void 0 : connection2.organization)
3450
+ }],
3451
+ message: 'Klaviyo is carrying this segment as "' + name + '".',
3452
+ writes: segmentRowWrites({
3453
+ connection: connection2,
3454
+ data: { ...connection2, settings },
3455
+ manifest,
3456
+ row: { id, type: "segment" },
3457
+ segment
3458
+ })
3459
+ };
3460
+ },
3461
+ // NO RE-READ. The segment is already deleted; the pre-image is the only
3462
+ // copy, and it carries the row naming what to delete.
3463
+ remove: async ({ connection: connection2, context, settings, token }, { fetcher } = {}) => {
3464
+ const segment = context == null ? void 0 : context.segment;
3465
+ const existing = segmentRowFor({ connection: connection2, segment });
3466
+ if (!(existing == null ? void 0 : existing.id)) return { message: "Klaviyo was never carrying this segment.", skipped: true };
3467
+ if (!canManageSegments(settings)) {
3468
+ return { message: "Reconnect Klaviyo to let Drawbridge manage segments.", skipped: true };
3469
+ }
3470
+ try {
3471
+ await api2("/segments/" + existing.id, { fetcher, method: "DELETE", token });
3472
+ } catch (error) {
3473
+ if (error.status !== 404) throw error;
3474
+ }
3475
+ return {
3476
+ message: "Klaviyo is no longer carrying this segment.",
3477
+ writes: segmentRowRemoveWrites({ connection: connection2, segment })
3478
+ };
3479
+ },
3480
+ // Drawbridge-side membership belongs to the private manifest.
3481
+ sync: false
3482
+ },
3483
+ // Declined for the same reason as `email` above: Drawbridge sends its own
3484
+ // notification SMS, and a vendor answering this would be a second sender.
3102
3485
  sms: false,
3486
+ // Klaviyo sends us nothing — no inbound message to receive, no signature
3487
+ // to verify.
3103
3488
  inbound: false,
3104
3489
  // Nothing to set up or tear down at the vendor: the grant is the whole
3105
3490
  // integration. What CAN rot is the grant itself, so health is the one
@@ -3220,6 +3605,18 @@ var klaviyo_default2 = {
3220
3605
  "KLAVIYO_OAUTH_CLIENT_ID",
3221
3606
  "KLAVIYO_OAUTH_CLIENT_SECRET"
3222
3607
  ],
3608
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
3609
+ review: {
3610
+ api: "https://developers.klaviyo.com/en/reference/api_overview",
3611
+ dashboard: "https://help.klaviyo.com/hc/en-us/articles/115005078647",
3612
+ // THE SCOPE TABLE, not the OAuth walk-through. set_up_oauth carries one
3613
+ // example scope string and nothing to check a manifest against; this page
3614
+ // lists the scopes each API takes, segments:read and segments:write among
3615
+ // them (fetched 2026-09-11).
3616
+ scopes: "https://developers.klaviyo.com/en/docs/authenticate_",
3617
+ content: "2026-09-11",
3618
+ verified: null
3619
+ },
3223
3620
  slug: "klaviyo",
3224
3621
  // ONE OF THE FOUR STATES AND NOTHING ELSE — the reason sits in `tasks`, which
3225
3622
  // is already the merchant-facing copy channel and is already rendered.
@@ -3250,7 +3647,8 @@ var klaviyo_default2 = {
3250
3647
  hook: "lifecycle.health",
3251
3648
  key: "Klaviyo Connection Health",
3252
3649
  queue: "connection",
3253
- system: true
3650
+ system: true,
3651
+ trigger: { event: "day", type: "schedule" }
3254
3652
  })
3255
3653
  }
3256
3654
  },
@@ -3294,6 +3692,28 @@ var klaviyo_default2 = {
3294
3692
  usage: { actions: 1 }
3295
3693
  };
3296
3694
  }
3695
+ },
3696
+ segment: {
3697
+ // SYSTEM, so the builder never offers it and a merchant POST refuses it:
3698
+ // these fire from the segment's own lifecycle, not from a workflow
3699
+ // somebody assembled. The trigger is declared here rather than hard-coded
3700
+ // in drawbridge-sync.
3701
+ register: () => ({
3702
+ description: "Keeps a matching Klaviyo segment for each Drawbridge segment, built on the segment ids Drawbridge writes onto your profiles.",
3703
+ hook: "segment.register",
3704
+ key: "Klaviyo Segment Register",
3705
+ queue: "connection",
3706
+ system: true,
3707
+ trigger: { event: "segment.register", type: "event" }
3708
+ }),
3709
+ remove: () => ({
3710
+ description: "Deletes the Klaviyo segment for a Drawbridge segment when the segment is deleted.",
3711
+ hook: "segment.remove",
3712
+ key: "Klaviyo Segment Remove",
3713
+ queue: "connection",
3714
+ system: true,
3715
+ trigger: { event: "segment.remove", type: "event" }
3716
+ })
3297
3717
  }
3298
3718
  },
3299
3719
  // WHY, in the merchant's words, and what to do about it.
@@ -3303,14 +3723,32 @@ var klaviyo_default2 = {
3303
3723
  // moment: the grant is good and the list is the missing half.
3304
3724
  tasks: (data2) => {
3305
3725
  var _a;
3306
- return !["active", "pending"].includes(data2 == null ? void 0 : data2.status) || ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? [] : [
3307
- {
3726
+ if (!["active", "pending"].includes(data2 == null ? void 0 : data2.status)) return [];
3727
+ return [
3728
+ // A connection made before segments were requested is authenticated and
3729
+ // cannot manage them, and no error surfaces anywhere else — the register
3730
+ // runs skip rather than fail.
3731
+ ...canManageSegments(data2 == null ? void 0 : data2.settings) ? [] : [{
3732
+ message: "Drawbridge now keeps a Klaviyo segment in step with each of your Drawbridge segments. Reconnect Klaviyo to allow it.",
3733
+ title: "Reconnect Klaviyo"
3734
+ }],
3735
+ ...((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.list) ? [] : [{
3308
3736
  message: "Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.",
3309
3737
  title: "Choose a list"
3310
- }
3738
+ }]
3311
3739
  ];
3312
3740
  },
3313
- title: "Klaviyo"
3741
+ title: "Klaviyo",
3742
+ // KLAVIYO PUBLISHES NO DASHBOARD URLS in its API reference. What is on record
3743
+ // is its own help centre on a list: "you can find a list's ID in the URL in
3744
+ // your browser when viewing this list"
3745
+ // (help.klaviyo.com/hc/en-us/articles/115005078647, fetched 2026-09-11), and a
3746
+ // segment's page is the sibling form of it. The path itself is NOT published
3747
+ // anywhere citable, so the dev walk-through confirms this against a real
3748
+ // account before promote.
3749
+ urls: {
3750
+ segment: (row2) => (row2 == null ? void 0 : row2.id) ? "https://www.klaviyo.com/segment/" + row2.id : null
3751
+ }
3314
3752
  };
3315
3753
 
3316
3754
  // lib/connections/providers/mailchimp.js
@@ -3347,6 +3785,7 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
3347
3785
  return response.status === 204 ? null : response.json();
3348
3786
  };
3349
3787
  var subscriberHash = (email) => createHash2("md5").update(String(email).trim().toLowerCase()).digest("hex");
3788
+ var tagName = (title) => "Drawbridge: " + title;
3350
3789
  var mailchimp_default2 = {
3351
3790
  // OAUTH 2, authorization code. Every url below is quoted from
3352
3791
  // mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ rather
@@ -3389,7 +3828,7 @@ var mailchimp_default2 = {
3389
3828
  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.",
3390
3829
  description: [
3391
3830
  "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.",
3392
- "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.",
3831
+ "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.",
3393
3832
  "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.",
3394
3833
  "Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before."
3395
3834
  ],
@@ -3402,6 +3841,7 @@ var mailchimp_default2 = {
3402
3841
  "Sign in to Mailchimp if you are not already, and choose the account to connect.",
3403
3842
  "You come back here to pick the audience your contacts should sync into.",
3404
3843
  "The connection shows Pending until you pick an audience, then Active.",
3844
+ '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.',
3405
3845
  "You can remove Drawbridge at any time from the Authorized Apps page in your Mailchimp account."
3406
3846
  ]
3407
3847
  },
@@ -3427,10 +3867,9 @@ var mailchimp_default2 = {
3427
3867
  }
3428
3868
  ],
3429
3869
  group: "contacts",
3430
- // A HOOK'S VALUE IS ITS ANSWER. A key is stored and can be removed; nothing
3431
- // else is built yet, because audience sync has not shipped. Every false here
3432
- // is "not yet" rather than "never" when the sync lands, probe and
3433
- // contacts.sync are the first to flip.
3870
+ // WHAT THIS VENDOR DOES AND DOES NOT DO is the value of each hook below, not
3871
+ // a paragraph up here that goes stale the moment one of them is implemented
3872
+ // which is exactly what happened to the note this replaces.
3434
3873
  hooks: {
3435
3874
  auth: {
3436
3875
  // WHERE THE ACCOUNT LIVES. Not enrichment — without this the connection
@@ -3484,26 +3923,32 @@ var mailchimp_default2 = {
3484
3923
  // why there is no create-or-update branch here. Quoted from Mailchimp's
3485
3924
  // Marketing API reference for the list-members resource.
3486
3925
  sync: async ({ connection: connection2, lead, segments, settings, suppressed, token }, { fetcher, read } = {}) => {
3487
- var _a, _b;
3926
+ var _a, _b, _c;
3488
3927
  const audience = settings == null ? void 0 : settings.audience;
3489
3928
  if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
3490
3929
  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);
3491
3930
  if (!email) return { message: "That lead has no email address to sync.", skipped: true };
3492
3931
  const hash = subscriberHash(email);
3932
+ const [firstName, ...restOfName] = String((lead == null ? void 0 : lead.name) || "").trim().split(/\s+/).filter(Boolean);
3933
+ const lastName = restOfName.join(" ");
3934
+ const phone = ((_c = lead == null ? void 0 : lead.phone) == null ? void 0 : _c.number) || null;
3935
+ const mergeFields = {
3936
+ ...firstName && { FNAME: firstName },
3937
+ ...lastName && { LNAME: lastName },
3938
+ ...phone && { PHONE: phone }
3939
+ };
3493
3940
  const member = await api3("/lists/" + audience + "/members/" + hash, {
3494
3941
  dc: settings == null ? void 0 : settings.dc,
3495
3942
  fetcher,
3496
3943
  method: "PUT",
3497
3944
  payload: {
3498
3945
  email_address: email,
3499
- // FNAME ONLY. Unlike Klaviyo, Mailchimp's custom fields are not
3500
- // schemaless a merge tag that does not exist on the audience is
3501
- // refused, taking the whole request with it and FNAME is one of
3502
- // the two tags every audience is created with. The Drawbridge
3503
- // totals Klaviyo receives cannot travel until something registers
3504
- // merge fields on the chosen audience, which is lifecycle.register's
3505
- // job and is not built.
3506
- ...(lead == null ? void 0 : lead.name) && { merge_fields: { FNAME: String(lead.name).trim().split(/\s+/)[0] } },
3946
+ // Built above. Omitted entirely when there is nothing to say, so a
3947
+ // lead with only an address does not send an empty object. The
3948
+ // Drawbridge totals Klaviyo receives still cannot travel this way
3949
+ // those are custom tags, and registering them on the chosen audience
3950
+ // is lifecycle.register's job and is not built.
3951
+ ...Object.keys(mergeFields).length > 0 && { merge_fields: mergeFields },
3507
3952
  ...suppressed && { status: "unsubscribed" },
3508
3953
  status_if_new: suppressed ? "unsubscribed" : "subscribed"
3509
3954
  },
@@ -3526,7 +3971,7 @@ var mailchimp_default2 = {
3526
3971
  });
3527
3972
  const joined = new Set(segments.map((entry) => entry.title));
3528
3973
  const tags = (owned || []).map((entry) => entry.title).filter(Boolean).map((title) => ({
3529
- name: "Drawbridge: " + title,
3974
+ name: tagName(title),
3530
3975
  status: joined.has(title) ? "active" : "inactive"
3531
3976
  }));
3532
3977
  if (tags.length > 0) {
@@ -3549,12 +3994,120 @@ var mailchimp_default2 = {
3549
3994
  };
3550
3995
  }
3551
3996
  },
3552
- // Drawbridge sends its own notification email and SMS, and owns its own
3553
- // segments see the private `drawbridge` manifest. A vendor answering
3554
- // these would be a second sender, which is the arrangement the platform
3555
- // sender replaced.
3997
+ // Drawbridge sends its own notification email. A vendor answering this
3998
+ // would be a second sender, which is the arrangement the platform sender
3999
+ // replaced.
3556
4000
  email: false,
3557
- segment: false,
4001
+ // SEGMENTS ARE SHARED, not owned by one side. Drawbridge holds the
4002
+ // membership — see the private `drawbridge` manifest, and `sync : false`
4003
+ // below — while register and remove keep a Mailchimp tag standing for each
4004
+ // Drawbridge segment, so the merchant can target one in their own audience.
4005
+ segment: {
4006
+ // THE TAG THIS SEGMENT IS, held by id at last.
4007
+ //
4008
+ // Tags ARE static segments in Mailchimp's model — same collection, same
4009
+ // ids — so this creates one through /segments and the member write goes
4010
+ // on attaching people to it by name. Both address the same object. The
4011
+ // segment schema says it outright: "The type of segment. Static segments
4012
+ // are now known as tags"
4013
+ // (api.mailchimp.com/schema/3.0/Swagger.json, fetched 2026-09-11).
4014
+ //
4015
+ // IDEMPOTENT ON EVERY PATH: called on create, on rename, on the boot
4016
+ // sweep and on backfill, it converges. That is what lets one hook serve
4017
+ // all four without a create-vs-update branch anywhere else.
4018
+ register: async ({ connection: connection2, context, manifest, settings, token, workflow }, { fetcher, read } = {}) => {
4019
+ var _a;
4020
+ const audience = settings == null ? void 0 : settings.audience;
4021
+ if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
4022
+ const segment = await currentSegment({ read, segment: context == null ? void 0 : context.segment });
4023
+ if (!(segment == null ? void 0 : segment.id) || segment.system) return { message: "That segment is not one this connection publishes.", skipped: true };
4024
+ const name = tagName(segment.title);
4025
+ const existing = segmentRowFor({ connection: connection2, segment });
4026
+ let id = null;
4027
+ if (existing == null ? void 0 : existing.id) {
4028
+ try {
4029
+ const found = await api3("/lists/" + audience + "/segments/" + existing.id, { dc: settings == null ? void 0 : settings.dc, fetcher, token });
4030
+ id = (found == null ? void 0 : found.id) ?? existing.id;
4031
+ if ((found == null ? void 0 : found.name) !== name) {
4032
+ await api3("/lists/" + audience + "/segments/" + existing.id, {
4033
+ dc: settings == null ? void 0 : settings.dc,
4034
+ fetcher,
4035
+ method: "PATCH",
4036
+ payload: { name },
4037
+ token
4038
+ });
4039
+ }
4040
+ } catch (error) {
4041
+ if (error.status !== 404) throw error;
4042
+ id = null;
4043
+ }
4044
+ }
4045
+ if (!id) {
4046
+ const search = await api3("/lists/" + audience + "/tag-search?name=" + encodeURIComponent(name), { dc: settings == null ? void 0 : settings.dc, fetcher, token });
4047
+ id = ((_a = ((search == null ? void 0 : search.tags) || []).find((tag) => (tag == null ? void 0 : tag.name) === name)) == null ? void 0 : _a.id) ?? null;
4048
+ }
4049
+ if (!id) {
4050
+ const created = await api3("/lists/" + audience + "/segments", {
4051
+ dc: settings == null ? void 0 : settings.dc,
4052
+ fetcher,
4053
+ method: "POST",
4054
+ // STATIC WITH NO MEMBERS. The member sync attaches people by
4055
+ // name; this call only has to make the object exist. Mailchimp's
4056
+ // own wording for the empty array: "Passing an empty array will
4057
+ // create a static segment without any subscribers."
4058
+ payload: { name, static_segment: [] },
4059
+ token
4060
+ });
4061
+ id = created == null ? void 0 : created.id;
4062
+ }
4063
+ if (!id) return { message: "Mailchimp returned no tag id.", skipped: true };
4064
+ const audienceDetail = await api3("/lists/" + audience + "?fields=web_id", { dc: settings == null ? void 0 : settings.dc, fetcher, token });
4065
+ return {
4066
+ // A RENAME THAT ARRIVED WHILE THIS RAN was dropped by the
4067
+ // coalescing job id, so the last thing this does is look again.
4068
+ enqueues: await driftEnqueues({ applied: segment.title, read, segment, workflow }),
4069
+ events: [{
4070
+ event: "organization.segments",
4071
+ payload: { id: segment.id },
4072
+ room: "organization." + (connection2 == null ? void 0 : connection2.organization)
4073
+ }],
4074
+ message: 'Mailchimp is carrying this segment as the tag "' + name + '".',
4075
+ writes: segmentRowWrites({
4076
+ connection: connection2,
4077
+ data: { ...connection2, settings },
4078
+ manifest,
4079
+ row: { id, type: "tag", webId: audienceDetail == null ? void 0 : audienceDetail.web_id },
4080
+ segment
4081
+ })
4082
+ };
4083
+ },
4084
+ // THE TAG GOES WITH THE SEGMENT. Leaving it behind is the orphan this
4085
+ // whole pair exists to stop — every member would keep a label for a
4086
+ // segment that no longer exists.
4087
+ remove: async ({ connection: connection2, context, settings, token }, { fetcher } = {}) => {
4088
+ const segment = context == null ? void 0 : context.segment;
4089
+ const existing = segmentRowFor({ connection: connection2, segment });
4090
+ if (!(existing == null ? void 0 : existing.id)) return { message: "Mailchimp was never carrying this segment.", skipped: true };
4091
+ try {
4092
+ await api3("/lists/" + (settings == null ? void 0 : settings.audience) + "/segments/" + existing.id, {
4093
+ dc: settings == null ? void 0 : settings.dc,
4094
+ fetcher,
4095
+ method: "DELETE",
4096
+ token
4097
+ });
4098
+ } catch (error) {
4099
+ if (error.status !== 404) throw error;
4100
+ }
4101
+ return {
4102
+ message: "Mailchimp is no longer carrying this segment.",
4103
+ writes: segmentRowRemoveWrites({ connection: connection2, segment })
4104
+ };
4105
+ },
4106
+ // Drawbridge-side membership belongs to the private manifest.
4107
+ sync: false
4108
+ },
4109
+ // Declined for the same reason as `email` above: Drawbridge sends its own
4110
+ // notification SMS, and a vendor answering this would be a second sender.
3558
4111
  sms: false,
3559
4112
  inbound: false,
3560
4113
  lifecycle: false,
@@ -3617,6 +4170,16 @@ var mailchimp_default2 = {
3617
4170
  "MAILCHIMP_OAUTH_CLIENT_ID",
3618
4171
  "MAILCHIMP_OAUTH_CLIENT_SECRET"
3619
4172
  ],
4173
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
4174
+ review: {
4175
+ api: "https://mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/",
4176
+ dashboard: "https://mailchimp.com/help/manage-tags/",
4177
+ // NO SCOPES EXIST. Mailchimp's OAuth guide describes none, and a token is
4178
+ // account-wide — so there is nothing to request and nothing to re-consent.
4179
+ scopes: false,
4180
+ content: "2026-09-11",
4181
+ verified: null
4182
+ },
3620
4183
  slug: "mailchimp",
3621
4184
  // A grant with no audience chosen is authenticated and useless — the sync has
3622
4185
  // nowhere to put anyone — so the card must say Pending rather than Active over
@@ -3662,6 +4225,30 @@ var mailchimp_default2 = {
3662
4225
  // adds this step, and what is charged when it runs.
3663
4226
  usage: { actions: 1 }
3664
4227
  })
4228
+ },
4229
+ segment: {
4230
+ // SYSTEM, so the builder never offers it and a merchant POST refuses it:
4231
+ // these fire from the segment's own lifecycle, not from a workflow
4232
+ // somebody assembled.
4233
+ //
4234
+ // The trigger is declared HERE rather than hard-coded in drawbridge-sync,
4235
+ // which is what lets a vendor arrive with its own without a queue edit.
4236
+ register: () => ({
4237
+ description: "Keeps a matching tag in your Mailchimp audience for each Drawbridge segment, and renames it when the segment is renamed.",
4238
+ hook: "segment.register",
4239
+ key: "Mailchimp Segment Register",
4240
+ queue: "connection",
4241
+ system: true,
4242
+ trigger: { event: "segment.register", type: "event" }
4243
+ }),
4244
+ remove: () => ({
4245
+ description: "Deletes the Mailchimp tag for a Drawbridge segment when the segment is deleted.",
4246
+ hook: "segment.remove",
4247
+ key: "Mailchimp Segment Remove",
4248
+ queue: "connection",
4249
+ system: true,
4250
+ trigger: { event: "segment.remove", type: "event" }
4251
+ })
3665
4252
  }
3666
4253
  },
3667
4254
  // WHY, in the merchant's words, and what to do about it.
@@ -3678,11 +4265,27 @@ var mailchimp_default2 = {
3678
4265
  }
3679
4266
  ];
3680
4267
  },
3681
- title: "Mailchimp"
4268
+ title: "Mailchimp",
4269
+ // THE MERCHANT'S OWN ADMIN. Mailchimp's list schema states the shape outright:
4270
+ // the web_id field is "The ID used in the Mailchimp web application. View this
4271
+ // list in your Mailchimp account at
4272
+ // https://{dc}.admin.mailchimp.com/lists/members/?id={web_id}"
4273
+ // (api.mailchimp.com/schema/3.0/Definitions/Lists/Response.json, fetched
4274
+ // 2026-09-11).
4275
+ //
4276
+ // It lands on the audience's contacts, where the Drawbridge tag is one filter
4277
+ // away. Mailchimp documents no url that pre-selects a tag, so this stops one
4278
+ // click short rather than guessing at one that could break silently.
4279
+ urls: {
4280
+ segment: (row2, data2) => {
4281
+ var _a;
4282
+ 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;
4283
+ }
4284
+ }
3682
4285
  };
3683
4286
 
3684
4287
  // lib/connections/providers/shopify.js
3685
- import { randomUUID } from "crypto";
4288
+ import { randomUUID as randomUUID2 } from "crypto";
3686
4289
  import { customAlphabet as customAlphabet2 } from "nanoid";
3687
4290
 
3688
4291
  // lib/connections/icons/shopify.js
@@ -3779,6 +4382,28 @@ var attributeLineItems = (lineItems = []) => lineItems.reduce(
3779
4382
  { attrMap: {}, attributedGross: 0, attributedLines: [] }
3780
4383
  );
3781
4384
  var generateDiscountCode = customAlphabet2("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
4385
+ var blockedReason = (discount) => {
4386
+ var _a;
4387
+ if ((discount == null ? void 0 : discount.status) === "EXPIRED") return "This discount has expired.";
4388
+ const buyers = (_a = discount == null ? void 0 : discount.context) == null ? void 0 : _a.__typename;
4389
+ if (buyers && buyers !== "DiscountBuyerSelectionAll") {
4390
+ 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.";
4391
+ }
4392
+ ;
4393
+ if (typeof (discount == null ? void 0 : discount.usageLimit) === "number" && discount.usageLimit > 0 && ((discount == null ? void 0 : discount.asyncUsageCount) || 0) >= discount.usageLimit) {
4394
+ return "This discount has reached its total usage limit.";
4395
+ }
4396
+ ;
4397
+ return null;
4398
+ };
4399
+ var discountWarning = (discount) => {
4400
+ if ((discount == null ? void 0 : discount.status) === "SCHEDULED") {
4401
+ return "This discount hasn't started yet, so codes issued before it does won't work until then.";
4402
+ }
4403
+ ;
4404
+ if (discount == null ? void 0 : discount.appliesOncePerCustomer) return "Each customer can use this discount only once.";
4405
+ return null;
4406
+ };
3782
4407
  var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
3783
4408
  var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
3784
4409
  var OAUTH_ERROR_SOURCE = "oauth";
@@ -3826,7 +4451,7 @@ var shopify_default2 = {
3826
4451
  description: [
3827
4452
  "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.",
3828
4453
  "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.",
3829
- "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."
4454
+ "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."
3830
4455
  ],
3831
4456
  errors: {
3832
4457
  connect: {
@@ -3840,7 +4465,7 @@ var shopify_default2 = {
3840
4465
  "Open the Drawbridge listing on the Shopify App Store.",
3841
4466
  "Install the app on the store you want to connect. It opens in Shopify admin and stays there.",
3842
4467
  "Approve the Drawbridge plan when prompted \u2014 during install, or from the connection page here. The connection shows Pending until you do, then Active.",
3843
- "Come back here \u2014 the connections list updates on its own once the install lands."
4468
+ "Come back here \u2014 the connections list updates on its own once the install finishes."
3844
4469
  ],
3845
4470
  // Names where the link GOES rather than what it does: installing happens on
3846
4471
  // the App Store listing, and the dashboard must never imply a store can be
@@ -4216,9 +4841,11 @@ var shopify_default2 = {
4216
4841
  phone: customerPhone
4217
4842
  } : null;
4218
4843
  const source = (connection2 == null ? void 0 : connection2.source) ? { domain: connection2.source.domain, id: connection2.source.id } : void 0;
4219
- const orderDocId = (existingOrder == null ? void 0 : existingOrder.id) || (isConversion && !backfill ? mintId() : null);
4844
+ const createsOrder = !backfill && (isConversion || Boolean(discount));
4845
+ const orderDocId = (existingOrder == null ? void 0 : existingOrder.id) || (createsOrder ? mintId() : null);
4846
+ const redemptionDocId = discount ? mintId() : null;
4220
4847
  const writes = [];
4221
- if (isConversion && !backfill) {
4848
+ if (createsOrder) {
4222
4849
  writes.push({
4223
4850
  collection: "order",
4224
4851
  data: {
@@ -4239,15 +4866,25 @@ var shopify_default2 = {
4239
4866
  provider: { id: String(orderId), slug: "shopify" },
4240
4867
  purchasedAt,
4241
4868
  rate,
4869
+ // Null on a conversion that matched no code of ours; the
4870
+ // backfill branch below sets it when one arrives later.
4871
+ redemption: redemptionDocId,
4242
4872
  source,
4243
- status: "completed"
4873
+ status: "completed",
4874
+ type: isConversion ? "conversion" : "redemption"
4244
4875
  },
4245
4876
  operation: "create"
4246
4877
  });
4247
4878
  if (org == null ? void 0 : org.usage) {
4248
4879
  writes.push({
4249
4880
  collection: "usage",
4250
- data: { $inc: { "totals.revenue": gross } },
4881
+ // TWO METERS, NOT ONE SUMMED. `revenue` has always meant
4882
+ // conversion revenue and is the figure the fee is charged
4883
+ // against, so redemption money gets its own key rather than
4884
+ // changing what an existing number means.
4885
+ data: {
4886
+ $inc: isConversion ? { "totals.revenue": gross } : { "totals.redemptionRevenue": gross }
4887
+ },
4251
4888
  operation: "update",
4252
4889
  query: { id: org.usage }
4253
4890
  });
@@ -4255,7 +4892,12 @@ var shopify_default2 = {
4255
4892
  if (leadId) {
4256
4893
  writes.push({
4257
4894
  collection: "lead",
4258
- data: { $inc: { "totals.orders": 1 } },
4895
+ // Same grouped shape the contact carries, so a lead and the
4896
+ // contact built from it cannot be read two different ways.
4897
+ data: { $inc: {
4898
+ "totals.orders.total": 1,
4899
+ ...isConversion ? { "totals.orders.conversions": 1 } : { "totals.orders.redemptions": 1 }
4900
+ } },
4259
4901
  operation: "update",
4260
4902
  options: { bypassDocumentValidation: true },
4261
4903
  query: { id: leadId }
@@ -4274,6 +4916,7 @@ var shopify_default2 = {
4274
4916
  customer,
4275
4917
  discount,
4276
4918
  gross,
4919
+ id: redemptionDocId,
4277
4920
  lead: leadId,
4278
4921
  order: orderDocId,
4279
4922
  organization: campaignOrganization,
@@ -4302,6 +4945,14 @@ var shopify_default2 = {
4302
4945
  query: { id: leadId }
4303
4946
  });
4304
4947
  }
4948
+ if (backfill && orderDocId && redemptionDocId) {
4949
+ writes.push({
4950
+ collection: "order",
4951
+ data: { $set: { redemption: redemptionDocId } },
4952
+ operation: "update",
4953
+ query: { id: orderDocId }
4954
+ });
4955
+ }
4305
4956
  }
4306
4957
  const billable = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && !backfill;
4307
4958
  const providerRow = billable ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
@@ -4680,7 +5331,7 @@ var shopify_default2 = {
4680
5331
  event: "shopify.register.webhooks"
4681
5332
  },
4682
5333
  name: "register",
4683
- options: { jobId: "connection.update.register." + workflow.connection + "." + randomUUID() },
5334
+ options: { jobId: "connection.update.register." + workflow.connection + "." + randomUUID2() },
4684
5335
  queue: "connection"
4685
5336
  }],
4686
5337
  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." : ""),
@@ -4794,10 +5445,19 @@ var shopify_default2 = {
4794
5445
  // picker stores, which is why the tail is taken here rather than by
4795
5446
  // each caller that happened to remember.
4796
5447
  items: ((discounts == null ? void 0 : discounts.edges) || []).map((edge) => {
4797
- var _a2, _b2, _c;
5448
+ var _a2, _b2;
5449
+ const node = ((_a2 = edge == null ? void 0 : edge.node) == null ? void 0 : _a2.codeDiscount) || {};
4798
5450
  return {
4799
- id: String(((_a2 = edge == null ? void 0 : edge.node) == null ? void 0 : _a2.id) || "").split("/").pop(),
4800
- title: (_c = (_b2 = edge == null ? void 0 : edge.node) == null ? void 0 : _b2.codeDiscount) == null ? void 0 : _c.title
5451
+ // Null when the discount can be used, a sentence when it cannot.
5452
+ // The picker greys the row and shows this instead of hiding it:
5453
+ // a discount the merchant can see in Shopify admin, missing here
5454
+ // with no explanation, reads as a bug in us.
5455
+ blocked: blockedReason(node),
5456
+ id: String(((_b2 = edge == null ? void 0 : edge.node) == null ? void 0 : _b2.id) || "").split("/").pop(),
5457
+ // Usable, but not in the way the merchant probably expects.
5458
+ // Shown beside the row without stopping them.
5459
+ warning: discountWarning(node),
5460
+ title: node.title
4801
5461
  };
4802
5462
  }),
4803
5463
  pageInfo: {
@@ -4812,20 +5472,6 @@ var shopify_default2 = {
4812
5472
  },
4813
5473
  icon: shopify_default,
4814
5474
  inbound,
4815
- // THE DEEP LINK into this store's Drawbridge app inside Shopify admin.
4816
- //
4817
- // Here rather than in drawbridge-api, which had `slug === 'shopify' && {...}`
4818
- // in the shared resolver — a hardcoded vendor branch in code every vendor runs
4819
- // through, which is the arrangement these manifests exist to remove.
4820
- //
4821
- // Undefined until a shop is linked, so the Manage button only appears on a
4822
- // connected connection. The app handle is NAMED by `requires` and read from
4823
- // the env the resolver passes, never from process.env here.
4824
- manage: (data2, env) => {
4825
- var _a;
4826
- const shop = (data2 == null ? void 0 : data2.shop) || ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.domain);
4827
- return shop ? "https://admin.shopify.com/store/" + String(shop).replace(".myshopify.com", "") + "/apps/" + (env == null ? void 0 : env.SHOPIFY_APP_HANDLE) : void 0;
4828
- },
4829
5475
  // DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
4830
5476
  // what an admin types on the provider screen. The four names below are exactly
4831
5477
  // what `requires` gates on, which is the point of declaring them together: a
@@ -4871,6 +5517,14 @@ var shopify_default2 = {
4871
5517
  "SHOPIFY_APP_LISTING_URL",
4872
5518
  "SHOPIFY_APP_HANDLE"
4873
5519
  ],
5520
+ // THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
5521
+ review: {
5522
+ api: "https://shopify.dev/docs/api/admin-graphql",
5523
+ dashboard: "https://help.shopify.com/en/manual/apps",
5524
+ scopes: "https://shopify.dev/docs/api/usage/access-scopes",
5525
+ content: "2026-09-11",
5526
+ verified: null
5527
+ },
4874
5528
  slug: "shopify",
4875
5529
  // The install is the whole configuration — Shopify hands back the shop and
4876
5530
  // there is nothing further to choose. `shop` absent means the install did not
@@ -4958,8 +5612,11 @@ var shopify_default2 = {
4958
5612
  })
4959
5613
  },
4960
5614
  // SYSTEM STEPS: dispatched by drawbridge-sync itself rather than offered
4961
- // in the builder, so they carry no trigger and no usage. Declared because
4962
- // the routing table and the system-workflow descriptions both read here.
5615
+ // in the builder, so they carry no usage. These two are fired by a webhook
5616
+ // arriving rather than by a workflow trigger, so they name none either —
5617
+ // and naming none is what stops a workflow being provisioned for them.
5618
+ // Declared because the routing table and the system-workflow descriptions
5619
+ // both read here.
4963
5620
  order: {
4964
5621
  record: () => ({
4965
5622
  description: "Records an order and billing charge when a purchase is made via a Drawbridge campaign link.",
@@ -4991,7 +5648,8 @@ var shopify_default2 = {
4991
5648
  hook: "lifecycle.health",
4992
5649
  key: "Shopify Connection Health",
4993
5650
  queue: "connection",
4994
- system: true
5651
+ system: true,
5652
+ trigger: { event: "day", type: "schedule" }
4995
5653
  })
4996
5654
  },
4997
5655
  // Audit-only. The "Shopify Token Activity" system workflow lists these
@@ -5052,7 +5710,30 @@ var shopify_default2 = {
5052
5710
  ] : []
5053
5711
  ];
5054
5712
  },
5055
- title: "Shopify"
5713
+ title: "Shopify",
5714
+ // THE VENDOR'S OWN ADMIN, one function per thing worth linking to. It lives
5715
+ // here rather than at the top level so a second link (a product, an order)
5716
+ // is a key in this object instead of a new manifest key nobody agreed on.
5717
+ //
5718
+ // AND HERE RATHER THAN IN drawbridge-api, which had `slug === 'shopify' &&
5719
+ // {...}` in the shared resolver — a hardcoded vendor branch in code every
5720
+ // vendor runs through, which is the arrangement these manifests exist to
5721
+ // remove.
5722
+ //
5723
+ // Never projected: the api composes connect.manage from it, and
5724
+ // resolveConnection drops the object, because a url built from settings is
5725
+ // built where the settings are already decrypted.
5726
+ urls: {
5727
+ // Undefined until a shop is linked, so the Manage button only appears on a
5728
+ // connected connection. The app handle is NAMED by `requires` and read from
5729
+ // the env its caller passes — the api's resolve() hands it the stored
5730
+ // credentials, never process.env.
5731
+ manage: (data2, env) => {
5732
+ var _a;
5733
+ const shop = (data2 == null ? void 0 : data2.shop) || ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.domain);
5734
+ return shop ? "https://admin.shopify.com/store/" + String(shop).replace(".myshopify.com", "") + "/apps/" + (env == null ? void 0 : env.SHOPIFY_APP_HANDLE) : void 0;
5735
+ }
5736
+ }
5056
5737
  };
5057
5738
 
5058
5739
  // lib/connections/providers/webhook.js
@@ -5232,7 +5913,7 @@ var webhook_default = {
5232
5913
  content: {
5233
5914
  confirm: "Disconnecting stops Drawbridge from sending signed webhook payloads to your endpoint.",
5234
5915
  description: [
5235
- "Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react in real time.",
5916
+ "Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react to it.",
5236
5917
  "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."
5237
5918
  ],
5238
5919
  excerpt: "Sign outgoing webhook payloads with an HMAC secret to verify authenticity.",
@@ -5333,6 +6014,9 @@ var webhook_default = {
5333
6014
  // Gated on the encryption secret: without it the signing secret could not be
5334
6015
  // stored safely, so the connection must not be offered at all.
5335
6016
  requires: ["ENCRYPT_CONNECTION_SECRET"],
6017
+ // NO THIRD PARTY AT ALL. There is no vendor reference to read, no dashboard
6018
+ // to link to and no scope to request: connecting mints a secret.
6019
+ review: false,
5336
6020
  // Outbound only. inbound.* is false because the direction is the point: we
5337
6021
  // sign and POST to the merchant's endpoint, they never call us. Every other
5338
6022
  // false follows from there being no third party to authenticate against —
@@ -5722,7 +6406,7 @@ var redactSettings = ({ slug: slug2, settings }) => {
5722
6406
  var publicConnectionKeys = Object.freeze([
5723
6407
  "actions",
5724
6408
  // API-COMPOSED, not manifest-declared: the api's resolve() builds it from
5725
- // auth.type, content.redirect and the manifest's manage() — the client reads
6409
+ // auth.type, content.redirect and the manifest's urls.manage() — the client reads
5726
6410
  // connect.type to choose entered-vs-installed, connect.redirect for the App
5727
6411
  // Store link, connect.manage for the admin deep link. It was dropped from
5728
6412
  // this list when the manifests stopped declaring it, which stripped the
@@ -5777,7 +6461,7 @@ var projectConnection = (record) => {
5777
6461
  var resolveConnection = (item, data2, env = {}) => {
5778
6462
  if (!item) return item;
5779
6463
  return Object.fromEntries(
5780
- Object.entries(item).filter(([key]) => !["auth", "enabled", "fields", "hooks", "inbound", "provider", "requires", "status", "steps", "supports"].includes(key)).map(([key, value]) => [
6464
+ Object.entries(item).filter(([key]) => !["auth", "enabled", "fields", "hooks", "inbound", "provider", "requires", "review", "status", "steps", "supports", "urls"].includes(key)).map(([key, value]) => [
5781
6465
  key,
5782
6466
  typeof value === "function" ? value(data2, env) : value
5783
6467
  ])