@aglyn/tenant-data-admin 1.0.0-beta.250 → 1.0.0-beta.252

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.
@@ -0,0 +1,446 @@
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 { createHash } from "node:crypto";
18
+ /**
19
+ * THE ACCOUNT-HOLDER AD AUDIENCE (AGL-3780) — the pure half.
20
+ *
21
+ * Once a night the operator's own account holders are kept in step with a
22
+ * customer-list audience on each of the operator's OWN ad accounts — Meta
23
+ * Custom Audiences, Google Customer Match and LinkedIn DMP segments — so
24
+ * signed-up people can be retargeted and left out of prospecting. This is
25
+ * the platform's marketing, not a merchant feature: a merchant's own pixels
26
+ * and conversion APIs are the ad-conversions plugin, which this shares
27
+ * nothing with.
28
+ *
29
+ * Everything here is pure — normalization, hashing, who is eligible, the
30
+ * add/remove diff and each provider's request — so the spec walks every rule
31
+ * without a network. `ad-audience-sync-run.ts` reads the accounts, sends and
32
+ * records.
33
+ *
34
+ * No address ever leaves in the clear: each provider receives the SHA-256 hex
35
+ * digest of the address normalized the way that provider documents, and
36
+ * nothing here logs either one.
37
+ */ export const AD_AUDIENCE_PROVIDERS = [
38
+ 'meta',
39
+ 'google',
40
+ 'linkedin'
41
+ ];
42
+ export const AD_AUDIENCE_PROVIDER_LABELS = {
43
+ meta: 'Meta',
44
+ google: 'Google',
45
+ linkedin: 'LinkedIn'
46
+ };
47
+ /*==========================================
48
+ * NORMALIZATION AND HASHING
49
+ *=========================================*/ const GOOGLE_CONSUMER_DOMAINS = new Set([
50
+ 'gmail.com',
51
+ 'googlemail.com'
52
+ ]);
53
+ /**
54
+ * The address the way one provider says to normalize it before hashing, or
55
+ * null when it is not an address at all.
56
+ *
57
+ * - **Meta** (Custom Audiences, `EMAIL`): trim leading and trailing white
58
+ * space, lowercase.
59
+ * - **LinkedIn** (DMP segment users, `SHA256_EMAIL`): lowercase, remove any
60
+ * white space.
61
+ * - **Google** (Data Manager API, Customer Match): lowercase, remove leading,
62
+ * trailing and inner white space, and for `gmail.com` and `googlemail.com`
63
+ * only, remove the dots from the local part and drop a `+` and everything
64
+ * after it. Other domains keep both.
65
+ *
66
+ * Inner white space is never part of a deliverable address, so removing it
67
+ * for Meta as well would change nothing real; it is left exactly as Meta
68
+ * documents it.
69
+ */ export function normalizeAudienceEmail(provider, raw) {
70
+ if (typeof raw !== 'string') return null;
71
+ let email = provider === 'meta' ? raw.trim().toLowerCase() : raw.replace(/\s+/g, '').toLowerCase();
72
+ const at = email.lastIndexOf('@');
73
+ if (at <= 0 || at === email.length - 1 || email.indexOf('@') !== at) return null;
74
+ if (provider === 'google') {
75
+ let local = email.slice(0, at);
76
+ const domain = email.slice(at + 1);
77
+ if (GOOGLE_CONSUMER_DOMAINS.has(domain)) {
78
+ const plus = local.indexOf('+');
79
+ if (plus >= 0) local = local.slice(0, plus);
80
+ local = local.replace(/\./g, '');
81
+ if (!local) return null;
82
+ email = `${local}@${domain}`;
83
+ }
84
+ }
85
+ return email;
86
+ }
87
+ /** Lowercase SHA-256 hex of a UTF-8 string: what all three providers take. */ export function sha256Hex(value) {
88
+ return createHash('sha256').update(value, 'utf8').digest('hex');
89
+ }
90
+ /** The hash one provider receives for an address, or null for no address. */ export function hashAudienceEmail(provider, raw) {
91
+ const normalized = normalizeAudienceEmail(provider, raw);
92
+ return normalized ? sha256Hex(normalized) : null;
93
+ }
94
+ export const AD_AUDIENCE_EXCLUSIONS = [
95
+ 'no-email',
96
+ 'disabled',
97
+ 'staff',
98
+ 'internal',
99
+ 'locked',
100
+ 'banned',
101
+ 'opted-out',
102
+ 'unverified',
103
+ 'no-country',
104
+ 'not-us'
105
+ ];
106
+ /** The one country the audience is built for. */ export const AD_AUDIENCE_COUNTRY = 'US';
107
+ const ALPHA2 = /^[A-Z]{2}$/;
108
+ /**
109
+ * The account's country, and which signal said so.
110
+ *
111
+ * 1. **The billing address** of a workspace the account owns. It is the
112
+ * address the account holder typed or gave Stripe, it is the address tax
113
+ * is computed on, and it is the strongest country fact the platform holds.
114
+ * When the account owns workspaces billed in different countries, the
115
+ * answer is US only when EVERY one of them is US: an account that bills
116
+ * from abroad is not counted as a US account holder on the strength of
117
+ * one US workspace.
118
+ * 2. Otherwise **the sign-up geography**: the country the edge reported for
119
+ * the request that created the account (AGL-3289). It says where the
120
+ * person was when they signed up, which is a weaker fact, and it is the
121
+ * one most accounts have.
122
+ * 3. Otherwise nothing, and an account with no country is left out. A phone
123
+ * number's `+1` is deliberately not a signal: it is shared with Canada and
124
+ * the Caribbean.
125
+ */ export function resolveAccountCountry(account) {
126
+ var _account_signupCountry;
127
+ const billing = [
128
+ ...new Set(account.billingCountries.map((country)=>String(country != null ? country : '').trim().toUpperCase()).filter((country)=>ALPHA2.test(country)))
129
+ ];
130
+ if (billing.length) {
131
+ const foreign = billing.find((country)=>country !== AD_AUDIENCE_COUNTRY);
132
+ return {
133
+ country: foreign != null ? foreign : AD_AUDIENCE_COUNTRY,
134
+ source: 'billing'
135
+ };
136
+ }
137
+ const signup = String((_account_signupCountry = account.signupCountry) != null ? _account_signupCountry : '').trim().toUpperCase();
138
+ if (ALPHA2.test(signup)) return {
139
+ country: signup,
140
+ source: 'signup'
141
+ };
142
+ return {
143
+ country: null,
144
+ source: null
145
+ };
146
+ }
147
+ /** The domain part of an operator address, lowercased, or null. */ export function emailDomain(address) {
148
+ const value = String(address != null ? address : '').trim().toLowerCase();
149
+ const at = value.lastIndexOf('@');
150
+ return at > 0 && at < value.length - 1 ? value.slice(at + 1) : null;
151
+ }
152
+ /**
153
+ * Why this account is left out, or null when it belongs in the audience.
154
+ *
155
+ * Every condition must hold: an address, not disabled, not staff, not on the
156
+ * operator's own domain, no account lock (an `abuse` lock is a permanent ban),
157
+ * not on the ban list, not opted out, a verified address, and a United
158
+ * States country. Account deletion needs no rule: an erased account has no
159
+ * Auth record, so it is never read at all, and the diff removes it.
160
+ *
161
+ * The address must be VERIFIED because anyone can sign up with an address
162
+ * that is not theirs, and uploading it would put a person who never signed up
163
+ * into the audience. A Google or SSO sign-in arrives verified.
164
+ */ export function adAudienceExclusion(account, options = {}) {
165
+ var _options_internalDomains;
166
+ const domain = emailDomain(account.email);
167
+ if (!domain) return 'no-email';
168
+ if (account.disabled) return 'disabled';
169
+ if (account.staff) return 'staff';
170
+ if (((_options_internalDomains = options.internalDomains) != null ? _options_internalDomains : []).some((internal)=>internal.toLowerCase() === domain)) {
171
+ return 'internal';
172
+ }
173
+ if (account.locked) return 'locked';
174
+ if (account.banned) return 'banned';
175
+ if (account.optedOut) return 'opted-out';
176
+ if (!account.emailVerified) return 'unverified';
177
+ const { country } = resolveAccountCountry(account);
178
+ if (!country) return 'no-country';
179
+ if (country !== AD_AUDIENCE_COUNTRY) return 'not-us';
180
+ return null;
181
+ }
182
+ /*==========================================
183
+ * THE DIFF
184
+ *=========================================*/ /** What has to change for a provider to hold `next` when it holds `previous`. */ export function diffAudience(previous, next) {
185
+ const before = new Set(previous);
186
+ const after = new Set(next);
187
+ return {
188
+ toAdd: [
189
+ ...after
190
+ ].filter((hash)=>!before.has(hash)).sort(),
191
+ toRemove: [
192
+ ...before
193
+ ].filter((hash)=>!after.has(hash)).sort()
194
+ };
195
+ }
196
+ /**
197
+ * Whether a night's set shrank so far it is more likely a failed read than
198
+ * real churn. Below twenty there is no telling the two apart, so it is never
199
+ * refused; above it, losing more than half the audience in one night is held
200
+ * until a person passes `force`.
201
+ */ export function isSuspiciousShrink(previousSize, nextSize) {
202
+ return previousSize >= 20 && nextSize < previousSize / 2;
203
+ }
204
+ /** `items` in runs of at most `size`. */ export function chunk(items, size) {
205
+ const runs = [];
206
+ for(let index = 0; index < items.length; index += size){
207
+ runs.push(items.slice(index, index + size));
208
+ }
209
+ return runs;
210
+ }
211
+ /**
212
+ * The variables, read here by their literal names so the self-host
213
+ * environment reference can see every one of them.
214
+ */ export function adAudienceEnv() {
215
+ return {
216
+ META_AUDIENCE_SYNC_ACCESS_TOKEN: process.env.META_AUDIENCE_SYNC_ACCESS_TOKEN,
217
+ META_AUDIENCE_SYNC_AUDIENCE_ID: process.env.META_AUDIENCE_SYNC_AUDIENCE_ID,
218
+ GOOGLE_AUDIENCE_SYNC_CLIENT_ID: process.env.GOOGLE_AUDIENCE_SYNC_CLIENT_ID,
219
+ GOOGLE_AUDIENCE_SYNC_CLIENT_SECRET: process.env.GOOGLE_AUDIENCE_SYNC_CLIENT_SECRET,
220
+ GOOGLE_AUDIENCE_SYNC_REFRESH_TOKEN: process.env.GOOGLE_AUDIENCE_SYNC_REFRESH_TOKEN,
221
+ GOOGLE_AUDIENCE_SYNC_CUSTOMER_ID: process.env.GOOGLE_AUDIENCE_SYNC_CUSTOMER_ID,
222
+ GOOGLE_AUDIENCE_SYNC_LOGIN_CUSTOMER_ID: process.env.GOOGLE_AUDIENCE_SYNC_LOGIN_CUSTOMER_ID,
223
+ GOOGLE_AUDIENCE_SYNC_USER_LIST_ID: process.env.GOOGLE_AUDIENCE_SYNC_USER_LIST_ID,
224
+ LINKEDIN_AUDIENCE_SYNC_ACCESS_TOKEN: process.env.LINKEDIN_AUDIENCE_SYNC_ACCESS_TOKEN,
225
+ LINKEDIN_AUDIENCE_SYNC_SEGMENT_ID: process.env.LINKEDIN_AUDIENCE_SYNC_SEGMENT_ID
226
+ };
227
+ }
228
+ const REQUIRED = {
229
+ meta: [
230
+ 'META_AUDIENCE_SYNC_ACCESS_TOKEN',
231
+ 'META_AUDIENCE_SYNC_AUDIENCE_ID'
232
+ ],
233
+ google: [
234
+ 'GOOGLE_AUDIENCE_SYNC_CLIENT_ID',
235
+ 'GOOGLE_AUDIENCE_SYNC_CLIENT_SECRET',
236
+ 'GOOGLE_AUDIENCE_SYNC_REFRESH_TOKEN',
237
+ 'GOOGLE_AUDIENCE_SYNC_CUSTOMER_ID',
238
+ 'GOOGLE_AUDIENCE_SYNC_USER_LIST_ID'
239
+ ],
240
+ linkedin: [
241
+ 'LINKEDIN_AUDIENCE_SYNC_ACCESS_TOKEN',
242
+ 'LINKEDIN_AUDIENCE_SYNC_SEGMENT_ID'
243
+ ]
244
+ };
245
+ const digits = (value)=>value.replace(/\D/g, '');
246
+ /**
247
+ * Each provider's settings, or null with the names it lacks. A provider is
248
+ * configured only when every required variable is set; a half-configured one
249
+ * is skipped, never half-sent.
250
+ *
251
+ * The Meta audience is never created here. Creating one when the id is unset
252
+ * would mint a fresh, empty audience on every run whose variable was lost, and
253
+ * the ads targeting the real one would go on pointing at it, so the id is
254
+ * required and the audience is made once, by hand.
255
+ */ export function readAdAudienceConfig(env = adAudienceEnv()) {
256
+ const value = (name)=>{
257
+ var _env_name;
258
+ return String((_env_name = env[name]) != null ? _env_name : '').trim();
259
+ };
260
+ const missing = Object.fromEntries(AD_AUDIENCE_PROVIDERS.map((provider)=>[
261
+ provider,
262
+ REQUIRED[provider].filter((name)=>!value(name))
263
+ ]));
264
+ const loginCustomerId = digits(value('GOOGLE_AUDIENCE_SYNC_LOGIN_CUSTOMER_ID'));
265
+ return {
266
+ meta: missing.meta.length ? null : {
267
+ accessToken: value('META_AUDIENCE_SYNC_ACCESS_TOKEN'),
268
+ // `act_` belongs to the ad account, never to an audience id.
269
+ audienceId: value('META_AUDIENCE_SYNC_AUDIENCE_ID')
270
+ },
271
+ google: missing.google.length ? null : {
272
+ clientId: value('GOOGLE_AUDIENCE_SYNC_CLIENT_ID'),
273
+ clientSecret: value('GOOGLE_AUDIENCE_SYNC_CLIENT_SECRET'),
274
+ refreshToken: value('GOOGLE_AUDIENCE_SYNC_REFRESH_TOKEN'),
275
+ // Google Ads shows `841-500-9958`; the API takes the digits.
276
+ customerId: digits(value('GOOGLE_AUDIENCE_SYNC_CUSTOMER_ID')),
277
+ loginCustomerId: loginCustomerId || null,
278
+ userListId: digits(value('GOOGLE_AUDIENCE_SYNC_USER_LIST_ID'))
279
+ },
280
+ linkedin: missing.linkedin.length ? null : {
281
+ accessToken: value('LINKEDIN_AUDIENCE_SYNC_ACCESS_TOKEN'),
282
+ // The segment URN's id, whichever form was pasted.
283
+ segmentId: value('LINKEDIN_AUDIENCE_SYNC_SEGMENT_ID').replace(/^urn:li:dmpSegment:/, '')
284
+ },
285
+ missing
286
+ };
287
+ }
288
+ /**
289
+ * META — the replace session.
290
+ *
291
+ * `POST /{audience}/usersreplace` swaps the audience's whole membership for
292
+ * the batches of one session: `batch_seq` 1 opens it and `last_batch_flag`
293
+ * closes it, so a removal needs no separate call and nothing has to be
294
+ * remembered about who was there before. Each batch carries at most 10,000
295
+ * hashes, and the session must finish inside 90 minutes. A replace does not
296
+ * reset an ad set's learning phase, which an add and a delete would.
297
+ */ export const META_GRAPH_VERSION = 'v25.0';
298
+ export const META_REPLACE_BATCH = 10000;
299
+ export function buildMetaReplaceRequests(config, hashes, sessionId) {
300
+ const batches = chunk(hashes, META_REPLACE_BATCH);
301
+ return batches.map((batch, index)=>({
302
+ method: 'POST',
303
+ url: `https://graph.facebook.com/${META_GRAPH_VERSION}/${encodeURIComponent(config.audienceId)}/usersreplace`,
304
+ headers: {
305
+ 'Content-Type': 'application/json'
306
+ },
307
+ body: {
308
+ payload: {
309
+ schema: 'EMAIL_SHA256',
310
+ data: batch
311
+ },
312
+ session: {
313
+ session_id: sessionId,
314
+ batch_seq: index + 1,
315
+ last_batch_flag: index === batches.length - 1,
316
+ estimated_num_total: hashes.length
317
+ },
318
+ access_token: config.accessToken
319
+ }
320
+ }));
321
+ }
322
+ /**
323
+ * GOOGLE — the Data Manager API, not the Google Ads API.
324
+ *
325
+ * Since 2026-04-01 the Google Ads API's `OfflineUserDataJobService` refuses
326
+ * Customer Match uploads for a developer token with no Customer Match
327
+ * activity in the six months before, and Google points every such project at
328
+ * the Data Manager API instead. It needs an OAuth token with the
329
+ * `datamanager` scope and no developer token.
330
+ *
331
+ * Adds go to `audienceMembers:ingest` and removals to `audienceMembers:remove`,
332
+ * 10,000 members a request. The Data Manager API has no replace, so the
333
+ * removals come from the set this sync last sent.
334
+ *
335
+ * No `consent` is sent. The audience is United States account holders only,
336
+ * for whom Google requires no consent signal, and stating `CONSENT_GRANTED`
337
+ * would assert a consent this platform does not collect.
338
+ * `customerMatchTermsOfServiceStatus: ACCEPTED` is the operator's own
339
+ * acceptance of the Customer Match terms, which Google requires on an
340
+ * ingest; they are accepted in the Google Ads account before this is
341
+ * configured.
342
+ */ export const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
343
+ export const GOOGLE_DATA_MANAGER_BASE = 'https://datamanager.googleapis.com/v1';
344
+ export const GOOGLE_MEMBERS_BATCH = 10000;
345
+ export function buildGoogleTokenRequest(config) {
346
+ return {
347
+ method: 'POST',
348
+ url: GOOGLE_TOKEN_URL,
349
+ headers: {
350
+ 'Content-Type': 'application/x-www-form-urlencoded'
351
+ },
352
+ body: new URLSearchParams({
353
+ grant_type: 'refresh_token',
354
+ client_id: config.clientId,
355
+ client_secret: config.clientSecret,
356
+ refresh_token: config.refreshToken
357
+ }).toString()
358
+ };
359
+ }
360
+ export function buildGoogleMemberRequests(config, accessToken, action, hashes) {
361
+ const destination = _extends({
362
+ operatingAccount: {
363
+ accountType: 'GOOGLE_ADS',
364
+ accountId: config.customerId
365
+ }
366
+ }, config.loginCustomerId ? {
367
+ loginAccount: {
368
+ accountType: 'GOOGLE_ADS',
369
+ accountId: config.loginCustomerId
370
+ }
371
+ } : {}, {
372
+ productDestinationId: config.userListId
373
+ });
374
+ return chunk(hashes, GOOGLE_MEMBERS_BATCH).map((batch)=>({
375
+ method: 'POST',
376
+ url: `${GOOGLE_DATA_MANAGER_BASE}/audienceMembers:${action}`,
377
+ headers: {
378
+ 'Content-Type': 'application/json',
379
+ Authorization: `Bearer ${accessToken}`
380
+ },
381
+ body: _extends({
382
+ destinations: [
383
+ destination
384
+ ],
385
+ audienceMembers: batch.map((hash)=>({
386
+ userData: {
387
+ userIdentifiers: [
388
+ {
389
+ emailAddress: hash
390
+ }
391
+ ]
392
+ }
393
+ })),
394
+ encoding: 'HEX'
395
+ }, action === 'ingest' ? {
396
+ termsOfService: {
397
+ customerMatchTermsOfServiceStatus: 'ACCEPTED'
398
+ }
399
+ } : {})
400
+ }));
401
+ }
402
+ /**
403
+ * LINKEDIN — DMP segment users, streamed.
404
+ *
405
+ * `POST /rest/dmpSegments/{id}/users` with `X-RestLi-Method: BATCH_CREATE`
406
+ * takes up to 5,000 `ADD` or `REMOVE` elements a request. There is no
407
+ * replace, so the removals come from the set this sync last sent. Adding an
408
+ * id twice or removing one already gone both answer 201, so a retry is safe.
409
+ * The API is open only to developers LinkedIn has approved for Matched
410
+ * Audiences, with the `rw_dmp_segments` permission.
411
+ */ export const LINKEDIN_VERSION = '202609';
412
+ export const LINKEDIN_USERS_BATCH = 5000;
413
+ export function buildLinkedInUserRequests(config, action, hashes) {
414
+ return chunk(hashes, LINKEDIN_USERS_BATCH).map((batch)=>({
415
+ method: 'POST',
416
+ url: `https://api.linkedin.com/rest/dmpSegments/${encodeURIComponent(config.segmentId)}/users`,
417
+ headers: {
418
+ 'Content-Type': 'application/json',
419
+ Authorization: `Bearer ${config.accessToken}`,
420
+ 'LinkedIn-Version': LINKEDIN_VERSION,
421
+ 'X-Restli-Protocol-Version': '2.0.0',
422
+ 'X-RestLi-Method': 'BATCH_CREATE'
423
+ },
424
+ body: {
425
+ elements: batch.map((hash)=>({
426
+ action,
427
+ userIds: [
428
+ {
429
+ idType: 'SHA256_EMAIL',
430
+ idValue: hash
431
+ }
432
+ ]
433
+ }))
434
+ }
435
+ }));
436
+ }
437
+ /** How many elements of a LinkedIn batch response were refused. */ export function linkedInRefusedCount(answer) {
438
+ const elements = answer == null ? void 0 : answer.elements;
439
+ if (!Array.isArray(elements)) return 0;
440
+ return elements.filter((element)=>{
441
+ const status = Number(element == null ? void 0 : element.status);
442
+ return !(status >= 200 && status < 300);
443
+ }).length;
444
+ }
445
+
446
+ //# sourceMappingURL=ad-audience-sync.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/ad-audience-sync.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 { createHash } from 'node:crypto'\n\n/**\n * THE ACCOUNT-HOLDER AD AUDIENCE (AGL-3780) — the pure half.\n *\n * Once a night the operator's own account holders are kept in step with a\n * customer-list audience on each of the operator's OWN ad accounts — Meta\n * Custom Audiences, Google Customer Match and LinkedIn DMP segments — so\n * signed-up people can be retargeted and left out of prospecting. This is\n * the platform's marketing, not a merchant feature: a merchant's own pixels\n * and conversion APIs are the ad-conversions plugin, which this shares\n * nothing with.\n *\n * Everything here is pure — normalization, hashing, who is eligible, the\n * add/remove diff and each provider's request — so the spec walks every rule\n * without a network. `ad-audience-sync-run.ts` reads the accounts, sends and\n * records.\n *\n * No address ever leaves in the clear: each provider receives the SHA-256 hex\n * digest of the address normalized the way that provider documents, and\n * nothing here logs either one.\n */\n\nexport const AD_AUDIENCE_PROVIDERS = ['meta', 'google', 'linkedin'] as const\nexport type AdAudienceProvider = (typeof AD_AUDIENCE_PROVIDERS)[number]\n\nexport const AD_AUDIENCE_PROVIDER_LABELS: Record<AdAudienceProvider, string> = {\n meta: 'Meta',\n google: 'Google',\n linkedin: 'LinkedIn',\n}\n\n/*==========================================\n * NORMALIZATION AND HASHING\n *=========================================*/\n\nconst GOOGLE_CONSUMER_DOMAINS = new Set(['gmail.com', 'googlemail.com'])\n\n/**\n * The address the way one provider says to normalize it before hashing, or\n * null when it is not an address at all.\n *\n * - **Meta** (Custom Audiences, `EMAIL`): trim leading and trailing white\n * space, lowercase.\n * - **LinkedIn** (DMP segment users, `SHA256_EMAIL`): lowercase, remove any\n * white space.\n * - **Google** (Data Manager API, Customer Match): lowercase, remove leading,\n * trailing and inner white space, and for `gmail.com` and `googlemail.com`\n * only, remove the dots from the local part and drop a `+` and everything\n * after it. Other domains keep both.\n *\n * Inner white space is never part of a deliverable address, so removing it\n * for Meta as well would change nothing real; it is left exactly as Meta\n * documents it.\n */\nexport function normalizeAudienceEmail(\n provider: AdAudienceProvider,\n raw: unknown,\n): string | null {\n if (typeof raw !== 'string') return null\n let email =\n provider === 'meta' ? raw.trim().toLowerCase() : raw.replace(/\\s+/g, '').toLowerCase()\n const at = email.lastIndexOf('@')\n if (at <= 0 || at === email.length - 1 || email.indexOf('@') !== at) return null\n if (provider === 'google') {\n let local = email.slice(0, at)\n const domain = email.slice(at + 1)\n if (GOOGLE_CONSUMER_DOMAINS.has(domain)) {\n const plus = local.indexOf('+')\n if (plus >= 0) local = local.slice(0, plus)\n local = local.replace(/\\./g, '')\n if (!local) return null\n email = `${local}@${domain}`\n }\n }\n return email\n}\n\n/** Lowercase SHA-256 hex of a UTF-8 string: what all three providers take. */\nexport function sha256Hex(value: string): string {\n return createHash('sha256').update(value, 'utf8').digest('hex')\n}\n\n/** The hash one provider receives for an address, or null for no address. */\nexport function hashAudienceEmail(provider: AdAudienceProvider, raw: unknown): string | null {\n const normalized = normalizeAudienceEmail(provider, raw)\n return normalized ? sha256Hex(normalized) : null\n}\n\n/*==========================================\n * ELIGIBILITY\n *=========================================*/\n\n/** What the sync knows about one account, read from Auth and Firestore. */\nexport interface AdAudienceAccount {\n uid: string\n email: string | null\n emailVerified: boolean\n disabled: boolean\n /** The `staff` custom claim. */\n staff: boolean\n /** `users/{uid}.adAudienceOptOut` is set. */\n optedOut: boolean\n /** An account lockdown is in force (`lockdowns/user--{uid}`). */\n locked: boolean\n /** The address is on the account-ban suppression list (an `abuse` ban). */\n banned: boolean\n /**\n * The billing-address countries (`orgs/{id}.contact.address.country`) of\n * the workspaces the account OWNS.\n */\n billingCountries: readonly string[]\n /** `users/{uid}.acquisition.geo.country`: where the sign-up request came from. */\n signupCountry: string | null\n}\n\n/** Why an account is left out. Counted in every report; never per person. */\nexport type AdAudienceExclusion =\n | 'no-email'\n | 'disabled'\n | 'staff'\n | 'internal'\n | 'locked'\n | 'banned'\n | 'opted-out'\n | 'unverified'\n | 'no-country'\n | 'not-us'\n\nexport const AD_AUDIENCE_EXCLUSIONS: readonly AdAudienceExclusion[] = [\n 'no-email',\n 'disabled',\n 'staff',\n 'internal',\n 'locked',\n 'banned',\n 'opted-out',\n 'unverified',\n 'no-country',\n 'not-us',\n]\n\n/** The one country the audience is built for. */\nexport const AD_AUDIENCE_COUNTRY = 'US'\n\nexport type AdAudienceCountrySource = 'billing' | 'signup'\n\nconst ALPHA2 = /^[A-Z]{2}$/\n\n/**\n * The account's country, and which signal said so.\n *\n * 1. **The billing address** of a workspace the account owns. It is the\n * address the account holder typed or gave Stripe, it is the address tax\n * is computed on, and it is the strongest country fact the platform holds.\n * When the account owns workspaces billed in different countries, the\n * answer is US only when EVERY one of them is US: an account that bills\n * from abroad is not counted as a US account holder on the strength of\n * one US workspace.\n * 2. Otherwise **the sign-up geography**: the country the edge reported for\n * the request that created the account (AGL-3289). It says where the\n * person was when they signed up, which is a weaker fact, and it is the\n * one most accounts have.\n * 3. Otherwise nothing, and an account with no country is left out. A phone\n * number's `+1` is deliberately not a signal: it is shared with Canada and\n * the Caribbean.\n */\nexport function resolveAccountCountry(account: {\n billingCountries: readonly string[]\n signupCountry: string | null\n}): { country: string | null; source: AdAudienceCountrySource | null } {\n const billing = [\n ...new Set(\n account.billingCountries\n .map((country) => String(country ?? '').trim().toUpperCase())\n .filter((country) => ALPHA2.test(country)),\n ),\n ]\n if (billing.length) {\n const foreign = billing.find((country) => country !== AD_AUDIENCE_COUNTRY)\n return { country: foreign ?? AD_AUDIENCE_COUNTRY, source: 'billing' }\n }\n const signup = String(account.signupCountry ?? '').trim().toUpperCase()\n if (ALPHA2.test(signup)) return { country: signup, source: 'signup' }\n return { country: null, source: null }\n}\n\n/** The domain part of an operator address, lowercased, or null. */\nexport function emailDomain(address: string | null | undefined): string | null {\n const value = String(address ?? '').trim().toLowerCase()\n const at = value.lastIndexOf('@')\n return at > 0 && at < value.length - 1 ? value.slice(at + 1) : null\n}\n\n/**\n * Why this account is left out, or null when it belongs in the audience.\n *\n * Every condition must hold: an address, not disabled, not staff, not on the\n * operator's own domain, no account lock (an `abuse` lock is a permanent ban),\n * not on the ban list, not opted out, a verified address, and a United\n * States country. Account deletion needs no rule: an erased account has no\n * Auth record, so it is never read at all, and the diff removes it.\n *\n * The address must be VERIFIED because anyone can sign up with an address\n * that is not theirs, and uploading it would put a person who never signed up\n * into the audience. A Google or SSO sign-in arrives verified.\n */\nexport function adAudienceExclusion(\n account: AdAudienceAccount,\n options: { internalDomains?: readonly string[] } = {},\n): AdAudienceExclusion | null {\n const domain = emailDomain(account.email)\n if (!domain) return 'no-email'\n if (account.disabled) return 'disabled'\n if (account.staff) return 'staff'\n if ((options.internalDomains ?? []).some((internal) => internal.toLowerCase() === domain)) {\n return 'internal'\n }\n if (account.locked) return 'locked'\n if (account.banned) return 'banned'\n if (account.optedOut) return 'opted-out'\n if (!account.emailVerified) return 'unverified'\n const { country } = resolveAccountCountry(account)\n if (!country) return 'no-country'\n if (country !== AD_AUDIENCE_COUNTRY) return 'not-us'\n return null\n}\n\n/*==========================================\n * THE DIFF\n *=========================================*/\n\n/** What has to change for a provider to hold `next` when it holds `previous`. */\nexport function diffAudience(\n previous: Iterable<string>,\n next: Iterable<string>,\n): { toAdd: string[]; toRemove: string[] } {\n const before = new Set(previous)\n const after = new Set(next)\n return {\n toAdd: [...after].filter((hash) => !before.has(hash)).sort(),\n toRemove: [...before].filter((hash) => !after.has(hash)).sort(),\n }\n}\n\n/**\n * Whether a night's set shrank so far it is more likely a failed read than\n * real churn. Below twenty there is no telling the two apart, so it is never\n * refused; above it, losing more than half the audience in one night is held\n * until a person passes `force`.\n */\nexport function isSuspiciousShrink(previousSize: number, nextSize: number): boolean {\n return previousSize >= 20 && nextSize < previousSize / 2\n}\n\n/** `items` in runs of at most `size`. */\nexport function chunk<T>(items: readonly T[], size: number): T[][] {\n const runs: T[][] = []\n for (let index = 0; index < items.length; index += size) {\n runs.push(items.slice(index, index + size))\n }\n return runs\n}\n\n/*==========================================\n * CONFIGURATION\n *=========================================*/\n\nexport interface MetaAudienceConfig {\n accessToken: string\n audienceId: string\n}\n\nexport interface GoogleAudienceConfig {\n clientId: string\n clientSecret: string\n refreshToken: string\n /** The Google Ads customer that owns the list, digits only. */\n customerId: string\n /** A manager account the credentials reach the customer through, digits only. */\n loginCustomerId: string | null\n /** The Customer Match user list id. */\n userListId: string\n}\n\nexport interface LinkedInAudienceConfig {\n accessToken: string\n segmentId: string\n}\n\nexport interface AdAudienceConfig {\n meta: MetaAudienceConfig | null\n google: GoogleAudienceConfig | null\n linkedin: LinkedInAudienceConfig | null\n /** For each provider left unconfigured, the variables it is missing. */\n missing: Record<AdAudienceProvider, string[]>\n}\n\n/**\n * The variables, read here by their literal names so the self-host\n * environment reference can see every one of them.\n */\nexport function adAudienceEnv(): Record<string, string | undefined> {\n return {\n META_AUDIENCE_SYNC_ACCESS_TOKEN: process.env.META_AUDIENCE_SYNC_ACCESS_TOKEN,\n META_AUDIENCE_SYNC_AUDIENCE_ID: process.env.META_AUDIENCE_SYNC_AUDIENCE_ID,\n GOOGLE_AUDIENCE_SYNC_CLIENT_ID: process.env.GOOGLE_AUDIENCE_SYNC_CLIENT_ID,\n GOOGLE_AUDIENCE_SYNC_CLIENT_SECRET: process.env.GOOGLE_AUDIENCE_SYNC_CLIENT_SECRET,\n GOOGLE_AUDIENCE_SYNC_REFRESH_TOKEN: process.env.GOOGLE_AUDIENCE_SYNC_REFRESH_TOKEN,\n GOOGLE_AUDIENCE_SYNC_CUSTOMER_ID: process.env.GOOGLE_AUDIENCE_SYNC_CUSTOMER_ID,\n GOOGLE_AUDIENCE_SYNC_LOGIN_CUSTOMER_ID: process.env.GOOGLE_AUDIENCE_SYNC_LOGIN_CUSTOMER_ID,\n GOOGLE_AUDIENCE_SYNC_USER_LIST_ID: process.env.GOOGLE_AUDIENCE_SYNC_USER_LIST_ID,\n LINKEDIN_AUDIENCE_SYNC_ACCESS_TOKEN: process.env.LINKEDIN_AUDIENCE_SYNC_ACCESS_TOKEN,\n LINKEDIN_AUDIENCE_SYNC_SEGMENT_ID: process.env.LINKEDIN_AUDIENCE_SYNC_SEGMENT_ID,\n }\n}\n\nconst REQUIRED: Record<AdAudienceProvider, readonly string[]> = {\n meta: ['META_AUDIENCE_SYNC_ACCESS_TOKEN', 'META_AUDIENCE_SYNC_AUDIENCE_ID'],\n google: [\n 'GOOGLE_AUDIENCE_SYNC_CLIENT_ID',\n 'GOOGLE_AUDIENCE_SYNC_CLIENT_SECRET',\n 'GOOGLE_AUDIENCE_SYNC_REFRESH_TOKEN',\n 'GOOGLE_AUDIENCE_SYNC_CUSTOMER_ID',\n 'GOOGLE_AUDIENCE_SYNC_USER_LIST_ID',\n ],\n linkedin: ['LINKEDIN_AUDIENCE_SYNC_ACCESS_TOKEN', 'LINKEDIN_AUDIENCE_SYNC_SEGMENT_ID'],\n}\n\nconst digits = (value: string) => value.replace(/\\D/g, '')\n\n/**\n * Each provider's settings, or null with the names it lacks. A provider is\n * configured only when every required variable is set; a half-configured one\n * is skipped, never half-sent.\n *\n * The Meta audience is never created here. Creating one when the id is unset\n * would mint a fresh, empty audience on every run whose variable was lost, and\n * the ads targeting the real one would go on pointing at it, so the id is\n * required and the audience is made once, by hand.\n */\nexport function readAdAudienceConfig(\n env: Record<string, string | undefined> = adAudienceEnv(),\n): AdAudienceConfig {\n const value = (name: string) => String(env[name] ?? '').trim()\n const missing = Object.fromEntries(\n AD_AUDIENCE_PROVIDERS.map((provider) => [\n provider,\n REQUIRED[provider].filter((name) => !value(name)),\n ]),\n ) as Record<AdAudienceProvider, string[]>\n const loginCustomerId = digits(value('GOOGLE_AUDIENCE_SYNC_LOGIN_CUSTOMER_ID'))\n return {\n meta: missing.meta.length\n ? null\n : {\n accessToken: value('META_AUDIENCE_SYNC_ACCESS_TOKEN'),\n // `act_` belongs to the ad account, never to an audience id.\n audienceId: value('META_AUDIENCE_SYNC_AUDIENCE_ID'),\n },\n google: missing.google.length\n ? null\n : {\n clientId: value('GOOGLE_AUDIENCE_SYNC_CLIENT_ID'),\n clientSecret: value('GOOGLE_AUDIENCE_SYNC_CLIENT_SECRET'),\n refreshToken: value('GOOGLE_AUDIENCE_SYNC_REFRESH_TOKEN'),\n // Google Ads shows `841-500-9958`; the API takes the digits.\n customerId: digits(value('GOOGLE_AUDIENCE_SYNC_CUSTOMER_ID')),\n loginCustomerId: loginCustomerId || null,\n userListId: digits(value('GOOGLE_AUDIENCE_SYNC_USER_LIST_ID')),\n },\n linkedin: missing.linkedin.length\n ? null\n : {\n accessToken: value('LINKEDIN_AUDIENCE_SYNC_ACCESS_TOKEN'),\n // The segment URN's id, whichever form was pasted.\n segmentId: value('LINKEDIN_AUDIENCE_SYNC_SEGMENT_ID').replace(/^urn:li:dmpSegment:/, ''),\n },\n missing,\n }\n}\n\n/*==========================================\n * PROVIDER REQUESTS\n *=========================================*/\n\n/** One HTTP request, built without sending it. */\nexport interface AudienceRequest {\n method: 'POST'\n url: string\n headers: Record<string, string>\n /** A JSON body, or a form body when `headers` says so. */\n body: Record<string, unknown> | string\n}\n\n/**\n * META — the replace session.\n *\n * `POST /{audience}/usersreplace` swaps the audience's whole membership for\n * the batches of one session: `batch_seq` 1 opens it and `last_batch_flag`\n * closes it, so a removal needs no separate call and nothing has to be\n * remembered about who was there before. Each batch carries at most 10,000\n * hashes, and the session must finish inside 90 minutes. A replace does not\n * reset an ad set's learning phase, which an add and a delete would.\n */\nexport const META_GRAPH_VERSION = 'v25.0'\nexport const META_REPLACE_BATCH = 10_000\n\nexport function buildMetaReplaceRequests(\n config: MetaAudienceConfig,\n hashes: readonly string[],\n sessionId: number,\n): AudienceRequest[] {\n const batches = chunk(hashes, META_REPLACE_BATCH)\n return batches.map((batch, index) => ({\n method: 'POST',\n url: `https://graph.facebook.com/${META_GRAPH_VERSION}/${encodeURIComponent(config.audienceId)}/usersreplace`,\n headers: { 'Content-Type': 'application/json' },\n body: {\n payload: { schema: 'EMAIL_SHA256', data: batch },\n session: {\n session_id: sessionId,\n batch_seq: index + 1,\n last_batch_flag: index === batches.length - 1,\n estimated_num_total: hashes.length,\n },\n access_token: config.accessToken,\n },\n }))\n}\n\n/**\n * GOOGLE — the Data Manager API, not the Google Ads API.\n *\n * Since 2026-04-01 the Google Ads API's `OfflineUserDataJobService` refuses\n * Customer Match uploads for a developer token with no Customer Match\n * activity in the six months before, and Google points every such project at\n * the Data Manager API instead. It needs an OAuth token with the\n * `datamanager` scope and no developer token.\n *\n * Adds go to `audienceMembers:ingest` and removals to `audienceMembers:remove`,\n * 10,000 members a request. The Data Manager API has no replace, so the\n * removals come from the set this sync last sent.\n *\n * No `consent` is sent. The audience is United States account holders only,\n * for whom Google requires no consent signal, and stating `CONSENT_GRANTED`\n * would assert a consent this platform does not collect.\n * `customerMatchTermsOfServiceStatus: ACCEPTED` is the operator's own\n * acceptance of the Customer Match terms, which Google requires on an\n * ingest; they are accepted in the Google Ads account before this is\n * configured.\n */\nexport const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'\nexport const GOOGLE_DATA_MANAGER_BASE = 'https://datamanager.googleapis.com/v1'\nexport const GOOGLE_MEMBERS_BATCH = 10_000\n\nexport function buildGoogleTokenRequest(config: GoogleAudienceConfig): AudienceRequest {\n return {\n method: 'POST',\n url: GOOGLE_TOKEN_URL,\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'refresh_token',\n client_id: config.clientId,\n client_secret: config.clientSecret,\n refresh_token: config.refreshToken,\n }).toString(),\n }\n}\n\nexport function buildGoogleMemberRequests(\n config: GoogleAudienceConfig,\n accessToken: string,\n action: 'ingest' | 'remove',\n hashes: readonly string[],\n): AudienceRequest[] {\n const destination = {\n operatingAccount: { accountType: 'GOOGLE_ADS', accountId: config.customerId },\n ...(config.loginCustomerId\n ? { loginAccount: { accountType: 'GOOGLE_ADS', accountId: config.loginCustomerId } }\n : {}),\n productDestinationId: config.userListId,\n }\n return chunk(hashes, GOOGLE_MEMBERS_BATCH).map((batch) => ({\n method: 'POST',\n url: `${GOOGLE_DATA_MANAGER_BASE}/audienceMembers:${action}`,\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${accessToken}`,\n },\n body: {\n destinations: [destination],\n audienceMembers: batch.map((hash) => ({\n userData: { userIdentifiers: [{ emailAddress: hash }] },\n })),\n encoding: 'HEX',\n ...(action === 'ingest'\n ? { termsOfService: { customerMatchTermsOfServiceStatus: 'ACCEPTED' } }\n : {}),\n },\n }))\n}\n\n/**\n * LINKEDIN — DMP segment users, streamed.\n *\n * `POST /rest/dmpSegments/{id}/users` with `X-RestLi-Method: BATCH_CREATE`\n * takes up to 5,000 `ADD` or `REMOVE` elements a request. There is no\n * replace, so the removals come from the set this sync last sent. Adding an\n * id twice or removing one already gone both answer 201, so a retry is safe.\n * The API is open only to developers LinkedIn has approved for Matched\n * Audiences, with the `rw_dmp_segments` permission.\n */\nexport const LINKEDIN_VERSION = '202609'\nexport const LINKEDIN_USERS_BATCH = 5_000\n\nexport function buildLinkedInUserRequests(\n config: LinkedInAudienceConfig,\n action: 'ADD' | 'REMOVE',\n hashes: readonly string[],\n): AudienceRequest[] {\n return chunk(hashes, LINKEDIN_USERS_BATCH).map((batch) => ({\n method: 'POST',\n url: `https://api.linkedin.com/rest/dmpSegments/${encodeURIComponent(config.segmentId)}/users`,\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${config.accessToken}`,\n 'LinkedIn-Version': LINKEDIN_VERSION,\n 'X-Restli-Protocol-Version': '2.0.0',\n 'X-RestLi-Method': 'BATCH_CREATE',\n },\n body: {\n elements: batch.map((hash) => ({\n action,\n userIds: [{ idType: 'SHA256_EMAIL', idValue: hash }],\n })),\n },\n }))\n}\n\n/** How many elements of a LinkedIn batch response were refused. */\nexport function linkedInRefusedCount(answer: unknown): number {\n const elements = (answer as { elements?: unknown } | null)?.elements\n if (!Array.isArray(elements)) return 0\n return elements.filter((element) => {\n const status = Number((element as { status?: unknown } | null)?.status)\n return !(status >= 200 && status < 300)\n }).length\n}\n"],"names":["createHash","AD_AUDIENCE_PROVIDERS","AD_AUDIENCE_PROVIDER_LABELS","meta","google","linkedin","GOOGLE_CONSUMER_DOMAINS","Set","normalizeAudienceEmail","provider","raw","email","trim","toLowerCase","replace","at","lastIndexOf","length","indexOf","local","slice","domain","has","plus","sha256Hex","value","update","digest","hashAudienceEmail","normalized","AD_AUDIENCE_EXCLUSIONS","AD_AUDIENCE_COUNTRY","ALPHA2","resolveAccountCountry","account","billing","billingCountries","map","country","String","toUpperCase","filter","test","foreign","find","source","signup","signupCountry","emailDomain","address","adAudienceExclusion","options","disabled","staff","internalDomains","some","internal","locked","banned","optedOut","emailVerified","diffAudience","previous","next","before","after","toAdd","hash","sort","toRemove","isSuspiciousShrink","previousSize","nextSize","chunk","items","size","runs","index","push","adAudienceEnv","META_AUDIENCE_SYNC_ACCESS_TOKEN","process","env","META_AUDIENCE_SYNC_AUDIENCE_ID","GOOGLE_AUDIENCE_SYNC_CLIENT_ID","GOOGLE_AUDIENCE_SYNC_CLIENT_SECRET","GOOGLE_AUDIENCE_SYNC_REFRESH_TOKEN","GOOGLE_AUDIENCE_SYNC_CUSTOMER_ID","GOOGLE_AUDIENCE_SYNC_LOGIN_CUSTOMER_ID","GOOGLE_AUDIENCE_SYNC_USER_LIST_ID","LINKEDIN_AUDIENCE_SYNC_ACCESS_TOKEN","LINKEDIN_AUDIENCE_SYNC_SEGMENT_ID","REQUIRED","digits","readAdAudienceConfig","name","missing","Object","fromEntries","loginCustomerId","accessToken","audienceId","clientId","clientSecret","refreshToken","customerId","userListId","segmentId","META_GRAPH_VERSION","META_REPLACE_BATCH","buildMetaReplaceRequests","config","hashes","sessionId","batches","batch","method","url","encodeURIComponent","headers","body","payload","schema","data","session","session_id","batch_seq","last_batch_flag","estimated_num_total","access_token","GOOGLE_TOKEN_URL","GOOGLE_DATA_MANAGER_BASE","GOOGLE_MEMBERS_BATCH","buildGoogleTokenRequest","URLSearchParams","grant_type","client_id","client_secret","refresh_token","toString","buildGoogleMemberRequests","action","destination","operatingAccount","accountType","accountId","loginAccount","productDestinationId","Authorization","destinations","audienceMembers","userData","userIdentifiers","emailAddress","encoding","termsOfService","customerMatchTermsOfServiceStatus","LINKEDIN_VERSION","LINKEDIN_USERS_BATCH","buildLinkedInUserRequests","elements","userIds","idType","idValue","linkedInRefusedCount","answer","Array","isArray","element","status","Number"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,cAAa;AAExC;;;;;;;;;;;;;;;;;;;CAmBC,GAED,OAAO,MAAMC,wBAAwB;IAAC;IAAQ;IAAU;CAAW,CAAS;AAG5E,OAAO,MAAMC,8BAAkE;IAC7EC,MAAM;IACNC,QAAQ;IACRC,UAAU;AACZ,EAAC;AAED;;2CAE2C,GAE3C,MAAMC,0BAA0B,IAAIC,IAAI;IAAC;IAAa;CAAiB;AAEvE;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASC,uBACdC,QAA4B,EAC5BC,GAAY;IAEZ,IAAI,OAAOA,QAAQ,UAAU,OAAO;IACpC,IAAIC,QACFF,aAAa,SAASC,IAAIE,IAAI,GAAGC,WAAW,KAAKH,IAAII,OAAO,CAAC,QAAQ,IAAID,WAAW;IACtF,MAAME,KAAKJ,MAAMK,WAAW,CAAC;IAC7B,IAAID,MAAM,KAAKA,OAAOJ,MAAMM,MAAM,GAAG,KAAKN,MAAMO,OAAO,CAAC,SAASH,IAAI,OAAO;IAC5E,IAAIN,aAAa,UAAU;QACzB,IAAIU,QAAQR,MAAMS,KAAK,CAAC,GAAGL;QAC3B,MAAMM,SAASV,MAAMS,KAAK,CAACL,KAAK;QAChC,IAAIT,wBAAwBgB,GAAG,CAACD,SAAS;YACvC,MAAME,OAAOJ,MAAMD,OAAO,CAAC;YAC3B,IAAIK,QAAQ,GAAGJ,QAAQA,MAAMC,KAAK,CAAC,GAAGG;YACtCJ,QAAQA,MAAML,OAAO,CAAC,OAAO;YAC7B,IAAI,CAACK,OAAO,OAAO;YACnBR,QAAQ,GAAGQ,MAAM,CAAC,EAAEE,QAAQ;QAC9B;IACF;IACA,OAAOV;AACT;AAEA,4EAA4E,GAC5E,OAAO,SAASa,UAAUC,KAAa;IACrC,OAAOzB,WAAW,UAAU0B,MAAM,CAACD,OAAO,QAAQE,MAAM,CAAC;AAC3D;AAEA,2EAA2E,GAC3E,OAAO,SAASC,kBAAkBnB,QAA4B,EAAEC,GAAY;IAC1E,MAAMmB,aAAarB,uBAAuBC,UAAUC;IACpD,OAAOmB,aAAaL,UAAUK,cAAc;AAC9C;AA0CA,OAAO,MAAMC,yBAAyD;IACpE;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD,CAAA;AAED,+CAA+C,GAC/C,OAAO,MAAMC,sBAAsB,KAAI;AAIvC,MAAMC,SAAS;AAEf;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASC,sBAAsBC,OAGrC;QAYuBA;IAXtB,MAAMC,UAAU;WACX,IAAI5B,IACL2B,QAAQE,gBAAgB,CACrBC,GAAG,CAAC,CAACC,UAAYC,OAAOD,kBAAAA,UAAW,IAAI1B,IAAI,GAAG4B,WAAW,IACzDC,MAAM,CAAC,CAACH,UAAYN,OAAOU,IAAI,CAACJ;KAEtC;IACD,IAAIH,QAAQlB,MAAM,EAAE;QAClB,MAAM0B,UAAUR,QAAQS,IAAI,CAAC,CAACN,UAAYA,YAAYP;QACtD,OAAO;YAAEO,OAAO,EAAEK,kBAAAA,UAAWZ;YAAqBc,QAAQ;QAAU;IACtE;IACA,MAAMC,SAASP,QAAOL,yBAAAA,QAAQa,aAAa,YAArBb,yBAAyB,IAAItB,IAAI,GAAG4B,WAAW;IACrE,IAAIR,OAAOU,IAAI,CAACI,SAAS,OAAO;QAAER,SAASQ;QAAQD,QAAQ;IAAS;IACpE,OAAO;QAAEP,SAAS;QAAMO,QAAQ;IAAK;AACvC;AAEA,iEAAiE,GACjE,OAAO,SAASG,YAAYC,OAAkC;IAC5D,MAAMxB,QAAQc,OAAOU,kBAAAA,UAAW,IAAIrC,IAAI,GAAGC,WAAW;IACtD,MAAME,KAAKU,MAAMT,WAAW,CAAC;IAC7B,OAAOD,KAAK,KAAKA,KAAKU,MAAMR,MAAM,GAAG,IAAIQ,MAAML,KAAK,CAACL,KAAK,KAAK;AACjE;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASmC,oBACdhB,OAA0B,EAC1BiB,UAAmD,CAAC,CAAC;QAMhDA;IAJL,MAAM9B,SAAS2B,YAAYd,QAAQvB,KAAK;IACxC,IAAI,CAACU,QAAQ,OAAO;IACpB,IAAIa,QAAQkB,QAAQ,EAAE,OAAO;IAC7B,IAAIlB,QAAQmB,KAAK,EAAE,OAAO;IAC1B,IAAI,EAACF,2BAAAA,QAAQG,eAAe,YAAvBH,2BAA2B,EAAE,EAAEI,IAAI,CAAC,CAACC,WAAaA,SAAS3C,WAAW,OAAOQ,SAAS;QACzF,OAAO;IACT;IACA,IAAIa,QAAQuB,MAAM,EAAE,OAAO;IAC3B,IAAIvB,QAAQwB,MAAM,EAAE,OAAO;IAC3B,IAAIxB,QAAQyB,QAAQ,EAAE,OAAO;IAC7B,IAAI,CAACzB,QAAQ0B,aAAa,EAAE,OAAO;IACnC,MAAM,EAAEtB,OAAO,EAAE,GAAGL,sBAAsBC;IAC1C,IAAI,CAACI,SAAS,OAAO;IACrB,IAAIA,YAAYP,qBAAqB,OAAO;IAC5C,OAAO;AACT;AAEA;;2CAE2C,GAE3C,+EAA+E,GAC/E,OAAO,SAAS8B,aACdC,QAA0B,EAC1BC,IAAsB;IAEtB,MAAMC,SAAS,IAAIzD,IAAIuD;IACvB,MAAMG,QAAQ,IAAI1D,IAAIwD;IACtB,OAAO;QACLG,OAAO;eAAID;SAAM,CAACxB,MAAM,CAAC,CAAC0B,OAAS,CAACH,OAAO1C,GAAG,CAAC6C,OAAOC,IAAI;QAC1DC,UAAU;eAAIL;SAAO,CAACvB,MAAM,CAAC,CAAC0B,OAAS,CAACF,MAAM3C,GAAG,CAAC6C,OAAOC,IAAI;IAC/D;AACF;AAEA;;;;;CAKC,GACD,OAAO,SAASE,mBAAmBC,YAAoB,EAAEC,QAAgB;IACvE,OAAOD,gBAAgB,MAAMC,WAAWD,eAAe;AACzD;AAEA,uCAAuC,GACvC,OAAO,SAASE,MAASC,KAAmB,EAAEC,IAAY;IACxD,MAAMC,OAAc,EAAE;IACtB,IAAK,IAAIC,QAAQ,GAAGA,QAAQH,MAAMzD,MAAM,EAAE4D,SAASF,KAAM;QACvDC,KAAKE,IAAI,CAACJ,MAAMtD,KAAK,CAACyD,OAAOA,QAAQF;IACvC;IACA,OAAOC;AACT;AAoCA;;;CAGC,GACD,OAAO,SAASG;IACd,OAAO;QACLC,iCAAiCC,QAAQC,GAAG,CAACF,+BAA+B;QAC5EG,gCAAgCF,QAAQC,GAAG,CAACC,8BAA8B;QAC1EC,gCAAgCH,QAAQC,GAAG,CAACE,8BAA8B;QAC1EC,oCAAoCJ,QAAQC,GAAG,CAACG,kCAAkC;QAClFC,oCAAoCL,QAAQC,GAAG,CAACI,kCAAkC;QAClFC,kCAAkCN,QAAQC,GAAG,CAACK,gCAAgC;QAC9EC,wCAAwCP,QAAQC,GAAG,CAACM,sCAAsC;QAC1FC,mCAAmCR,QAAQC,GAAG,CAACO,iCAAiC;QAChFC,qCAAqCT,QAAQC,GAAG,CAACQ,mCAAmC;QACpFC,mCAAmCV,QAAQC,GAAG,CAACS,iCAAiC;IAClF;AACF;AAEA,MAAMC,WAA0D;IAC9DzF,MAAM;QAAC;QAAmC;KAAiC;IAC3EC,QAAQ;QACN;QACA;QACA;QACA;QACA;KACD;IACDC,UAAU;QAAC;QAAuC;KAAoC;AACxF;AAEA,MAAMwF,SAAS,CAACpE,QAAkBA,MAAMX,OAAO,CAAC,OAAO;AAEvD;;;;;;;;;CASC,GACD,OAAO,SAASgF,qBACdZ,MAA0CH,eAAe;IAEzD,MAAMtD,QAAQ,CAACsE;YAAwBb;eAAP3C,QAAO2C,YAAAA,GAAG,CAACa,KAAK,YAATb,YAAa,IAAItE,IAAI;;IAC5D,MAAMoF,UAAUC,OAAOC,WAAW,CAChCjG,sBAAsBoC,GAAG,CAAC,CAAC5B,WAAa;YACtCA;YACAmF,QAAQ,CAACnF,SAAS,CAACgC,MAAM,CAAC,CAACsD,OAAS,CAACtE,MAAMsE;SAC5C;IAEH,MAAMI,kBAAkBN,OAAOpE,MAAM;IACrC,OAAO;QACLtB,MAAM6F,QAAQ7F,IAAI,CAACc,MAAM,GACrB,OACA;YACEmF,aAAa3E,MAAM;YACnB,6DAA6D;YAC7D4E,YAAY5E,MAAM;QACpB;QACJrB,QAAQ4F,QAAQ5F,MAAM,CAACa,MAAM,GACzB,OACA;YACEqF,UAAU7E,MAAM;YAChB8E,cAAc9E,MAAM;YACpB+E,cAAc/E,MAAM;YACpB,6DAA6D;YAC7DgF,YAAYZ,OAAOpE,MAAM;YACzB0E,iBAAiBA,mBAAmB;YACpCO,YAAYb,OAAOpE,MAAM;QAC3B;QACJpB,UAAU2F,QAAQ3F,QAAQ,CAACY,MAAM,GAC7B,OACA;YACEmF,aAAa3E,MAAM;YACnB,mDAAmD;YACnDkF,WAAWlF,MAAM,qCAAqCX,OAAO,CAAC,uBAAuB;QACvF;QACJkF;IACF;AACF;AAeA;;;;;;;;;CASC,GACD,OAAO,MAAMY,qBAAqB,QAAO;AACzC,OAAO,MAAMC,qBAAqB,MAAM;AAExC,OAAO,SAASC,yBACdC,MAA0B,EAC1BC,MAAyB,EACzBC,SAAiB;IAEjB,MAAMC,UAAUzC,MAAMuC,QAAQH;IAC9B,OAAOK,QAAQ7E,GAAG,CAAC,CAAC8E,OAAOtC,QAAW,CAAA;YACpCuC,QAAQ;YACRC,KAAK,CAAC,2BAA2B,EAAET,mBAAmB,CAAC,EAAEU,mBAAmBP,OAAOV,UAAU,EAAE,aAAa,CAAC;YAC7GkB,SAAS;gBAAE,gBAAgB;YAAmB;YAC9CC,MAAM;gBACJC,SAAS;oBAAEC,QAAQ;oBAAgBC,MAAMR;gBAAM;gBAC/CS,SAAS;oBACPC,YAAYZ;oBACZa,WAAWjD,QAAQ;oBACnBkD,iBAAiBlD,UAAUqC,QAAQjG,MAAM,GAAG;oBAC5C+G,qBAAqBhB,OAAO/F,MAAM;gBACpC;gBACAgH,cAAclB,OAAOX,WAAW;YAClC;QACF,CAAA;AACF;AAEA;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,MAAM8B,mBAAmB,sCAAqC;AACrE,OAAO,MAAMC,2BAA2B,wCAAuC;AAC/E,OAAO,MAAMC,uBAAuB,MAAM;AAE1C,OAAO,SAASC,wBAAwBtB,MAA4B;IAClE,OAAO;QACLK,QAAQ;QACRC,KAAKa;QACLX,SAAS;YAAE,gBAAgB;QAAoC;QAC/DC,MAAM,IAAIc,gBAAgB;YACxBC,YAAY;YACZC,WAAWzB,OAAOT,QAAQ;YAC1BmC,eAAe1B,OAAOR,YAAY;YAClCmC,eAAe3B,OAAOP,YAAY;QACpC,GAAGmC,QAAQ;IACb;AACF;AAEA,OAAO,SAASC,0BACd7B,MAA4B,EAC5BX,WAAmB,EACnByC,MAA2B,EAC3B7B,MAAyB;IAEzB,MAAM8B,cAAc;QAClBC,kBAAkB;YAAEC,aAAa;YAAcC,WAAWlC,OAAON,UAAU;QAAC;OACxEM,OAAOZ,eAAe,GACtB;QAAE+C,cAAc;YAAEF,aAAa;YAAcC,WAAWlC,OAAOZ,eAAe;QAAC;IAAE,IACjF,CAAC;QACLgD,sBAAsBpC,OAAOL,UAAU;;IAEzC,OAAOjC,MAAMuC,QAAQoB,sBAAsB/F,GAAG,CAAC,CAAC8E,QAAW,CAAA;YACzDC,QAAQ;YACRC,KAAK,GAAGc,yBAAyB,iBAAiB,EAAEU,QAAQ;YAC5DtB,SAAS;gBACP,gBAAgB;gBAChB6B,eAAe,CAAC,OAAO,EAAEhD,aAAa;YACxC;YACAoB,MAAM;gBACJ6B,cAAc;oBAACP;iBAAY;gBAC3BQ,iBAAiBnC,MAAM9E,GAAG,CAAC,CAAC8B,OAAU,CAAA;wBACpCoF,UAAU;4BAAEC,iBAAiB;gCAAC;oCAAEC,cAActF;gCAAK;6BAAE;wBAAC;oBACxD,CAAA;gBACAuF,UAAU;eACNb,WAAW,WACX;gBAAEc,gBAAgB;oBAAEC,mCAAmC;gBAAW;YAAE,IACpE,CAAC;QAET,CAAA;AACF;AAEA;;;;;;;;;CASC,GACD,OAAO,MAAMC,mBAAmB,SAAQ;AACxC,OAAO,MAAMC,uBAAuB,KAAK;AAEzC,OAAO,SAASC,0BACdhD,MAA8B,EAC9B8B,MAAwB,EACxB7B,MAAyB;IAEzB,OAAOvC,MAAMuC,QAAQ8C,sBAAsBzH,GAAG,CAAC,CAAC8E,QAAW,CAAA;YACzDC,QAAQ;YACRC,KAAK,CAAC,0CAA0C,EAAEC,mBAAmBP,OAAOJ,SAAS,EAAE,MAAM,CAAC;YAC9FY,SAAS;gBACP,gBAAgB;gBAChB6B,eAAe,CAAC,OAAO,EAAErC,OAAOX,WAAW,EAAE;gBAC7C,oBAAoByD;gBACpB,6BAA6B;gBAC7B,mBAAmB;YACrB;YACArC,MAAM;gBACJwC,UAAU7C,MAAM9E,GAAG,CAAC,CAAC8B,OAAU,CAAA;wBAC7B0E;wBACAoB,SAAS;4BAAC;gCAAEC,QAAQ;gCAAgBC,SAAShG;4BAAK;yBAAE;oBACtD,CAAA;YACF;QACF,CAAA;AACF;AAEA,iEAAiE,GACjE,OAAO,SAASiG,qBAAqBC,MAAe;IAClD,MAAML,WAAYK,0BAAD,AAACA,OAA0CL,QAAQ;IACpE,IAAI,CAACM,MAAMC,OAAO,CAACP,WAAW,OAAO;IACrC,OAAOA,SAASvH,MAAM,CAAC,CAAC+H;QACtB,MAAMC,SAASC,OAAQF,2BAAD,AAACA,QAAyCC,MAAM;QACtE,OAAO,CAAEA,CAAAA,UAAU,OAAOA,SAAS,GAAE;IACvC,GAAGxJ,MAAM;AACX"}
@@ -0,0 +1,101 @@
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
+ /**
18
+ * A person's consent as their CONTACT records it, read by address.
19
+ *
20
+ * Three surfaces ask this, and they must get one answer:
21
+ *
22
+ * - the list gate (`email/list-members-preview` and `-add`), deciding
23
+ * whether an address can be enrolled and on what basis;
24
+ * - `performCampaignSend`, for a list member a rule enrolled — the
25
+ * materializer writes no basis onto those rows, because matching a rule is
26
+ * not an opt-in, so the person's real basis is the one on their contact;
27
+ * - the list's Consent column, which shows what the send will act on.
28
+ *
29
+ * A copy per surface agrees only until the shape moves, which is how the
30
+ * Consent column once reported every member as having no basis while the
31
+ * sender mailed them.
32
+ */
33
+ import type { ConsentGroup } from '@aglyn/aglyn/app-utils/consent-groups';
34
+ import { type MarketingConsentRecord } from '@aglyn/aglyn/app-utils/marketing-consent';
35
+ /** Addresses per `in` query — Firestore's disjunction limit. */
36
+ export declare const CONTACT_LOOKUP_CHUNK = 30;
37
+ /**
38
+ * The stored consent facts for these addresses, off the org's contacts.
39
+ *
40
+ * Read UNSCOPED, deliberately, exactly as the Inbox route reads it:
41
+ * `scopedToHost` narrows an org collection to what one site may see, and a
42
+ * refusal filtered out by that narrowing is a refusal the caller would then
43
+ * step over — the failure mode is mailing or enrolling somebody who said no.
44
+ * A grant cannot leak across sites through this read either: the record is
45
+ * read for `group`, so another site's opt-in answers `otherGrant`, never
46
+ * `granted`. Callers serving a browser must still prove the requester is an
47
+ * org-wide member, which is the tier the rules grant the whole org's
48
+ * contacts to.
49
+ *
50
+ * ## A refusal wins over an opt-in when the CRM holds both
51
+ *
52
+ * Nothing guarantees one contact per address — the collection is keyed by
53
+ * resource id, not by email — so two records for one person can disagree.
54
+ * Taking whichever the query happened to answer first would make the outcome
55
+ * depend on document order. A recorded refusal is the answer whenever one
56
+ * exists, which is the same precedence `assignmentBasis` applies within a
57
+ * single record.
58
+ *
59
+ * ## A refusal outlives the contact it was written on (AGL-3338)
60
+ *
61
+ * Deleting a contact keeps every refusal it held in the org's retained store,
62
+ * so the address is read with what was retained laid over whatever record is
63
+ * left — `withRetainedRefusals` — and an address with no contact at all still
64
+ * reads `declined` when its person said no. A grant given after the refusal,
65
+ * on a contact the person signed up again through, is the person changing
66
+ * their mind and reads `granted`. The grants carried onto a membership come
67
+ * off the same merged record.
68
+ *
69
+ * A failed read falls to `declined` for the whole batch: a read that throws
70
+ * can neither say the person consented nor that they refused, and the
71
+ * direction that costs a retry is the one that does not reach somebody whose
72
+ * stored refusal we simply failed to see. The retained store is read the
73
+ * same way, and fails the same way.
74
+ *
75
+ * @param contacts the org's contacts collection
76
+ * (`orgDataCollectionForHost(hostId, 'contacts')`), passed
77
+ * as the pending lookup so a failure to resolve it reads as
78
+ * the same `declined` as a failed query.
79
+ * @param addresses normalized addresses (`normalizeContactEmail`).
80
+ */
81
+ export declare function readContactConsent(contacts: FirebaseFirestore.CollectionReference | Promise<FirebaseFirestore.CollectionReference>, group: ConsentGroup, addresses: readonly string[]): Promise<{
82
+ stored: Map<string, MarketingConsentRecord>;
83
+ grants: Map<string, Record<string, Record<string, unknown>>>;
84
+ }>;
85
+ /**
86
+ * Lays each rule-enrolled member's contact basis over the basis its row
87
+ * carries, where the row carries none.
88
+ *
89
+ * A row the dynamic-list materializer wrote (`via: 'rule'`) never holds a
90
+ * basis of its own — see the ⛔ in `dynamic-list-materialize.ts` — so for
91
+ * those rows the person's contact is the record. Only an `unrecorded` row
92
+ * is filled: a basis stored on the membership (an attestation, an opt-in an
93
+ * automation carried, a refusal) is a fact about that membership and stays
94
+ * the answer. Rows enrolled any other way are untouched.
95
+ *
96
+ * @param contacts as for {@link readContactConsent}.
97
+ * @param records the members' own records, keyed by normalized address;
98
+ * updated in place.
99
+ * @param ruleMembers the normalized addresses of the `via: 'rule'` rows.
100
+ */
101
+ export declare function withContactConsentForRuleMembers(contacts: FirebaseFirestore.CollectionReference | Promise<FirebaseFirestore.CollectionReference>, group: ConsentGroup, records: Map<string, MarketingConsentRecord>, ruleMembers: ReadonlySet<string>): Promise<Map<string, MarketingConsentRecord>>;