@aglyn/tenant-data-admin 1.0.0-beta.218 → 1.0.0-beta.220

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.
Files changed (156) hide show
  1. package/package.json +8 -8
  2. package/src/lib/server/api-http.js +5 -1
  3. package/src/lib/server/api-http.js.map +1 -1
  4. package/src/lib/server/api-v1-description.d.ts +112 -0
  5. package/src/lib/server/api-v1-description.js +126 -0
  6. package/src/lib/server/api-v1-description.js.map +1 -0
  7. package/src/lib/server/api-v1-kit.d.ts +163 -0
  8. package/src/lib/server/api-v1-kit.js +191 -0
  9. package/src/lib/server/api-v1-kit.js.map +1 -0
  10. package/src/lib/server/api-v1-resources.d.ts +143 -0
  11. package/src/lib/server/api-v1-resources.js +125 -0
  12. package/src/lib/server/api-v1-resources.js.map +1 -0
  13. package/src/lib/server/campaign-conversion-attribution.d.ts +1 -1
  14. package/src/lib/server/campaign-conversion-attribution.js +3 -3
  15. package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
  16. package/src/lib/server/card-payment-velocity.d.ts +9 -0
  17. package/src/lib/server/card-payment-velocity.js +28 -2
  18. package/src/lib/server/card-payment-velocity.js.map +1 -1
  19. package/src/lib/server/consent-group-carry.js +1 -1
  20. package/src/lib/server/consent-group-carry.js.map +1 -1
  21. package/src/lib/server/consent-group-change.d.ts +2 -1
  22. package/src/lib/server/consent-group-change.js +43 -18
  23. package/src/lib/server/consent-group-change.js.map +1 -1
  24. package/src/lib/server/contact-email-index.d.ts +22 -0
  25. package/src/lib/server/contact-email-index.js +108 -0
  26. package/src/lib/server/contact-email-index.js.map +1 -1
  27. package/src/lib/server/crm-email-activity.d.ts +1 -1
  28. package/src/lib/server/crm-email-activity.js +1 -1
  29. package/src/lib/server/crm-email-activity.js.map +1 -1
  30. package/src/lib/server/crm-records.d.ts +9 -1
  31. package/src/lib/server/crm-records.js +19 -2
  32. package/src/lib/server/crm-records.js.map +1 -1
  33. package/src/lib/server/cron-auth.d.ts +44 -0
  34. package/src/lib/server/cron-auth.js +76 -0
  35. package/src/lib/server/cron-auth.js.map +1 -0
  36. package/src/lib/server/dataset-live-pages.js +6 -6
  37. package/src/lib/server/dataset-live-pages.js.map +1 -1
  38. package/src/lib/server/duplicate-resource.d.ts +26 -0
  39. package/src/lib/server/duplicate-resource.js +47 -25
  40. package/src/lib/server/duplicate-resource.js.map +1 -1
  41. package/src/lib/server/dynamic-list-materialize.js +6 -3
  42. package/src/lib/server/dynamic-list-materialize.js.map +1 -1
  43. package/src/lib/server/email-flow-gate.js +2 -2
  44. package/src/lib/server/email-flow-gate.js.map +1 -1
  45. package/src/lib/server/email-suppression.d.ts +11 -0
  46. package/src/lib/server/email-suppression.js +37 -2
  47. package/src/lib/server/email-suppression.js.map +1 -1
  48. package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
  49. package/src/lib/server/email-unsubscribe-link.js +7 -7
  50. package/src/lib/server/email-unsubscribe-link.js.map +1 -1
  51. package/src/lib/server/erase.d.ts +19 -33
  52. package/src/lib/server/erase.js +37 -162
  53. package/src/lib/server/erase.js.map +1 -1
  54. package/src/lib/server/host-email-tokens.js +1 -1
  55. package/src/lib/server/host-email-tokens.js.map +1 -1
  56. package/src/lib/server/host-memberships.js +3 -3
  57. package/src/lib/server/host-memberships.js.map +1 -1
  58. package/src/lib/server/host-visitor-records.d.ts +32 -4
  59. package/src/lib/server/host-visitor-records.js +73 -14
  60. package/src/lib/server/host-visitor-records.js.map +1 -1
  61. package/src/lib/server/hosted-page-review.d.ts +27 -0
  62. package/src/lib/server/hosted-page-review.js +149 -12
  63. package/src/lib/server/hosted-page-review.js.map +1 -1
  64. package/src/lib/server/media-cdn-rate-limit.d.ts +6 -3
  65. package/src/lib/server/media-cdn-rate-limit.js +6 -3
  66. package/src/lib/server/media-cdn-rate-limit.js.map +1 -1
  67. package/src/lib/server/member-email-aliases.d.ts +10 -2
  68. package/src/lib/server/member-email-aliases.js +26 -2
  69. package/src/lib/server/member-email-aliases.js.map +1 -1
  70. package/src/lib/server/new-account-announcement.d.ts +62 -0
  71. package/src/lib/server/new-account-announcement.js +77 -0
  72. package/src/lib/server/new-account-announcement.js.map +1 -0
  73. package/src/lib/server/notifications.d.ts +22 -2
  74. package/src/lib/server/notifications.js +40 -3
  75. package/src/lib/server/notifications.js.map +1 -1
  76. package/src/lib/server/operator-alerts.d.ts +7 -1
  77. package/src/lib/server/operator-alerts.js +5 -3
  78. package/src/lib/server/operator-alerts.js.map +1 -1
  79. package/src/lib/server/org-containers.d.ts +66 -0
  80. package/src/lib/server/org-containers.js +95 -0
  81. package/src/lib/server/org-containers.js.map +1 -0
  82. package/src/lib/server/org-member-notice.js +3 -2
  83. package/src/lib/server/org-member-notice.js.map +1 -1
  84. package/src/lib/server/payment-fraud-signal.js +5 -1
  85. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  86. package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
  87. package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +36 -2
  88. package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
  89. package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
  90. package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
  91. package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
  92. package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
  93. package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
  94. package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
  95. package/src/lib/server/payment-provider.d.ts +173 -0
  96. package/src/lib/server/payment-provider.js +70 -0
  97. package/src/lib/server/payment-provider.js.map +1 -0
  98. package/src/lib/server/payment-risk-record.d.ts +1 -1
  99. package/src/lib/server/payment-risk-record.js +3 -1
  100. package/src/lib/server/payment-risk-record.js.map +1 -1
  101. package/src/lib/server/personal-data-export.js +1 -1
  102. package/src/lib/server/personal-data-export.js.map +1 -1
  103. package/src/lib/server/platform-marketing-consent.d.ts +1 -1
  104. package/src/lib/server/platform-marketing-consent.js +1 -1
  105. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  106. package/src/lib/server/rate-limit-store.js +13 -3
  107. package/src/lib/server/rate-limit-store.js.map +1 -1
  108. package/src/lib/server/realm-plugins.js +8 -3
  109. package/src/lib/server/realm-plugins.js.map +1 -1
  110. package/src/lib/server/retained-refusals.d.ts +142 -0
  111. package/src/lib/server/retained-refusals.js +325 -0
  112. package/src/lib/server/retained-refusals.js.map +1 -0
  113. package/src/lib/server/risk-notice.d.ts +32 -1
  114. package/src/lib/server/risk-notice.js +90 -28
  115. package/src/lib/server/risk-notice.js.map +1 -1
  116. package/src/lib/server/sending-domains.d.ts +17 -0
  117. package/src/lib/server/sending-domains.js +28 -4
  118. package/src/lib/server/sending-domains.js.map +1 -1
  119. package/src/lib/server/sso-enforcement.js +7 -2
  120. package/src/lib/server/sso-enforcement.js.map +1 -1
  121. package/src/lib/server/staff-alert-email.js +4 -2
  122. package/src/lib/server/staff-alert-email.js.map +1 -1
  123. package/src/lib/server/test-firestore-queries.d.ts +7 -0
  124. package/src/lib/server/test-firestore-queries.js +70 -27
  125. package/src/lib/server/test-firestore-queries.js.map +1 -1
  126. package/src/lib/server/{email-topic-confirmation.js → topic-subscriptions.js} +15 -11
  127. package/src/lib/server/topic-subscriptions.js.map +1 -0
  128. package/src/lib/server/upsert-contact.d.ts +46 -1
  129. package/src/lib/server/upsert-contact.js +87 -20
  130. package/src/lib/server/upsert-contact.js.map +1 -1
  131. package/src/tenancy.d.ts +5 -8
  132. package/src/tenancy.js +12 -13
  133. package/src/tenancy.js.map +1 -1
  134. package/src/lib/server/connect-account-status.js.map +0 -1
  135. package/src/lib/server/connect-payout-failure.js.map +0 -1
  136. package/src/lib/server/contact-email-engagement.d.ts +0 -32
  137. package/src/lib/server/contact-email-engagement.js +0 -145
  138. package/src/lib/server/contact-email-engagement.js.map +0 -1
  139. package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
  140. package/src/lib/server/contact-lifecycle-floor.js +0 -91
  141. package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
  142. package/src/lib/server/contact-merge.d.ts +0 -55
  143. package/src/lib/server/contact-merge.js +0 -288
  144. package/src/lib/server/contact-merge.js.map +0 -1
  145. package/src/lib/server/crm-booking-activity.d.ts +0 -86
  146. package/src/lib/server/crm-booking-activity.js +0 -257
  147. package/src/lib/server/crm-booking-activity.js.map +0 -1
  148. package/src/lib/server/crm-inbound-email.d.ts +0 -145
  149. package/src/lib/server/crm-inbound-email.js +0 -297
  150. package/src/lib/server/crm-inbound-email.js.map +0 -1
  151. package/src/lib/server/email-topic-confirmation.js.map +0 -1
  152. package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
  153. package/src/lib/server/form-dataset-binding-token.js +0 -107
  154. package/src/lib/server/form-dataset-binding-token.js.map +0 -1
  155. package/src/lib/server/stripe-account-mode.js.map +0 -1
  156. /package/src/lib/server/{email-topic-confirmation.d.ts → topic-subscriptions.d.ts} +0 -0
@@ -1,297 +0,0 @@
1
- import { _ as _extends } from "@swc/helpers/_/_extends";
2
- /**
3
- * @license
4
- * Copyright 2026 Aglyn LLC
5
- *
6
- * Licensed under the Apache License, Version 2.0 (the "License");
7
- * you may not use this file except in compliance with the License.
8
- * You may obtain a copy of the License at
9
- *
10
- * http://www.apache.org/licenses/LICENSE-2.0
11
- *
12
- * Unless required by applicable law or agreed to in writing, software
13
- * distributed under the License is distributed on an "AS IS" BASIS,
14
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
- * See the License for the specific language governing permissions and
16
- * limitations under the License.
17
- */ import { buildCrmCapturedEmailActivity, consentGroupForHost, contactCaptureHostIds, crmActivityLogHasRoom, crmCapturedEmailKey, crmInboundCandidates, crmInboundExcerpt, crmInboundRoster, crmScopeTokens, emailAddressOf, emailDomainOf, findMemberByEmailAddress, forwardedSection, htmlToPlainText, isCrmInboundAddress, isCrmInboundToken, mintCrmInboundToken, personKey, readContactFacet } from "@aglyn/aglyn/server";
18
- import { MEMBER_EMAIL_ALIASES_COLLECTION } from "@aglyn/aglyn/app-utils/member-email-aliases";
19
- import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
20
- import { findContactByEmail } from "./contact-email-index.js";
21
- import { createCrmEmailActivity, crmCapturedEmailActivityRef, newCrmActivityRef } from "./crm-email-activity.js";
22
- import { countCrmActivitiesForRecord } from "./crm-records.js";
23
- /**
24
- * EMAIL CAPTURE (AGL-2657): the half with Firestore.
25
- *
26
- * The org's capture token — minted once, replaced on a rotation, found
27
- * again from a message's recipient — and the filing of one received
28
- * message: which record it was with, whether it has been filed before, and
29
- * the row it becomes. The pure rules are `@aglyn/aglyn`'s
30
- * `crm-inbound.ts`; this module is the reads and the one write.
31
- *
32
- * Firestore is a parameter throughout, as in `crm-records.ts` and
33
- * `crm-email-activity.ts`: the routes hold one, and a module that reached
34
- * for the Admin app itself would drag it into every spec that only wants
35
- * the matching. The roster is read through that same store by
36
- * `loadCrmInboundRoster`, and the caller writes the org's feed line, so a
37
- * spec of this module needs a store and nothing else.
38
- */ /** The most candidate addresses one message is matched by. */ export const CRM_INBOUND_CANDIDATE_MAX = 6;
39
- /** The most sites whose leads are consulted for one message. */ export const CRM_INBOUND_LEAD_HOST_MAX = 50;
40
- /**
41
- * The org's capture token, minted the first time anybody asks and kept
42
- * thereafter; with `rotate`, replaced. A transaction, so two members
43
- * opening the settings page at once mint one token and not two, and a
44
- * rotation lands on the token the reader saw.
45
- */ export async function ensureCrmInboundToken(firestore, orgId, options = {}) {
46
- var _options_nowMs;
47
- const rotate = options.rotate === true;
48
- const nowMs = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
49
- const orgRef = firestore.collection('orgs').doc(orgId);
50
- return firestore.runTransaction(async (tx)=>{
51
- var _snapshot_get;
52
- const snapshot = await tx.get(orgRef);
53
- if (!snapshot.exists) throw new Error(`[crm-inbound] unknown org ${orgId}`);
54
- const current = (_snapshot_get = snapshot.get('crmInbound')) != null ? _snapshot_get : null;
55
- const held = isCrmInboundToken(current == null ? void 0 : current.token);
56
- if (held && !rotate) {
57
- const createdAtMs = Number(current == null ? void 0 : current.createdAtMs);
58
- return _extends({
59
- token: String(current == null ? void 0 : current.token),
60
- createdAtMs: Number.isFinite(createdAtMs) && createdAtMs > 0 ? createdAtMs : nowMs
61
- }, typeof (current == null ? void 0 : current.rotatedAtMs) === 'number' ? {
62
- rotatedAtMs: current.rotatedAtMs
63
- } : {}, {
64
- rotated: false
65
- });
66
- }
67
- const createdAtMs = held && Number.isFinite(Number(current == null ? void 0 : current.createdAtMs)) && Number(current == null ? void 0 : current.createdAtMs) > 0 ? Number(current == null ? void 0 : current.createdAtMs) : nowMs;
68
- const next = _extends({
69
- token: mintCrmInboundToken(),
70
- createdAtMs
71
- }, held ? {
72
- rotatedAtMs: nowMs
73
- } : {});
74
- tx.update(orgRef, {
75
- crmInbound: next
76
- });
77
- return _extends({}, next, {
78
- rotated: held
79
- });
80
- });
81
- }
82
- /**
83
- * The org a capture token names, or `null`. One equality on
84
- * `crmInbound.token`, which Firestore indexes on its own; a token that is
85
- * not one by shape is refused before the query, so a malformed local part
86
- * costs no read.
87
- */ export async function findOrgByCrmInboundToken(firestore, token) {
88
- var _doc_data;
89
- if (!isCrmInboundToken(token)) return null;
90
- const page = await firestore.collection('orgs').where('crmInbound.token', '==', token).limit(1).get();
91
- const doc = page.docs[0];
92
- if (!doc) return null;
93
- return {
94
- orgId: doc.id,
95
- org: _extends({
96
- $id: doc.id
97
- }, (_doc_data = doc.data()) != null ? _doc_data : {})
98
- };
99
- }
100
- /**
101
- * Every site of the organization, by id, up to the ceiling — the set a
102
- * message's lead lookup sweeps. One equality on `hosts.orgId`.
103
- */ export async function crmInboundHostIds(firestore, orgId) {
104
- const page = await firestore.collection('hosts').where('orgId', '==', orgId).limit(CRM_INBOUND_LEAD_HOST_MAX).get();
105
- return page.docs.map((doc)=>String(doc.id));
106
- }
107
- /**
108
- * The first candidate that is a contact of the org — through the address
109
- * index, so a merged record's alternate address answers — or a lead on any
110
- * of its sites. Candidates are tried in the order the rule gave them and
111
- * the first hit wins; a contact is looked for before a lead at each
112
- * address, because a converted lead's address is its contact's.
113
- */ export async function matchCrmInboundCorrespondent(firestore, input) {
114
- const { orgId, org } = input;
115
- const orgRef = firestore.collection('orgs').doc(orgId);
116
- const contacts = orgRef.collection('contacts');
117
- const tokensOf = (record)=>{
118
- const raw = record['visibleTo'];
119
- return Array.isArray(raw) ? raw.map((token)=>String(token)) : [];
120
- };
121
- for (const candidate of input.candidates.slice(0, CRM_INBOUND_CANDIDATE_MAX)){
122
- const contact = await findContactByEmail(contacts, candidate.email);
123
- if (contact) {
124
- var _contact_data, _record_hostId;
125
- const record = (_contact_data = contact.data()) != null ? _contact_data : {};
126
- const hostId = String((_record_hostId = record['hostId']) != null ? _record_hostId : '').trim() || contactCaptureHostIds(record)[0] || '';
127
- const link = {
128
- contactId: contact.id
129
- };
130
- if (hostId) {
131
- // The company the capturing site files this person under, so the
132
- // row shows on the company's log as a sent email's does.
133
- const group = consentGroupForHost(org, hostId);
134
- const facet = readContactFacet(record, group.groupId);
135
- if (facet.companyId) link.companyId = facet.companyId;
136
- }
137
- return {
138
- email: candidate.email,
139
- direction: candidate.direction,
140
- kind: 'contact',
141
- link,
142
- hostId,
143
- visibleTo: tokensOf(record)
144
- };
145
- }
146
- const key = personKey(candidate.email);
147
- if (!key) continue;
148
- /*
149
- * ONE ROW, then which of the named sites may see it (AGL-3275).
150
- *
151
- * This used to read a lead per site because there was one per site. There
152
- * is one now, so the read is single and the LOOP is what it always really
153
- * was: deciding whose mailbox this reply belongs to. A site that cannot
154
- * see the row is not that site, which is what `visibleToHost` answers —
155
- * and skipping it is what keeps an agency's clients apart on a shared row.
156
- */ const lead = await orgRef.collection('leads').doc(key).get();
157
- for (const hostId of input.hostIds.slice(0, CRM_INBOUND_LEAD_HOST_MAX)){
158
- var _lead_get;
159
- if (!lead.exists) break;
160
- if (!visibleToHost(lead.get('visibleTo'), hostId)) continue;
161
- const link = {
162
- leadId: lead.id
163
- };
164
- // A converted lead's mail belongs on the contact it became as well.
165
- const converted = String((_lead_get = lead.get('convertedContactId')) != null ? _lead_get : '').trim();
166
- if (converted) link.contactId = converted;
167
- return {
168
- email: candidate.email,
169
- direction: candidate.direction,
170
- kind: 'lead',
171
- link,
172
- hostId,
173
- // A lead carries no tokens of its own: the site's scope, as every
174
- // record created from that site is stamped.
175
- visibleTo: crmScopeTokens(org, consentGroupForHost(org, hostId))
176
- };
177
- }
178
- }
179
- return null;
180
- }
181
- /**
182
- * The workspace's roster as the filer matches against it: the member
183
- * documents and every member's stored alias document, two reads under the
184
- * org, reduced by `crmInboundRoster` to each member's sign-in address and
185
- * the aliases they have confirmed (AGL-2975).
186
- */ export async function loadCrmInboundRoster(firestore, orgId) {
187
- const orgRef = firestore.collection('orgs').doc(orgId);
188
- const [members, aliases] = await Promise.all([
189
- orgRef.collection('members').get(),
190
- orgRef.collection(MEMBER_EMAIL_ALIASES_COLLECTION).get()
191
- ]);
192
- const rows = members.docs.map((doc)=>{
193
- var _doc_data;
194
- return _extends({}, (_doc_data = doc.data()) != null ? _doc_data : {}, {
195
- $id: doc.id
196
- });
197
- });
198
- const aliasDocuments = new Map(aliases.docs.map((doc)=>[
199
- String(doc.id),
200
- doc.data()
201
- ]));
202
- return crmInboundRoster(rows, aliasDocuments);
203
- }
204
- /**
205
- * Files one received message on the record it was with (AGL-2657).
206
- *
207
- * The order is the cost: the candidate rule is arithmetic; the match is
208
- * the reads that decide everything after it; a message filed before is
209
- * answered off one read of the row it would write, before the ceiling's
210
- * aggregate; the ceiling comes last and refuses rather than dropping a row
211
- * on a full log. The write is a `create`, so two deliveries racing past
212
- * the read still yield one row. The BODY never leaves this function except
213
- * as the bounded excerpt in the row.
214
- */ export async function fileCrmInboundEmail(firestore, input) {
215
- var _ref, _message_to_map_find, _ref1, _ref2;
216
- const { orgId, org, message, domain } = input;
217
- const plain = (message.text.trim() ? message.text : htmlToPlainText(message.html)).replace(/\r\n?/g, '\n');
218
- const forwarded = forwardedSection(plain);
219
- const { sender, senderIsMember, candidates } = crmInboundCandidates({
220
- from: message.from,
221
- to: [
222
- ...message.to,
223
- ...message.receivedFor
224
- ],
225
- cc: [
226
- ...message.cc,
227
- ...message.bcc
228
- ],
229
- forwardedFrom: (_ref = forwarded == null ? void 0 : forwarded.from) != null ? _ref : undefined,
230
- domain,
231
- members: input.members
232
- });
233
- const senderDomain = emailDomainOf(message.from);
234
- if (!candidates.length) return {
235
- outcome: 'unmatched',
236
- senderDomain
237
- };
238
- const match = await matchCrmInboundCorrespondent(firestore, {
239
- orgId,
240
- org,
241
- candidates,
242
- hostIds: input.hostIds
243
- });
244
- if (!match) return {
245
- outcome: 'unmatched',
246
- senderDomain
247
- };
248
- const key = crmCapturedEmailKey(message.messageId, message.id);
249
- const ref = key ? crmCapturedEmailActivityRef(firestore, orgId, key) : newCrmActivityRef(firestore, orgId);
250
- if (key) {
251
- const existing = await ref.get();
252
- if (existing.exists) return {
253
- outcome: 'duplicate',
254
- activityId: ref.id
255
- };
256
- }
257
- const orgRef = firestore.collection('orgs').doc(orgId);
258
- if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, match.link))) {
259
- return {
260
- outcome: 'ceiling',
261
- match
262
- };
263
- }
264
- // The member the From is — by sign-in address, or by an alias exactly one
265
- // member confirmed. A shared alias names the team and no one person.
266
- const author = senderIsMember ? findMemberByEmailAddress(input.members, sender) : null;
267
- // The address the message was written to, when it is worth showing: the
268
- // correspondent for a copied send, else the first recipient that is not
269
- // the capture address.
270
- const to = match.direction === 'outbound' ? match.email : (_message_to_map_find = message.to.map((value)=>emailAddressOf(value)).find((value)=>value && !isCrmInboundAddress(value, domain))) != null ? _message_to_map_find : null;
271
- const activity = buildCrmCapturedEmailActivity({
272
- direction: match.direction,
273
- subject: message.subject,
274
- excerpt: crmInboundExcerpt(message.text, message.html),
275
- from: sender != null ? sender : message.from,
276
- to,
277
- messageId: message.messageId || message.id,
278
- inReplyTo: message.inReplyTo,
279
- atMs: message.receivedAtMs,
280
- byUid: (_ref1 = author == null ? void 0 : author.uid) != null ? _ref1 : '',
281
- byName: (_ref2 = author == null ? void 0 : author.name) != null ? _ref2 : null,
282
- link: match.link,
283
- hostId: match.hostId,
284
- visibleTo: match.visibleTo
285
- });
286
- const created = await createCrmEmailActivity(ref, activity);
287
- return created === 'created' ? {
288
- outcome: 'filed',
289
- activityId: ref.id,
290
- match
291
- } : {
292
- outcome: 'duplicate',
293
- activityId: ref.id
294
- };
295
- }
296
-
297
- //# sourceMappingURL=crm-inbound-email.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/crm-inbound-email.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n buildCrmCapturedEmailActivity,\n consentGroupForHost,\n contactCaptureHostIds,\n crmActivityLogHasRoom,\n crmCapturedEmailKey,\n crmInboundCandidates,\n crmInboundExcerpt,\n crmInboundRoster,\n type CrmActivityLink,\n type CrmEmailDirection,\n type CrmInboundCandidate,\n type CrmInboundMember,\n type CrmInboundRosterRow,\n crmScopeTokens,\n emailAddressOf,\n emailDomainOf,\n findMemberByEmailAddress,\n forwardedSection,\n htmlToPlainText,\n isCrmInboundAddress,\n isCrmInboundToken,\n mintCrmInboundToken,\n personKey,\n readContactFacet,\n} from '@aglyn/aglyn/server'\nimport { MEMBER_EMAIL_ALIASES_COLLECTION } from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport type { OrgCrmInbound } from '@aglyn/aglyn/foundation'\nimport type { ReceivedEmail } from '@aglyn/shared-util-email'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { findContactByEmail } from './contact-email-index'\nimport {\n createCrmEmailActivity,\n crmCapturedEmailActivityRef,\n newCrmActivityRef,\n} from './crm-email-activity'\nimport { countCrmActivitiesForRecord } from './crm-records'\n\n/**\n * EMAIL CAPTURE (AGL-2657): the half with Firestore.\n *\n * The org's capture token — minted once, replaced on a rotation, found\n * again from a message's recipient — and the filing of one received\n * message: which record it was with, whether it has been filed before, and\n * the row it becomes. The pure rules are `@aglyn/aglyn`'s\n * `crm-inbound.ts`; this module is the reads and the one write.\n *\n * Firestore is a parameter throughout, as in `crm-records.ts` and\n * `crm-email-activity.ts`: the routes hold one, and a module that reached\n * for the Admin app itself would drag it into every spec that only wants\n * the matching. The roster is read through that same store by\n * `loadCrmInboundRoster`, and the caller writes the org's feed line, so a\n * spec of this module needs a store and nothing else.\n */\n\n/** The most candidate addresses one message is matched by. */\nexport const CRM_INBOUND_CANDIDATE_MAX = 6\n\n/** The most sites whose leads are consulted for one message. */\nexport const CRM_INBOUND_LEAD_HOST_MAX = 50\n\nexport interface CrmInboundTokenResult {\n token: string\n createdAtMs: number\n rotatedAtMs?: number\n /** True when this call replaced a token that existed. */\n rotated: boolean\n}\n\n/**\n * The org's capture token, minted the first time anybody asks and kept\n * thereafter; with `rotate`, replaced. A transaction, so two members\n * opening the settings page at once mint one token and not two, and a\n * rotation lands on the token the reader saw.\n */\nexport async function ensureCrmInboundToken(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n options: { rotate?: boolean; nowMs?: number } = {},\n): Promise<CrmInboundTokenResult> {\n const rotate = options.rotate === true\n const nowMs = options.nowMs ?? Date.now()\n const orgRef = firestore.collection('orgs').doc(orgId)\n return firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(orgRef)\n if (!snapshot.exists) throw new Error(`[crm-inbound] unknown org ${orgId}`)\n const current = (snapshot.get('crmInbound') ?? null) as Partial<OrgCrmInbound> | null\n const held = isCrmInboundToken(current?.token)\n if (held && !rotate) {\n const createdAtMs = Number(current?.createdAtMs)\n return {\n token: String(current?.token),\n createdAtMs: Number.isFinite(createdAtMs) && createdAtMs > 0 ? createdAtMs : nowMs,\n ...(typeof current?.rotatedAtMs === 'number' ? { rotatedAtMs: current.rotatedAtMs } : {}),\n rotated: false,\n }\n }\n const createdAtMs =\n held && Number.isFinite(Number(current?.createdAtMs)) && Number(current?.createdAtMs) > 0\n ? Number(current?.createdAtMs)\n : nowMs\n const next: OrgCrmInbound = {\n token: mintCrmInboundToken(),\n createdAtMs,\n ...(held ? { rotatedAtMs: nowMs } : {}),\n }\n tx.update(orgRef, { crmInbound: next })\n return { ...next, rotated: held }\n })\n}\n\n/**\n * The org a capture token names, or `null`. One equality on\n * `crmInbound.token`, which Firestore indexes on its own; a token that is\n * not one by shape is refused before the query, so a malformed local part\n * costs no read.\n */\nexport async function findOrgByCrmInboundToken(\n firestore: FirebaseFirestore.Firestore,\n token: unknown,\n): Promise<{ orgId: string; org: Record<string, unknown> } | null> {\n if (!isCrmInboundToken(token)) return null\n const page = await firestore\n .collection('orgs')\n .where('crmInbound.token', '==', token)\n .limit(1)\n .get()\n const doc = page.docs[0]\n if (!doc) return null\n return { orgId: doc.id, org: { $id: doc.id, ...(doc.data() ?? {}) } }\n}\n\n/**\n * Every site of the organization, by id, up to the ceiling — the set a\n * message's lead lookup sweeps. One equality on `hosts.orgId`.\n */\nexport async function crmInboundHostIds(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<string[]> {\n const page = await firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .limit(CRM_INBOUND_LEAD_HOST_MAX)\n .get()\n return page.docs.map((doc) => String(doc.id))\n}\n\n/** The record one of a message's addresses resolved to. */\nexport interface CrmInboundMatch {\n email: string\n direction: CrmEmailDirection\n kind: 'contact' | 'lead'\n /** Where the row is filed. */\n link: CrmActivityLink\n /** The site the row is stamped with — the record's own. */\n hostId: string\n /** Who may list the row — the record's own tokens. */\n visibleTo: string[]\n}\n\n/**\n * The first candidate that is a contact of the org — through the address\n * index, so a merged record's alternate address answers — or a lead on any\n * of its sites. Candidates are tried in the order the rule gave them and\n * the first hit wins; a contact is looked for before a lead at each\n * address, because a converted lead's address is its contact's.\n */\nexport async function matchCrmInboundCorrespondent(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n org: Record<string, unknown>\n candidates: readonly CrmInboundCandidate[]\n hostIds: readonly string[]\n },\n): Promise<CrmInboundMatch | null> {\n const { orgId, org } = input\n const orgRef = firestore.collection('orgs').doc(orgId)\n const contacts = orgRef.collection('contacts')\n const tokensOf = (record: Record<string, unknown>): string[] => {\n const raw = record['visibleTo']\n return Array.isArray(raw) ? raw.map((token) => String(token)) : []\n }\n for (const candidate of input.candidates.slice(0, CRM_INBOUND_CANDIDATE_MAX)) {\n const contact = await findContactByEmail(contacts, candidate.email)\n if (contact) {\n const record = (contact.data() ?? {}) as Record<string, unknown>\n const hostId =\n String(record['hostId'] ?? '').trim() || contactCaptureHostIds(record)[0] || ''\n const link: CrmActivityLink = { contactId: contact.id }\n if (hostId) {\n // The company the capturing site files this person under, so the\n // row shows on the company's log as a sent email's does.\n const group = consentGroupForHost(org, hostId)\n const facet = readContactFacet(record, group.groupId)\n if (facet.companyId) link.companyId = facet.companyId\n }\n return {\n email: candidate.email,\n direction: candidate.direction,\n kind: 'contact',\n link,\n hostId,\n visibleTo: tokensOf(record),\n }\n }\n const key = personKey(candidate.email)\n if (!key) continue\n /*\n * ONE ROW, then which of the named sites may see it (AGL-3275).\n *\n * This used to read a lead per site because there was one per site. There\n * is one now, so the read is single and the LOOP is what it always really\n * was: deciding whose mailbox this reply belongs to. A site that cannot\n * see the row is not that site, which is what `visibleToHost` answers —\n * and skipping it is what keeps an agency's clients apart on a shared row.\n */\n const lead = await orgRef.collection('leads').doc(key).get()\n for (const hostId of input.hostIds.slice(0, CRM_INBOUND_LEAD_HOST_MAX)) {\n if (!lead.exists) break\n if (!visibleToHost(lead.get('visibleTo') as string[] | undefined, hostId)) continue\n const link: CrmActivityLink = { leadId: lead.id }\n // A converted lead's mail belongs on the contact it became as well.\n const converted = String(lead.get('convertedContactId') ?? '').trim()\n if (converted) link.contactId = converted\n return {\n email: candidate.email,\n direction: candidate.direction,\n kind: 'lead',\n link,\n hostId,\n // A lead carries no tokens of its own: the site's scope, as every\n // record created from that site is stamped.\n visibleTo: crmScopeTokens(org, consentGroupForHost(org, hostId)),\n }\n }\n }\n return null\n}\n\n/**\n * The workspace's roster as the filer matches against it: the member\n * documents and every member's stored alias document, two reads under the\n * org, reduced by `crmInboundRoster` to each member's sign-in address and\n * the aliases they have confirmed (AGL-2975).\n */\nexport async function loadCrmInboundRoster(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<CrmInboundMember[]> {\n const orgRef = firestore.collection('orgs').doc(orgId)\n const [members, aliases] = await Promise.all([\n orgRef.collection('members').get(),\n orgRef.collection(MEMBER_EMAIL_ALIASES_COLLECTION).get(),\n ])\n const rows: CrmInboundRosterRow[] = members.docs.map((doc) => ({\n ...((doc.data() ?? {}) as Omit<CrmInboundRosterRow, '$id'>),\n $id: doc.id,\n }))\n const aliasDocuments = new Map<string, unknown>(\n aliases.docs.map((doc) => [String(doc.id), doc.data()]),\n )\n return crmInboundRoster(rows, aliasDocuments)\n}\n\nexport type CrmInboundFileOutcome =\n /** A row was written. */\n | { outcome: 'filed'; activityId: string; match: CrmInboundMatch }\n /** The message had been filed before; nothing written. */\n | { outcome: 'duplicate'; activityId: string }\n /** No address on the message is a record of the org; nothing written. */\n | { outcome: 'unmatched'; senderDomain: string | null }\n /** The record's activity log is at its ceiling; nothing written. */\n | { outcome: 'ceiling'; match: CrmInboundMatch }\n\n/**\n * Files one received message on the record it was with (AGL-2657).\n *\n * The order is the cost: the candidate rule is arithmetic; the match is\n * the reads that decide everything after it; a message filed before is\n * answered off one read of the row it would write, before the ceiling's\n * aggregate; the ceiling comes last and refuses rather than dropping a row\n * on a full log. The write is a `create`, so two deliveries racing past\n * the read still yield one row. The BODY never leaves this function except\n * as the bounded excerpt in the row.\n */\nexport async function fileCrmInboundEmail(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n org: Record<string, unknown>\n message: ReceivedEmail\n /** The capture domain, so the capture address is never the correspondent. */\n domain: string\n /** The roster, from `loadCrmInboundRoster`: sign-in addresses and confirmed aliases. */\n members: readonly CrmInboundMember[]\n hostIds: readonly string[]\n },\n): Promise<CrmInboundFileOutcome> {\n const { orgId, org, message, domain } = input\n const plain = (message.text.trim() ? message.text : htmlToPlainText(message.html)).replace(\n /\\r\\n?/g,\n '\\n',\n )\n const forwarded = forwardedSection(plain)\n const { sender, senderIsMember, candidates } = crmInboundCandidates({\n from: message.from,\n to: [...message.to, ...message.receivedFor],\n cc: [...message.cc, ...message.bcc],\n forwardedFrom: forwarded?.from ?? undefined,\n domain,\n members: input.members,\n })\n const senderDomain = emailDomainOf(message.from)\n if (!candidates.length) return { outcome: 'unmatched', senderDomain }\n\n const match = await matchCrmInboundCorrespondent(firestore, {\n orgId,\n org,\n candidates,\n hostIds: input.hostIds,\n })\n if (!match) return { outcome: 'unmatched', senderDomain }\n\n const key = crmCapturedEmailKey(message.messageId, message.id)\n const ref = key\n ? crmCapturedEmailActivityRef(firestore, orgId, key)\n : newCrmActivityRef(firestore, orgId)\n if (key) {\n const existing = await ref.get()\n if (existing.exists) return { outcome: 'duplicate', activityId: ref.id }\n }\n\n const orgRef = firestore.collection('orgs').doc(orgId)\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, match.link))) {\n return { outcome: 'ceiling', match }\n }\n\n // The member the From is — by sign-in address, or by an alias exactly one\n // member confirmed. A shared alias names the team and no one person.\n const author = senderIsMember ? findMemberByEmailAddress(input.members, sender) : null\n // The address the message was written to, when it is worth showing: the\n // correspondent for a copied send, else the first recipient that is not\n // the capture address.\n const to =\n match.direction === 'outbound'\n ? match.email\n : (message.to\n .map((value) => emailAddressOf(value))\n .find((value) => value && !isCrmInboundAddress(value, domain)) ?? null)\n const activity = buildCrmCapturedEmailActivity({\n direction: match.direction,\n subject: message.subject,\n excerpt: crmInboundExcerpt(message.text, message.html),\n from: sender ?? message.from,\n to,\n messageId: message.messageId || message.id,\n inReplyTo: message.inReplyTo,\n atMs: message.receivedAtMs,\n byUid: author?.uid ?? '',\n byName: author?.name ?? null,\n link: match.link,\n hostId: match.hostId,\n visibleTo: match.visibleTo,\n })\n const created = await createCrmEmailActivity(ref, activity)\n return created === 'created'\n ? { outcome: 'filed', activityId: ref.id, match }\n : { outcome: 'duplicate', activityId: ref.id }\n}\n"],"names":["buildCrmCapturedEmailActivity","consentGroupForHost","contactCaptureHostIds","crmActivityLogHasRoom","crmCapturedEmailKey","crmInboundCandidates","crmInboundExcerpt","crmInboundRoster","crmScopeTokens","emailAddressOf","emailDomainOf","findMemberByEmailAddress","forwardedSection","htmlToPlainText","isCrmInboundAddress","isCrmInboundToken","mintCrmInboundToken","personKey","readContactFacet","MEMBER_EMAIL_ALIASES_COLLECTION","visibleToHost","findContactByEmail","createCrmEmailActivity","crmCapturedEmailActivityRef","newCrmActivityRef","countCrmActivitiesForRecord","CRM_INBOUND_CANDIDATE_MAX","CRM_INBOUND_LEAD_HOST_MAX","ensureCrmInboundToken","firestore","orgId","options","rotate","nowMs","Date","now","orgRef","collection","doc","runTransaction","tx","snapshot","get","exists","Error","current","held","token","createdAtMs","Number","String","isFinite","rotatedAtMs","rotated","next","update","crmInbound","findOrgByCrmInboundToken","page","where","limit","docs","id","org","$id","data","crmInboundHostIds","map","matchCrmInboundCorrespondent","input","contacts","tokensOf","record","raw","Array","isArray","candidate","candidates","slice","contact","email","hostId","trim","link","contactId","group","facet","groupId","companyId","direction","kind","visibleTo","key","lead","hostIds","leadId","converted","loadCrmInboundRoster","members","aliases","Promise","all","rows","aliasDocuments","Map","fileCrmInboundEmail","message","domain","plain","text","html","replace","forwarded","sender","senderIsMember","from","to","receivedFor","cc","bcc","forwardedFrom","undefined","senderDomain","length","outcome","match","messageId","ref","existing","activityId","author","value","find","activity","subject","excerpt","inReplyTo","atMs","receivedAtMs","byUid","uid","byName","name","created"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-topic-confirmation.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * DOUBLE OPT-IN — the durable half.\n *\n * `email-topics.ts` in the framework states the policy and owns the three\n * states an entry can be in; this writes them, because writing them needs\n * Firestore.\n *\n * ## The record it writes is the one the send path already reads\n *\n * `hosts/{hostId}/topicOptOuts/{emailKey}` holds a map keyed by topic, and\n * `filterTopicSendable` reads it on every topic-filtered send. A pending\n * confirmation is therefore a THIRD state of a fact the send path already\n * consults rather than a new document nobody has a reason to look at — which\n * is the difference between ActiveCampaign's quarantine, where an unconfirmed\n * subscriber genuinely cannot be mailed, and Customer.io's recipe, whose own\n * documentation warns that it \"doesn't automatically check this attribute\n * before sending messages\".\n *\n * ## Nothing here is a deletion\n *\n * A confirmation stamps `confirmedAt` beside the `pendingAt` it answers,\n * rather than clearing the pending mark. The pair IS the evidence — when we\n * asked and when they answered — and it is the evidence a consent question\n * needs, which is the same argument `writeTopicOptOuts` makes for keeping an\n * opt-out entry after somebody rejoins.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n doubleOptInExpired,\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionEntry,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport { isEmailTopicId } from '@aglyn/aglyn/app-utils/email-topics'\nimport firebaseAdmin from './firebase-admin'\nimport {\n emailSuppressionKey,\n releaseEmailForConfirmedOptIn,\n} from './email-suppression'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\nfunction optOutDoc(hostId: string, key: string, firestore?: any) {\n return (firestore ?? defaultFirestore())\n .collection('hosts')\n .doc(hostId)\n .collection(TOPIC_OPT_OUTS_SUBCOLLECTION)\n .doc(key)\n}\n\n/** What asking for a confirmation did. */\nexport type PendingConfirmationResult =\n /** Recorded. The address is not mailable about this topic until confirmed. */\n | 'pending'\n /** Already confirmed, or never needed confirming. Nothing was written. */\n | 'already-subscribed'\n /**\n * They left this stream. Nothing was written and nothing may be sent.\n *\n * A signup form cannot re-arm mail to somebody who unsubscribed by asking\n * them again: the confirmation request is itself a message, and sending it\n * would be mailing somebody who told this site to stop.\n */\n | 'opted-out'\n /** Not an address, or not a topic id. */\n | 'unusable'\n\n/**\n * Puts an address in the quarantine for one topic and returns the moment it\n * entered, which is what the confirmation link is signed and dated from.\n *\n * ## Re-asking does not restamp — inside the window\n *\n * A second signup from the same address inside the window keeps the original\n * `pendingAt`, so the 72-hour expiry measures from when we first asked. The\n * alternative lets somebody — or a script — hold an address in a permanently\n * fresh pending state by resubmitting a form, which is a way to keep a\n * confirmation link alive indefinitely.\n *\n * Once the window has passed, a signup is a new question and is stamped now.\n * An expired request admits nobody and its link no longer acts, so keeping\n * its moment would date the new link from the old one — expired before it is\n * sent — and \"sign up again\", which the expired-link page and the docs tell\n * the person to do, could never succeed. With a consent group waiting on its\n * sites' confirmations, that stranded request would hold the stream from\n * every site of the group.\n */\nexport async function recordPendingTopicConfirmation(\n hostId: string,\n email: string,\n topicId: string,\n options?: { nowMs?: number; firestore?: any },\n): Promise<{ result: PendingConfirmationResult; pendingAtMs: number | null }> {\n const key = emailSuppressionKey(email)\n if (!key || !hostId || !isEmailTopicId(topicId)) {\n return { result: 'unusable', pendingAtMs: null }\n }\n const nowMs = options?.nowMs ?? Date.now()\n const ref = optOutDoc(hostId, key, options?.firestore)\n const address = String(email).trim().toLowerCase()\n let result: PendingConfirmationResult = 'pending'\n let pendingAtMs: number | null = nowMs\n await (options?.firestore ?? defaultFirestore()).runTransaction(\n async (transaction: any) => {\n const snapshot = await transaction.get(ref)\n const stored = ((snapshot.exists ? snapshot.get('topics') : null) ??\n {}) as Record<string, TopicSubscriptionEntry>\n const existing = stored[topicId]\n const state = readTopicSubscriptionState(existing)\n if (state === 'opted-out') {\n result = 'opted-out'\n pendingAtMs = null\n return\n }\n if (state === 'pending') {\n // Already in the quarantine. Keep the original moment while that\n // request can still be confirmed — see above; past it, fall through\n // and ask again.\n const askedAtMs = Number(existing?.pendingAt) || nowMs\n if (!doubleOptInExpired(askedAtMs, nowMs)) {\n result = 'pending'\n pendingAtMs = askedAtMs\n return\n }\n }\n if (existing?.confirmedAt) {\n result = 'already-subscribed'\n pendingAtMs = null\n return\n }\n transaction.set(\n ref,\n {\n email: address,\n topics: {\n ...stored,\n [topicId]: {\n ...(existing ?? {}),\n pendingAt: nowMs,\n confirmedAt: null,\n },\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(snapshot.exists\n ? {}\n : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n },\n )\n return { result, pendingAtMs }\n}\n\n/** What a click on a confirmation link did. */\nexport type ConfirmTopicResult =\n /** They are now a subscriber to this topic. */\n | 'confirmed'\n /** They already were, and a second click changes nothing. */\n | 'already-confirmed'\n /** The request is older than the window, so the link no longer acts. */\n | 'expired'\n /** They left this stream. A confirmation link may not undo that. */\n | 'opted-out'\n /** Nothing was pending for this address and topic. */\n | 'not-pending'\n /** Not an address, or not a topic id. */\n | 'unusable'\n\n/**\n * Honors a confirmation click.\n *\n * ## An expired link does not admit anybody, and does not clear anything\n *\n * `expired` leaves the record exactly as it was: still pending, still\n * unmailable. Expiry is a property of the LINK, and a lapse that quietly\n * admitted the address, or quietly cleared the request, would make the window\n * a delay somebody could wait out rather than a gate.\n *\n * ## A confirmation cannot reverse an unsubscribe\n *\n * Same rule as `releaseSiteSuppression`: the recipient's later, more explicit\n * act stands, and a link minted before it must not undo it.\n *\n * ## A confirmation DOES reverse a hard bounce\n *\n * The one thing a completed round trip is evidence of. A `bounce` entry on\n * the platform list claims mail addressed here is permanently refused, and a\n * click on a link that was delivered to that mailbox is the only evidence\n * that could refute it. Without this, an address recorded as permanently\n * bounced — often a full mailbox or a misconfigured receiving server that a\n * provider reported as permanent — can never re-subscribe by any route, and\n * the double opt-in it just completed would enrol somebody the send path then\n * silently drops.\n *\n * The refusal above and the release here are the same rule seen from two\n * sides: a stated intention stands and a measurement does not. Which reasons\n * qualify is decided by `OPT_IN_RELEASABLE_REASONS`, not here — an unsubscribe\n * or a spam complaint is never among them, or this function would be a way to\n * launder a complaint through a signup form.\n *\n * The release is best-effort and deliberately after the transaction: the\n * confirmation is what the person is waiting for, and a suppression list that\n * could not be written must not cost them the subscription they just made.\n */\nexport async function confirmTopicSubscription(\n hostId: string,\n email: string,\n topicId: string,\n options?: { nowMs?: number; firestore?: any },\n): Promise<ConfirmTopicResult> {\n const key = emailSuppressionKey(email)\n if (!key || !hostId || !isEmailTopicId(topicId)) return 'unusable'\n const nowMs = options?.nowMs ?? Date.now()\n const ref = optOutDoc(hostId, key, options?.firestore)\n /*\n * The verdict comes OUT of the transaction rather than through a closure\n * variable beside it. A transaction body can be retried, so a variable the\n * caller reads afterwards describes whichever attempt ran last rather than\n * the one that committed.\n */\n const result: ConfirmTopicResult = await (\n options?.firestore ?? defaultFirestore()\n ).runTransaction(async (transaction: any): Promise<ConfirmTopicResult> => {\n const snapshot = await transaction.get(ref)\n const stored = ((snapshot.exists ? snapshot.get('topics') : null) ??\n {}) as Record<string, TopicSubscriptionEntry>\n const existing = stored[topicId]\n const state = readTopicSubscriptionState(existing)\n if (state === 'opted-out') return 'opted-out'\n if (state === 'subscribed') {\n return existing?.confirmedAt ? 'already-confirmed' : 'not-pending'\n }\n if (doubleOptInExpired(Number(existing?.pendingAt), nowMs)) {\n return 'expired'\n }\n transaction.set(\n ref,\n {\n email: String(email).trim().toLowerCase(),\n topics: {\n ...stored,\n [topicId]: { ...(existing ?? {}), confirmedAt: nowMs },\n },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n return 'confirmed'\n })\n if (result === 'confirmed') {\n await releaseEmailForConfirmedOptIn({\n email,\n hostId,\n topicId,\n ...(options?.firestore ? { firestore: options.firestore } : {}),\n })\n }\n return result\n}\n\n/**\n * Whether this site confirms new subscriptions by default.\n *\n * One host-document field, read at the moment somebody signs up and nowhere\n * else — the send path never asks, because by then the answer is already on\n * the person's own record as a pending mark or its absence. A read on every\n * send would be a read per recipient to re-derive a decision that was made\n * once.\n *\n * Fails to `false`, which is off. A default that a failed read could switch\n * ON would quarantine every new signup on a site whose owner never asked for\n * confirmations, and they would have no way to tell why their list stopped\n * growing.\n */\nexport async function siteRequiresDoubleOptIn(\n hostId: string,\n firestore?: any,\n): Promise<boolean> {\n if (!hostId) return false\n try {\n const snapshot = await (firestore ?? defaultFirestore())\n .collection('hosts')\n .doc(hostId)\n .get()\n return snapshot.exists && snapshot.get('emailDoubleOptIn') === true\n } catch (error) {\n console.error('[email-topics] double opt-in setting read failed', error)\n return false\n }\n}\n\n/** What asking to leave one topic did. */\nexport type TopicOptOutResult =\n /** Recorded now. The address is not mailable about this topic. */\n | 'opted-out'\n /** They had already left it; the moment they left is kept. */\n | 'already-opted-out'\n /** Not an address, or not a topic id. Nothing was written. */\n | 'unusable'\n\n/**\n * Takes one address off one topic on one site (AGL-2981) — the opt-out a\n * sender that is not the preference page records: a sales email's\n * unsubscribe link, a reply that asks to be left alone.\n *\n * The same record the preference page writes (`writeTopicOptOuts` in the\n * email plugin) and the send path reads, on the same rules:\n *\n * - an address that already left keeps the moment it left — a second\n * request is not a later one;\n * - the entry is carried forward rather than replaced, so a confirmation\n * the person once gave stays on the record beside the opt-out;\n * - a rejoin stamped earlier is cleared, because leaving is now the more\n * recent act.\n *\n * A transaction, and its verdict comes out of it rather than through a\n * variable beside it: a retried attempt must not describe itself as the one\n * that committed.\n */\nexport async function recordTopicOptOut(\n hostId: string,\n email: string,\n topicId: string,\n options?: { firestore?: any },\n): Promise<TopicOptOutResult> {\n const key = emailSuppressionKey(email)\n if (!key || !hostId || !isEmailTopicId(topicId)) return 'unusable'\n const db = options?.firestore ?? defaultFirestore()\n const ref = optOutDoc(hostId, key, db)\n return db.runTransaction(async (transaction: any): Promise<TopicOptOutResult> => {\n const snapshot = await transaction.get(ref)\n const stored = ((snapshot.exists ? snapshot.get('topics') : null) ??\n {}) as Record<string, TopicSubscriptionEntry>\n const previous = stored[topicId]\n if (readTopicSubscriptionState(previous) === 'opted-out') return 'already-opted-out'\n transaction.set(\n ref,\n {\n email: String(email).trim().toLowerCase(),\n topics: {\n ...stored,\n [topicId]: {\n ...(previous ?? {}),\n optedOutAt: FieldValue.serverTimestamp(),\n resubscribedAt: null,\n },\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(snapshot.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n return 'opted-out'\n })\n}\n"],"names":["FieldValue","doubleOptInExpired","readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","isEmailTopicId","firebaseAdmin","emailSuppressionKey","releaseEmailForConfirmedOptIn","defaultFirestore","app","firestore","optOutDoc","hostId","key","collection","doc","recordPendingTopicConfirmation","email","topicId","options","result","pendingAtMs","nowMs","Date","now","ref","address","String","trim","toLowerCase","runTransaction","transaction","snapshot","get","stored","exists","existing","state","askedAtMs","Number","pendingAt","confirmedAt","set","topics","updatedAt","serverTimestamp","createdAt","merge","confirmTopicSubscription","siteRequiresDoubleOptIn","error","console","recordTopicOptOut","db","previous","optedOutAt","resubscribedAt"],"mappings":"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"}
@@ -1,35 +0,0 @@
1
- /**
2
- * @license
3
- * Copyright 2026 Aglyn LLC
4
- *
5
- * Licensed under the Apache License, Version 2.0 (the "License");
6
- * you may not use this file except in compliance with the License.
7
- * You may obtain a copy of the License at
8
- *
9
- * http://www.apache.org/licenses/LICENSE-2.0
10
- *
11
- * Unless required by applicable law or agreed to in writing, software
12
- * distributed under the License is distributed on an "AS IS" BASIS,
13
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
- * See the License for the specific language governing permissions and
15
- * limitations under the License.
16
- */
17
- import type { FormDatasetBinding } from '@aglyn/aglyn/app-utils/form-dataset-binding';
18
- /**
19
- * The token a page carries for one form's binding on one site.
20
- *
21
- * Throws when `TOKEN_SIGNING_SECRET` is not configured, like every token this
22
- * secret signs; a caller that renders a page decides what an unsigned form
23
- * does.
24
- */
25
- export declare function signFormDatasetBinding(hostId: string, binding: FormDatasetBinding): string;
26
- /**
27
- * The binding a token carries, or null when it is not a valid token for
28
- * `hostId`.
29
- *
30
- * Null for anything else at all — not a string, oversized, another version, a
31
- * signature that does not match, a site other than this one, or no signing
32
- * secret — because the only safe reading of a binding that cannot be verified
33
- * is that there is none.
34
- */
35
- export declare function verifyFormDatasetBinding(hostId: string, token: unknown): FormDatasetBinding | null;
@@ -1,107 +0,0 @@
1
- /**
2
- * @license
3
- * Copyright 2026 Aglyn LLC
4
- *
5
- * Licensed under the Apache License, Version 2.0 (the "License");
6
- * you may not use this file except in compliance with the License.
7
- * You may obtain a copy of the License at
8
- *
9
- * http://www.apache.org/licenses/LICENSE-2.0
10
- *
11
- * Unless required by applicable law or agreed to in writing, software
12
- * distributed under the License is distributed on an "AS IS" BASIS,
13
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
- * See the License for the specific language governing permissions and
15
- * limitations under the License.
16
- */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
- import { createHmac } from "crypto";
18
- import { tokenSigningSecret } from "./media-signing.js";
19
- import { safeEqual } from "./safe-equal.js";
20
- /**
21
- * A form's dataset binding, signed into the page that renders the form.
22
- *
23
- * `/api/forms/submit` is public and unauthenticated, so nothing in its body
24
- * may choose which dataset a record lands in: a dataset id taken from the body
25
- * let anyone add rows to any dataset the site can see. The binding is instead
26
- * read off the form by the compose that renders the page, signed here, and
27
- * carried by the form; the route writes only where a valid signature says.
28
- *
29
- * What a token grants is what the published form already does. Anyone can read
30
- * one out of the page, and replaying it writes to that form's dataset with
31
- * that form's field map, for the site it was minted on — nothing a visitor
32
- * submitting the form could not already do.
33
- *
34
- * Signed with the shared, fail-closed `TOKEN_SIGNING_SECRET`, the secret the
35
- * other tenant tokens use, under its own `form-dataset-binding:` prefix so it
36
- * can never be replayed as any other kind of token. No expiry: a published
37
- * page can be served long after it rendered, and an expired token would drop
38
- * a live form's records. Rotating the secret, or the version, retires every
39
- * token at once.
40
- */ const VERSION = 'v1';
41
- const PREFIX = 'form-dataset-binding';
42
- /** Far above any real binding, so an oversized body is refused unparsed. */ const MAX_TOKEN_CHARS = 8192;
43
- const signature = (payload)=>createHmac('sha256', tokenSigningSecret()).update(`${PREFIX}:${VERSION}:${payload}`).digest('hex');
44
- /**
45
- * The token a page carries for one form's binding on one site.
46
- *
47
- * Throws when `TOKEN_SIGNING_SECRET` is not configured, like every token this
48
- * secret signs; a caller that renders a page decides what an unsigned form
49
- * does.
50
- */ export function signFormDatasetBinding(hostId, binding) {
51
- const body = _extends({
52
- h: hostId
53
- }, binding.datasetId ? {
54
- d: binding.datasetId
55
- } : {}, binding.datasetName ? {
56
- n: binding.datasetName
57
- } : {}, {
58
- m: binding.fieldMap
59
- });
60
- const payload = Buffer.from(JSON.stringify(body)).toString('base64url');
61
- return `${VERSION}.${payload}.${signature(payload)}`;
62
- }
63
- const text = (value, max)=>typeof value === 'string' ? value.trim().slice(0, max) : '';
64
- /**
65
- * The binding a token carries, or null when it is not a valid token for
66
- * `hostId`.
67
- *
68
- * Null for anything else at all — not a string, oversized, another version, a
69
- * signature that does not match, a site other than this one, or no signing
70
- * secret — because the only safe reading of a binding that cannot be verified
71
- * is that there is none.
72
- */ export function verifyFormDatasetBinding(hostId, token) {
73
- try {
74
- if (typeof token !== 'string' || token.length > MAX_TOKEN_CHARS) return null;
75
- const [version, payload, signed, extra] = token.split('.');
76
- if (version !== VERSION || !payload || !signed || extra !== undefined) {
77
- return null;
78
- }
79
- if (!safeEqual(signed, signature(payload))) return null;
80
- const body = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
81
- if (!hostId || (body == null ? void 0 : body.h) !== hostId) return null;
82
- const datasetId = text(body.d, 128);
83
- const datasetName = text(body.n, 60);
84
- if (!datasetId && !datasetName) return null;
85
- const fieldMap = {};
86
- if (body.m && typeof body.m === 'object' && !Array.isArray(body.m)) {
87
- for (const [key, value] of Object.entries(body.m)){
88
- const submittedKey = text(key, 64);
89
- const datasetFieldId = text(value, 64);
90
- if (submittedKey && datasetFieldId) {
91
- fieldMap[submittedKey] = datasetFieldId;
92
- }
93
- }
94
- }
95
- return _extends({}, datasetId ? {
96
- datasetId
97
- } : {}, datasetName ? {
98
- datasetName
99
- } : {}, {
100
- fieldMap
101
- });
102
- } catch (unused) {
103
- return null;
104
- }
105
- }
106
-
107
- //# sourceMappingURL=form-dataset-binding-token.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/form-dataset-binding-token.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { FormDatasetBinding } from '@aglyn/aglyn/app-utils/form-dataset-binding'\nimport { createHmac } from 'crypto'\nimport { tokenSigningSecret } from './media-signing'\nimport { safeEqual } from './safe-equal'\n\n/**\n * A form's dataset binding, signed into the page that renders the form.\n *\n * `/api/forms/submit` is public and unauthenticated, so nothing in its body\n * may choose which dataset a record lands in: a dataset id taken from the body\n * let anyone add rows to any dataset the site can see. The binding is instead\n * read off the form by the compose that renders the page, signed here, and\n * carried by the form; the route writes only where a valid signature says.\n *\n * What a token grants is what the published form already does. Anyone can read\n * one out of the page, and replaying it writes to that form's dataset with\n * that form's field map, for the site it was minted on — nothing a visitor\n * submitting the form could not already do.\n *\n * Signed with the shared, fail-closed `TOKEN_SIGNING_SECRET`, the secret the\n * other tenant tokens use, under its own `form-dataset-binding:` prefix so it\n * can never be replayed as any other kind of token. No expiry: a published\n * page can be served long after it rendered, and an expired token would drop\n * a live form's records. Rotating the secret, or the version, retires every\n * token at once.\n */\n\nconst VERSION = 'v1'\nconst PREFIX = 'form-dataset-binding'\n/** Far above any real binding, so an oversized body is refused unparsed. */\nconst MAX_TOKEN_CHARS = 8192\n\ninterface SignedBinding {\n h: string\n d?: string\n n?: string\n m: Record<string, string>\n}\n\nconst signature = (payload: string): string =>\n createHmac('sha256', tokenSigningSecret())\n .update(`${PREFIX}:${VERSION}:${payload}`)\n .digest('hex')\n\n/**\n * The token a page carries for one form's binding on one site.\n *\n * Throws when `TOKEN_SIGNING_SECRET` is not configured, like every token this\n * secret signs; a caller that renders a page decides what an unsigned form\n * does.\n */\nexport function signFormDatasetBinding(\n hostId: string,\n binding: FormDatasetBinding,\n): string {\n const body: SignedBinding = {\n h: hostId,\n ...(binding.datasetId ? { d: binding.datasetId } : {}),\n ...(binding.datasetName ? { n: binding.datasetName } : {}),\n m: binding.fieldMap,\n }\n const payload = Buffer.from(JSON.stringify(body)).toString('base64url')\n return `${VERSION}.${payload}.${signature(payload)}`\n}\n\nconst text = (value: unknown, max: number): string =>\n typeof value === 'string' ? value.trim().slice(0, max) : ''\n\n/**\n * The binding a token carries, or null when it is not a valid token for\n * `hostId`.\n *\n * Null for anything else at all — not a string, oversized, another version, a\n * signature that does not match, a site other than this one, or no signing\n * secret — because the only safe reading of a binding that cannot be verified\n * is that there is none.\n */\nexport function verifyFormDatasetBinding(\n hostId: string,\n token: unknown,\n): FormDatasetBinding | null {\n try {\n if (typeof token !== 'string' || token.length > MAX_TOKEN_CHARS) return null\n const [version, payload, signed, extra] = token.split('.')\n if (version !== VERSION || !payload || !signed || extra !== undefined) {\n return null\n }\n if (!safeEqual(signed, signature(payload))) return null\n const body = JSON.parse(\n Buffer.from(payload, 'base64url').toString('utf8'),\n ) as Partial<SignedBinding>\n if (!hostId || body?.h !== hostId) return null\n const datasetId = text(body.d, 128)\n const datasetName = text(body.n, 60)\n if (!datasetId && !datasetName) return null\n const fieldMap: Record<string, string> = {}\n if (body.m && typeof body.m === 'object' && !Array.isArray(body.m)) {\n for (const [key, value] of Object.entries(body.m)) {\n const submittedKey = text(key, 64)\n const datasetFieldId = text(value, 64)\n if (submittedKey && datasetFieldId) {\n fieldMap[submittedKey] = datasetFieldId\n }\n }\n }\n return {\n ...(datasetId ? { datasetId } : {}),\n ...(datasetName ? { datasetName } : {}),\n fieldMap,\n }\n } catch {\n return null\n }\n}\n"],"names":["createHmac","tokenSigningSecret","safeEqual","VERSION","PREFIX","MAX_TOKEN_CHARS","signature","payload","update","digest","signFormDatasetBinding","hostId","binding","body","h","datasetId","d","datasetName","n","m","fieldMap","Buffer","from","JSON","stringify","toString","text","value","max","trim","slice","verifyFormDatasetBinding","token","length","version","signed","extra","split","undefined","parse","Array","isArray","key","Object","entries","submittedKey","datasetFieldId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SAASA,UAAU,QAAQ,SAAQ;AACnC,SAASC,kBAAkB,QAAQ,qBAAiB;AACpD,SAASC,SAAS,QAAQ,kBAAc;AAExC;;;;;;;;;;;;;;;;;;;;CAoBC,GAED,MAAMC,UAAU;AAChB,MAAMC,SAAS;AACf,0EAA0E,GAC1E,MAAMC,kBAAkB;AASxB,MAAMC,YAAY,CAACC,UACjBP,WAAW,UAAUC,sBAClBO,MAAM,CAAC,GAAGJ,OAAO,CAAC,EAAED,QAAQ,CAAC,EAAEI,SAAS,EACxCE,MAAM,CAAC;AAEZ;;;;;;CAMC,GACD,OAAO,SAASC,uBACdC,MAAc,EACdC,OAA2B;IAE3B,MAAMC,OAAsB;QAC1BC,GAAGH;OACCC,QAAQG,SAAS,GAAG;QAAEC,GAAGJ,QAAQG,SAAS;IAAC,IAAI,CAAC,GAChDH,QAAQK,WAAW,GAAG;QAAEC,GAAGN,QAAQK,WAAW;IAAC,IAAI,CAAC;QACxDE,GAAGP,QAAQQ,QAAQ;;IAErB,MAAMb,UAAUc,OAAOC,IAAI,CAACC,KAAKC,SAAS,CAACX,OAAOY,QAAQ,CAAC;IAC3D,OAAO,GAAGtB,QAAQ,CAAC,EAAEI,QAAQ,CAAC,EAAED,UAAUC,UAAU;AACtD;AAEA,MAAMmB,OAAO,CAACC,OAAgBC,MAC5B,OAAOD,UAAU,WAAWA,MAAME,IAAI,GAAGC,KAAK,CAAC,GAAGF,OAAO;AAE3D;;;;;;;;CAQC,GACD,OAAO,SAASG,yBACdpB,MAAc,EACdqB,KAAc;IAEd,IAAI;QACF,IAAI,OAAOA,UAAU,YAAYA,MAAMC,MAAM,GAAG5B,iBAAiB,OAAO;QACxE,MAAM,CAAC6B,SAAS3B,SAAS4B,QAAQC,MAAM,GAAGJ,MAAMK,KAAK,CAAC;QACtD,IAAIH,YAAY/B,WAAW,CAACI,WAAW,CAAC4B,UAAUC,UAAUE,WAAW;YACrE,OAAO;QACT;QACA,IAAI,CAACpC,UAAUiC,QAAQ7B,UAAUC,WAAW,OAAO;QACnD,MAAMM,OAAOU,KAAKgB,KAAK,CACrBlB,OAAOC,IAAI,CAACf,SAAS,aAAakB,QAAQ,CAAC;QAE7C,IAAI,CAACd,UAAUE,CAAAA,wBAAAA,KAAMC,CAAC,MAAKH,QAAQ,OAAO;QAC1C,MAAMI,YAAYW,KAAKb,KAAKG,CAAC,EAAE;QAC/B,MAAMC,cAAcS,KAAKb,KAAKK,CAAC,EAAE;QACjC,IAAI,CAACH,aAAa,CAACE,aAAa,OAAO;QACvC,MAAMG,WAAmC,CAAC;QAC1C,IAAIP,KAAKM,CAAC,IAAI,OAAON,KAAKM,CAAC,KAAK,YAAY,CAACqB,MAAMC,OAAO,CAAC5B,KAAKM,CAAC,GAAG;YAClE,KAAK,MAAM,CAACuB,KAAKf,MAAM,IAAIgB,OAAOC,OAAO,CAAC/B,KAAKM,CAAC,EAAG;gBACjD,MAAM0B,eAAenB,KAAKgB,KAAK;gBAC/B,MAAMI,iBAAiBpB,KAAKC,OAAO;gBACnC,IAAIkB,gBAAgBC,gBAAgB;oBAClC1B,QAAQ,CAACyB,aAAa,GAAGC;gBAC3B;YACF;QACF;QACA,OAAO,aACD/B,YAAY;YAAEA;QAAU,IAAI,CAAC,GAC7BE,cAAc;YAAEA;QAAY,IAAI,CAAC;YACrCG;;IAEJ,EAAE,eAAM;QACN,OAAO;IACT;AACF"}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/stripe-account-mode.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Whether a stored Connect linkage can actually take money HERE (AGL-2471).\n *\n * THE DEFECT. Production Firestore held three Connect linkages and all three\n * named TEST-mode accounts. One — `profiles/7AVEMtDa…`, `stripeAccountId:\n * acct_1TulDeRbL3B9Ioqz` — also carried `stripeChargesEnabled: true`, which\n * was the entire payments-readiness test every money door made:\n *\n * if (!accountId || !ownerProfile.get('stripeChargesEnabled')) …\n *\n * So three storefronts presented as payments-ready, minted a LIVE Checkout\n * session with a TEST-mode `payment_intent_data[transfer_data][destination]`,\n * and were refused by Stripe as a generic 502 at the shopper's checkout. A\n * merchant told they are ready to take money, who silently cannot.\n *\n * WHY THE ACCOUNT ID IS NOT THE EVIDENCE. `acct_1TulDeRbL3B9Ioqz` is a real\n * production value naming a TEST account. Stripe's account ids carry no mode\n * marker — live and test ids are the same shape — so no amount of reading the\n * string can tell them apart. What DOES tell them apart is Stripe itself:\n * retrieving that id with the live key answers\n *\n * 400 The account acct_1TulDeRbL3B9Ioqz was a test account created with a\n * testmode key, and therefore can only be used with testmode keys.\n *\n * The Account object, notably, has NO `livemode` field of its own (verified\n * against the live API: the payload carries `charges_enabled`,\n * `payouts_enabled`, `capabilities` and no `livemode`). So the mode is\n * recorded at the two moments Stripe does state it:\n *\n * 1. `account.updated` — the EVENT carries `livemode`, and it is Stripe's\n * own statement about the account the event is describing;\n * 2. onboarding — the account was just created or retrieved successfully\n * with the platform key, and Stripe hard-refuses cross-mode retrieval\n * (above), so the account's mode IS the key's mode; the key's mode is\n * then confirmed against the API via `resolvePlatformStripeMode`.\n *\n * WHY THE RULE IS ASYMMETRIC, AND NOT \"REFUSE ANYTHING UNPROVEN\". An absent\n * `stripeAccountLivemode` refuses only on a LIVE deployment. That is where\n * real money moves and where the defect was found, and a live deployment can\n * always re-establish the field: the merchant reconnects, or Stripe sends one\n * `account.updated`. Everywhere else — a test-key deployment, a developer\n * machine, a self-hosted install still in sandbox, the whole test suite — an\n * unverified linkage keeps the behaviour it had before, because no real money\n * can move there and Stripe enforces the mode boundary itself. Refusing\n * everywhere would have bought production nothing extra and broken every\n * non-production deployment.\n *\n * A PROVEN mismatch (`stripeAccountLivemode` recorded and disagreeing) is\n * refused in BOTH directions, because that costs nothing to detect and is\n * never right.\n *\n * WHAT IS SNIFFED, AND WHY THAT ONE IS FAIR. The SECRET KEY states its own\n * mode — `sk_live_`, `sk_test_`, `rk_live_`, `rk_test_` — and that is a\n * documented, stable Stripe invariant, unlike the account id. It is used only\n * to answer \"which mode is this deployment\", never \"which mode is this\n * account\", and even there `resolvePlatformStripeMode` prefers the API.\n *\n * HOW IT GOT THERE. A developer machine runs against PRODUCTION Firestore\n * (`FIREBASE_PROJECT_ID=aglyn-main` in every dev env file) while\n * `apps/console/.env.development.local` sets `STRIPE_SECRET_KEY` to the\n * `sk_test_` key by deliberate policy (AGL-1137: localhost must never touch\n * live Stripe). The connect route then creates a test-mode Express account\n * and writes it into the production database. Nothing about that is going to\n * change — the split key is the right call — so the gate has to be the thing\n * that notices.\n */\n\n/** Which Stripe world a key or an account belongs to. */\nexport type StripeMode = 'live' | 'test'\n\n/**\n * Why a stored linkage may not be charged against. Every value other than\n * `'ready'` is a refusal, and the mode ones are refusals the old two-field\n * test could not make.\n */\nexport type ConnectReadiness =\n /** Charge away. */\n | 'ready'\n /** No Connect account is stored at all — the onboarding call to action. */\n | 'not-connected'\n /** Stripe will not let this account take charges. */\n | 'charges-disabled'\n /**\n * The linkage predates AGL-2471 (or was written by something that did not\n * record mode), so its mode was never established — AND this deployment is\n * LIVE. This is the shape all three poisoned production records have.\n * The connect route and the `account.updated` sync both write the field, so\n * a genuine merchant self-heals on the next of either.\n */\n | 'mode-unverified'\n /** The account belongs to the OTHER Stripe world. The AGL-2471 defect. */\n | 'mode-mismatch'\n\nexport interface ConnectReadinessInput {\n /** `stripeAccountId` off the profile, whatever shape the document has. */\n accountId?: unknown\n /** `stripeChargesEnabled` off the profile. */\n chargesEnabled?: unknown\n /** `stripeAccountLivemode` off the profile — absent before AGL-2471. */\n accountLivemode?: unknown\n /** Defaults to the mode of `STRIPE_SECRET_KEY`. */\n platformMode?: StripeMode | undefined\n}\n\n/**\n * The mode a Stripe secret key states about itself, or `undefined` when the\n * string is not a Stripe secret key.\n *\n * Deliberately narrow: it matches `sk_`/`rk_` followed by `live`/`test`, and\n * refuses everything else rather than guessing. Handing it an `acct_…` id\n * returns `undefined` — the whole point is that account ids say nothing.\n */\nexport function platformStripeMode(\n key: string | undefined = process.env.STRIPE_SECRET_KEY,\n): StripeMode | undefined {\n const match = /^[sr]k_(live|test)_/.exec(String(key ?? '').trim())\n return match ? (match[1] as StripeMode) : undefined\n}\n\n/**\n * The platform's mode, asked of Stripe and only then of the key.\n *\n * `GET /v1/balance` is a singleton that carries `livemode`, so it states the\n * mode of whatever key made the call — no object has to be created to find\n * out. The key prefix is the fallback for a restricted key that cannot read\n * balance; without it, a self-hosted install with a scoped key could not\n * finish onboarding at all.\n */\nexport async function resolvePlatformStripeMode(\n key: string | undefined = process.env.STRIPE_SECRET_KEY,\n fetchImpl: typeof fetch = fetch,\n): Promise<StripeMode | undefined> {\n const fromKey = platformStripeMode(key)\n if (!key) return fromKey\n try {\n const response = await fetchImpl('https://api.stripe.com/v1/balance', {\n method: 'GET',\n headers: { Authorization: `Bearer ${key}` },\n })\n if (response.ok) {\n const payload = await response.json()\n if (typeof payload?.livemode === 'boolean') {\n return payload.livemode ? 'live' : 'test'\n }\n }\n } catch {\n // Network or transport failure — fall through to the key, which is the\n // same answer we would have recorded before this existed.\n }\n return fromKey\n}\n\n/**\n * Decides whether a stored Connect linkage may be charged against.\n *\n * Order matters: the two refusals that existed before AGL-2471 are asked\n * first, so an unconnected or restricted merchant still gets the answer they\n * always got, and only a linkage that WOULD have passed reaches the mode\n * question.\n */\nexport function connectReadiness(\n input: ConnectReadinessInput,\n): ConnectReadiness {\n const accountId =\n typeof input.accountId === 'string' ? input.accountId.trim() : ''\n if (!accountId) return 'not-connected'\n if (input.chargesEnabled !== true) return 'charges-disabled'\n const platformMode =\n 'platformMode' in input ? input.platformMode : platformStripeMode()\n // Three-valued on purpose, exactly as AGL-1997 reads `payoutsEnabled`: only\n // a literal boolean is a recorded answer. A string `'true'` or a `1` is a\n // field somebody else wrote, and inventing `true` from it would re-open the\n // hole this closes.\n if (typeof input.accountLivemode === 'boolean') {\n // A PROVEN mismatch is refused in either direction. It costs nothing to\n // detect and it is always wrong.\n if (!platformMode) return 'ready'\n return input.accountLivemode === (platformMode === 'live')\n ? 'ready'\n : 'mode-mismatch'\n }\n return platformMode === 'live' ? 'mode-unverified' : 'ready'\n}\n\n/**\n * The form every money door calls: `true` only when the linkage may be\n * charged, with the two mode refusals reported to the server log.\n *\n * The log line is the part the shopper's generic 409 cannot carry. Before\n * this, a mode mismatch surfaced as a 502 from Stripe with nothing anywhere\n * naming the cause; the sale is still refused the same way, but now somebody\n * can find out why.\n */\nexport function connectLinkageIsReady(\n input: ConnectReadinessInput,\n context?: { subject?: string },\n): boolean {\n const readiness = connectReadiness(input)\n if (readiness === 'mode-mismatch' || readiness === 'mode-unverified') {\n console.error(\n `[AGL-2471] Refusing a charge against Connect account ` +\n `${String(input.accountId)}${\n context?.subject ? ` (${context.subject})` : ''\n }: ${readiness}. The stored linkage is not verified for this ` +\n `deployment's Stripe mode; the merchant must re-onboard.`,\n )\n }\n return readiness === 'ready'\n}\n"],"names":["platformStripeMode","key","process","env","STRIPE_SECRET_KEY","match","exec","String","trim","undefined","resolvePlatformStripeMode","fetchImpl","fetch","fromKey","response","method","headers","Authorization","ok","payload","json","livemode","connectReadiness","input","accountId","chargesEnabled","platformMode","accountLivemode","connectLinkageIsReady","context","readiness","console","error","subject"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiEC,GAED,uDAAuD,GAqCvD;;;;;;;CAOC,GACD,OAAO,SAASA,mBACdC,MAA0BC,QAAQC,GAAG,CAACC,iBAAiB;IAEvD,MAAMC,QAAQ,sBAAsBC,IAAI,CAACC,OAAON,cAAAA,MAAO,IAAIO,IAAI;IAC/D,OAAOH,QAASA,KAAK,CAAC,EAAE,GAAkBI;AAC5C;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAeC,0BACpBT,MAA0BC,QAAQC,GAAG,CAACC,iBAAiB,EACvDO,YAA0BC,KAAK;IAE/B,MAAMC,UAAUb,mBAAmBC;IACnC,IAAI,CAACA,KAAK,OAAOY;IACjB,IAAI;QACF,MAAMC,WAAW,MAAMH,UAAU,qCAAqC;YACpEI,QAAQ;YACRC,SAAS;gBAAEC,eAAe,CAAC,OAAO,EAAEhB,KAAK;YAAC;QAC5C;QACA,IAAIa,SAASI,EAAE,EAAE;YACf,MAAMC,UAAU,MAAML,SAASM,IAAI;YACnC,IAAI,QAAOD,2BAAAA,QAASE,QAAQ,MAAK,WAAW;gBAC1C,OAAOF,QAAQE,QAAQ,GAAG,SAAS;YACrC;QACF;IACF,EAAE,eAAM;IACN,uEAAuE;IACvE,0DAA0D;IAC5D;IACA,OAAOR;AACT;AAEA;;;;;;;CAOC,GACD,OAAO,SAASS,iBACdC,KAA4B;IAE5B,MAAMC,YACJ,OAAOD,MAAMC,SAAS,KAAK,WAAWD,MAAMC,SAAS,CAAChB,IAAI,KAAK;IACjE,IAAI,CAACgB,WAAW,OAAO;IACvB,IAAID,MAAME,cAAc,KAAK,MAAM,OAAO;IAC1C,MAAMC,eACJ,kBAAkBH,QAAQA,MAAMG,YAAY,GAAG1B;IACjD,4EAA4E;IAC5E,0EAA0E;IAC1E,4EAA4E;IAC5E,oBAAoB;IACpB,IAAI,OAAOuB,MAAMI,eAAe,KAAK,WAAW;QAC9C,wEAAwE;QACxE,iCAAiC;QACjC,IAAI,CAACD,cAAc,OAAO;QAC1B,OAAOH,MAAMI,eAAe,KAAMD,CAAAA,iBAAiB,MAAK,IACpD,UACA;IACN;IACA,OAAOA,iBAAiB,SAAS,oBAAoB;AACvD;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASE,sBACdL,KAA4B,EAC5BM,OAA8B;IAE9B,MAAMC,YAAYR,iBAAiBC;IACnC,IAAIO,cAAc,mBAAmBA,cAAc,mBAAmB;QACpEC,QAAQC,KAAK,CACX,CAAC,qDAAqD,CAAC,GACrD,GAAGzB,OAAOgB,MAAMC,SAAS,IACvBK,CAAAA,2BAAAA,QAASI,OAAO,IAAG,CAAC,EAAE,EAAEJ,QAAQI,OAAO,CAAC,CAAC,CAAC,GAAG,GAC9C,EAAE,EAAEH,UAAU,8CAA8C,CAAC,GAC9D,CAAC,uDAAuD,CAAC;IAE/D;IACA,OAAOA,cAAc;AACvB"}