@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/tenant-data-admin",
3
- "version": "1.0.0-beta.250",
3
+ "version": "1.0.0-beta.252",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,12 +25,12 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.250",
29
- "@aglyn/shared-util-email": "1.0.0-beta.250",
30
- "@aglyn/shared-util-fbserver": "1.0.0-beta.250",
31
- "@aglyn/shared-util-first-touch": "1.0.0-beta.250",
32
- "@aglyn/shared-util-http": "1.0.0-beta.250",
33
- "@aglyn/shared-util-tools": "1.0.0-beta.250",
28
+ "@aglyn/aglyn": "1.0.0-beta.252",
29
+ "@aglyn/shared-util-email": "1.0.0-beta.252",
30
+ "@aglyn/shared-util-fbserver": "1.0.0-beta.252",
31
+ "@aglyn/shared-util-first-touch": "1.0.0-beta.252",
32
+ "@aglyn/shared-util-http": "1.0.0-beta.252",
33
+ "@aglyn/shared-util-tools": "1.0.0-beta.252",
34
34
  "@msgpack/msgpack": "^3.1.3",
35
35
  "@swc/helpers": "0.5.23",
36
36
  "sharp": "^0.35.5",
@@ -0,0 +1,134 @@
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 AdAudienceAccount, type AdAudienceConfig, type AdAudienceExclusion, type AdAudienceProvider } from './ad-audience-sync';
18
+ /**
19
+ * THE ACCOUNT-HOLDER AD AUDIENCE (AGL-3780) — reading, sending, recording.
20
+ *
21
+ * One run: every account from Auth (every pool), joined with the Firestore
22
+ * facts eligibility needs, reduced to one hash set per provider, diffed
23
+ * against what that provider was last sent, and — unless it is a dry run —
24
+ * sent. Each provider is independent: an unconfigured one is skipped with a
25
+ * line saying which variables it lacks, and a failing one fails alone.
26
+ *
27
+ * WHAT IS KEPT. `adAudienceSync/{provider}` holds the counts and times of the
28
+ * last run, and `adAudienceSync/{provider}/members/{n}` the hashes it last
29
+ * sent, in chunks. The hashes are kept because two of the three providers
30
+ * have no replace: an account that is erased leaves no address behind, so
31
+ * the only way to take it out of a Google list or a LinkedIn segment is the
32
+ * hash this sync sent it under. Admin-SDK only; no client rule opens either.
33
+ * No address is stored, and neither addresses nor hashes are ever logged.
34
+ */
35
+ export declare const AD_AUDIENCE_SYNC_COLLECTION = "adAudienceSync";
36
+ export declare const AD_AUDIENCE_MEMBERS_SUBCOLLECTION = "members";
37
+ /** The Auth page reader, as `listUsersAcrossPools` answers it. */
38
+ export type ListUsersPage = (pageSize: number, pageToken?: string) => Promise<{
39
+ users: Array<{
40
+ record: {
41
+ uid: string;
42
+ email?: string;
43
+ emailVerified: boolean;
44
+ disabled: boolean;
45
+ customClaims?: Record<string, unknown>;
46
+ };
47
+ }>;
48
+ nextPageToken: string | null | undefined;
49
+ }>;
50
+ export interface AdAudienceReadDeps {
51
+ firestore: FirebaseFirestore.Firestore;
52
+ listUserPage: ListUsersPage;
53
+ /** The account-ban list's answer for these addresses, lowercased. */
54
+ bannedEmails: (emails: readonly string[]) => Promise<Set<string>>;
55
+ now: number;
56
+ }
57
+ /** A number for each reason; never a person. */
58
+ export type ExclusionCounts = Record<AdAudienceExclusion, number>;
59
+ export interface AdAudienceSelection {
60
+ scanned: number;
61
+ eligible: AdAudienceAccount[];
62
+ excluded: ExclusionCounts;
63
+ /** How the eligible accounts' country was decided. */
64
+ countrySource: {
65
+ billing: number;
66
+ signup: number;
67
+ };
68
+ }
69
+ /** Every account, with the facts eligibility reads. */
70
+ export declare function readAdAudienceAccounts(deps: AdAudienceReadDeps): Promise<{
71
+ accounts: AdAudienceAccount[];
72
+ }>;
73
+ /** The eligible accounts and the counts of everyone left out. */
74
+ export declare function selectAdAudience(accounts: readonly AdAudienceAccount[], internalDomains: readonly string[]): AdAudienceSelection;
75
+ /** The set one provider should hold. Two addresses may share a hash. */
76
+ export declare function providerHashes(provider: AdAudienceProvider, eligible: readonly AdAudienceAccount[]): string[];
77
+ /** What one provider was last sent. */
78
+ export declare function readSentHashes(firestore: FirebaseFirestore.Firestore, provider: AdAudienceProvider): Promise<string[]>;
79
+ export type AudienceFetch = (url: string, init: {
80
+ method: string;
81
+ headers: Record<string, string>;
82
+ body: string;
83
+ }) => Promise<{
84
+ ok: boolean;
85
+ status: number;
86
+ json: () => Promise<unknown>;
87
+ }>;
88
+ /** Thrown with the provider's status only; a response body may echo a token. */
89
+ export declare class AudienceSendError extends Error {
90
+ readonly provider: AdAudienceProvider;
91
+ readonly status: number;
92
+ constructor(provider: AdAudienceProvider, status: number, detail: string);
93
+ }
94
+ export type ProviderOutcome = 'unconfigured' | 'dry-run' | 'flag-off' | 'unchanged' | 'empty' | 'held-shrink' | 'sent' | 'failed';
95
+ export interface ProviderReport {
96
+ outcome: ProviderOutcome;
97
+ eligible: number;
98
+ previous: number;
99
+ toAdd: number;
100
+ toRemove: number;
101
+ /** For `unconfigured`: the variables it lacks. */
102
+ missing?: string[];
103
+ /** For `failed`: the provider's status and message, never a payload. */
104
+ error?: string;
105
+ }
106
+ export interface AdAudienceSyncReport {
107
+ dryRun: boolean;
108
+ flagOn: boolean;
109
+ scanned: number;
110
+ eligible: number;
111
+ excluded: ExclusionCounts;
112
+ countrySource: {
113
+ billing: number;
114
+ signup: number;
115
+ };
116
+ providers: Record<AdAudienceProvider, ProviderReport>;
117
+ }
118
+ export interface AdAudienceRunOptions {
119
+ /** Report what would change and send nothing. */
120
+ dryRun: boolean;
121
+ /** `release_ad_audience_sync`. Off, a run that is not a dry run sends nothing. */
122
+ flagOn: boolean;
123
+ /** Send a set that shrank by more than half anyway. */
124
+ force?: boolean;
125
+ config: AdAudienceConfig;
126
+ internalDomains: readonly string[];
127
+ accounts: readonly AdAudienceAccount[];
128
+ firestore: FirebaseFirestore.Firestore;
129
+ fetch: AudienceFetch;
130
+ now: number;
131
+ /** One line per provider, for the function log. Counts only. */
132
+ log?: (line: string) => void;
133
+ }
134
+ export declare function runAdAudienceSync(options: AdAudienceRunOptions): Promise<AdAudienceSyncReport>;
@@ -0,0 +1,364 @@
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 { AD_AUDIENCE_OPT_OUT_FIELD, readAdAudienceOptOut } from "@aglyn/aglyn/app-utils/ad-audience";
18
+ import { ACCOUNT_ACQUISITION_FIELD } from "@aglyn/aglyn/app-utils/account-acquisition";
19
+ import { isAccountBanLockdownReason, LOCKDOWNS_COLLECTION } from "@aglyn/aglyn/app-utils/lockdown";
20
+ import { FieldPath } from "firebase-admin/firestore";
21
+ import { AD_AUDIENCE_EXCLUSIONS, AD_AUDIENCE_PROVIDERS, adAudienceExclusion, buildGoogleMemberRequests, buildGoogleTokenRequest, buildLinkedInUserRequests, buildMetaReplaceRequests, diffAudience, hashAudienceEmail, isSuspiciousShrink, linkedInRefusedCount, resolveAccountCountry } from "./ad-audience-sync.js";
22
+ /**
23
+ * THE ACCOUNT-HOLDER AD AUDIENCE (AGL-3780) — reading, sending, recording.
24
+ *
25
+ * One run: every account from Auth (every pool), joined with the Firestore
26
+ * facts eligibility needs, reduced to one hash set per provider, diffed
27
+ * against what that provider was last sent, and — unless it is a dry run —
28
+ * sent. Each provider is independent: an unconfigured one is skipped with a
29
+ * line saying which variables it lacks, and a failing one fails alone.
30
+ *
31
+ * WHAT IS KEPT. `adAudienceSync/{provider}` holds the counts and times of the
32
+ * last run, and `adAudienceSync/{provider}/members/{n}` the hashes it last
33
+ * sent, in chunks. The hashes are kept because two of the three providers
34
+ * have no replace: an account that is erased leaves no address behind, so
35
+ * the only way to take it out of a Google list or a LinkedIn segment is the
36
+ * hash this sync sent it under. Admin-SDK only; no client rule opens either.
37
+ * No address is stored, and neither addresses nor hashes are ever logged.
38
+ */ export const AD_AUDIENCE_SYNC_COLLECTION = 'adAudienceSync';
39
+ export const AD_AUDIENCE_MEMBERS_SUBCOLLECTION = 'members';
40
+ /** Hashes per member chunk: 5,000 × 64 bytes stays far under a document's 1 MiB. */ const MEMBER_CHUNK = 5000;
41
+ /** Auth pages walked per run (1,000 accounts each). */ const MAX_AUTH_PAGES = 500;
42
+ /** Every account, with the facts eligibility reads. */ export async function readAdAudienceAccounts(deps) {
43
+ const db = deps.firestore;
44
+ // Profiles: the opt-out and the sign-up country, in one query.
45
+ const profiles = await db.collection('users').select(AD_AUDIENCE_OPT_OUT_FIELD, `${ACCOUNT_ACQUISITION_FIELD}.geo.country`).get();
46
+ const optedOut = new Set();
47
+ const signupCountry = new Map();
48
+ for (const doc of profiles.docs){
49
+ var _doc_data;
50
+ var _data_ACCOUNT_ACQUISITION_FIELD;
51
+ const data = (_doc_data = doc.data()) != null ? _doc_data : {};
52
+ if (readAdAudienceOptOut(data)) optedOut.add(doc.id);
53
+ const geo = (_data_ACCOUNT_ACQUISITION_FIELD = data[ACCOUNT_ACQUISITION_FIELD]) == null ? void 0 : _data_ACCOUNT_ACQUISITION_FIELD.geo;
54
+ if (typeof (geo == null ? void 0 : geo.country) === 'string') signupCountry.set(doc.id, geo.country);
55
+ }
56
+ // Billing countries of the workspaces each account owns.
57
+ const orgs = await db.collection('orgs').select('ownerUid', 'contact.address.country').get();
58
+ const billing = new Map();
59
+ for (const doc of orgs.docs){
60
+ var _billing_get;
61
+ const owner = doc.get('ownerUid');
62
+ const country = doc.get('contact.address.country');
63
+ if (typeof owner !== 'string' || !owner || typeof country !== 'string' || !country) continue;
64
+ billing.set(owner, [
65
+ ...(_billing_get = billing.get(owner)) != null ? _billing_get : [],
66
+ country
67
+ ]);
68
+ }
69
+ // Account locks: `lockdowns/user--{uid}`, by document id range.
70
+ const locks = await db.collection(LOCKDOWNS_COLLECTION).where(FieldPath.documentId(), '>=', 'user--').where(FieldPath.documentId(), '<', 'user-.').get();
71
+ const locked = new Set();
72
+ for (const doc of locks.docs){
73
+ const uid = doc.id.slice('user--'.length);
74
+ const untilMs = doc.get('untilMs');
75
+ // An `abuse` lock is a permanent ban whatever its expiry says.
76
+ const live = isAccountBanLockdownReason(doc.get('reason')) || !(typeof untilMs === 'number' && untilMs <= deps.now);
77
+ if (uid && live) locked.add(uid);
78
+ }
79
+ const accounts = [];
80
+ let pageToken;
81
+ for(let page = 0; page < MAX_AUTH_PAGES; page += 1){
82
+ var _listed_nextPageToken;
83
+ const listed = await deps.listUserPage(1000, pageToken);
84
+ for (const { record } of listed.users){
85
+ var _record_email, _billing_get1, _signupCountry_get;
86
+ var _record_customClaims;
87
+ accounts.push({
88
+ uid: record.uid,
89
+ email: (_record_email = record.email) != null ? _record_email : null,
90
+ emailVerified: Boolean(record.emailVerified),
91
+ disabled: Boolean(record.disabled),
92
+ staff: Boolean((_record_customClaims = record.customClaims) == null ? void 0 : _record_customClaims['staff']),
93
+ optedOut: optedOut.has(record.uid),
94
+ locked: locked.has(record.uid),
95
+ banned: false,
96
+ billingCountries: (_billing_get1 = billing.get(record.uid)) != null ? _billing_get1 : [],
97
+ signupCountry: (_signupCountry_get = signupCountry.get(record.uid)) != null ? _signupCountry_get : null
98
+ });
99
+ }
100
+ pageToken = (_listed_nextPageToken = listed.nextPageToken) != null ? _listed_nextPageToken : undefined;
101
+ if (!pageToken) break;
102
+ }
103
+ if (pageToken) {
104
+ // A partial read would look like mass churn and remove real members.
105
+ throw new Error(`more than ${MAX_AUTH_PAGES * 1000} accounts; raise MAX_AUTH_PAGES`);
106
+ }
107
+ const banned = await deps.bannedEmails(accounts.map((account)=>account.email).filter((email)=>Boolean(email)));
108
+ for (const account of accounts){
109
+ if (account.email && banned.has(account.email.trim().toLowerCase())) account.banned = true;
110
+ }
111
+ return {
112
+ accounts
113
+ };
114
+ }
115
+ /** The eligible accounts and the counts of everyone left out. */ export function selectAdAudience(accounts, internalDomains) {
116
+ const excluded = Object.fromEntries(AD_AUDIENCE_EXCLUSIONS.map((reason)=>[
117
+ reason,
118
+ 0
119
+ ]));
120
+ const eligible = [];
121
+ const countrySource = {
122
+ billing: 0,
123
+ signup: 0
124
+ };
125
+ for (const account of accounts){
126
+ const reason = adAudienceExclusion(account, {
127
+ internalDomains
128
+ });
129
+ if (reason) {
130
+ excluded[reason] += 1;
131
+ continue;
132
+ }
133
+ eligible.push(account);
134
+ const { source } = resolveAccountCountry(account);
135
+ if (source) countrySource[source] += 1;
136
+ }
137
+ return {
138
+ scanned: accounts.length,
139
+ eligible,
140
+ excluded,
141
+ countrySource
142
+ };
143
+ }
144
+ /** The set one provider should hold. Two addresses may share a hash. */ export function providerHashes(provider, eligible) {
145
+ const hashes = new Set();
146
+ for (const account of eligible){
147
+ const hash = hashAudienceEmail(provider, account.email);
148
+ if (hash) hashes.add(hash);
149
+ }
150
+ return [
151
+ ...hashes
152
+ ].sort();
153
+ }
154
+ /*==========================================
155
+ * STATE
156
+ *=========================================*/ /** What one provider was last sent. */ export async function readSentHashes(firestore, provider) {
157
+ const chunks = await firestore.collection(AD_AUDIENCE_SYNC_COLLECTION).doc(provider).collection(AD_AUDIENCE_MEMBERS_SUBCOLLECTION).get();
158
+ const hashes = [];
159
+ for (const doc of chunks.docs){
160
+ const held = doc.get('hashes');
161
+ if (Array.isArray(held)) hashes.push(...held.filter((hash)=>typeof hash === 'string'));
162
+ }
163
+ return hashes;
164
+ }
165
+ /** Replace what one provider was last sent, and its counts. */ async function writeSentState(firestore, provider, hashes, state) {
166
+ const ref = firestore.collection(AD_AUDIENCE_SYNC_COLLECTION).doc(provider);
167
+ const members = ref.collection(AD_AUDIENCE_MEMBERS_SUBCOLLECTION);
168
+ const existing = await members.get();
169
+ const runs = [];
170
+ for(let index = 0; index < hashes.length; index += MEMBER_CHUNK){
171
+ runs.push(hashes.slice(index, index + MEMBER_CHUNK));
172
+ }
173
+ for (const [index, run] of runs.entries()){
174
+ await members.doc(String(index).padStart(4, '0')).set({
175
+ hashes: run
176
+ });
177
+ }
178
+ const keep = new Set(runs.map((_, index)=>String(index).padStart(4, '0')));
179
+ for (const doc of existing.docs){
180
+ if (!keep.has(doc.id)) await doc.ref.delete();
181
+ }
182
+ await ref.set(_extends({
183
+ provider
184
+ }, state, {
185
+ members: hashes.length,
186
+ chunks: runs.length
187
+ }), {
188
+ merge: true
189
+ });
190
+ }
191
+ /** The counts of a run on the provider's state document, members untouched. */ async function writeRunState(firestore, provider, state) {
192
+ await firestore.collection(AD_AUDIENCE_SYNC_COLLECTION).doc(provider).set(_extends({
193
+ provider
194
+ }, state), {
195
+ merge: true
196
+ });
197
+ }
198
+ /** Thrown with the provider's status only; a response body may echo a token. */ export class AudienceSendError extends Error {
199
+ constructor(provider, status, detail){
200
+ super(`${provider} answered ${status}: ${detail}`), this.provider = provider, this.status = status;
201
+ }
202
+ }
203
+ /** The provider's error message, when it gave one, clipped. Never the request. */ function errorDetail(answer) {
204
+ var _ref;
205
+ const error = answer != null ? answer : null;
206
+ const message = (_ref = typeof (error == null ? void 0 : error.error) === 'object' && (error == null ? void 0 : error.error) ? error.error.message : error == null ? void 0 : error.error) != null ? _ref : error == null ? void 0 : error.message;
207
+ return typeof message === 'string' ? message.slice(0, 300) : 'no detail';
208
+ }
209
+ async function send(fetcher, provider, request) {
210
+ const response = await fetcher(request.url, {
211
+ method: request.method,
212
+ headers: request.headers,
213
+ body: typeof request.body === 'string' ? request.body : JSON.stringify(request.body)
214
+ });
215
+ const answer = await response.json().catch(()=>null);
216
+ if (!response.ok) throw new AudienceSendError(provider, response.status, errorDetail(answer));
217
+ return answer;
218
+ }
219
+ async function sendMeta(fetcher, config, hashes, now) {
220
+ // A session id unique within the ad account: the run's clock is.
221
+ for (const request of buildMetaReplaceRequests(config, hashes, now)){
222
+ await send(fetcher, 'meta', request);
223
+ }
224
+ }
225
+ async function sendGoogle(fetcher, config, diff) {
226
+ const token = await send(fetcher, 'google', buildGoogleTokenRequest(config));
227
+ if (typeof (token == null ? void 0 : token.access_token) !== 'string') {
228
+ throw new AudienceSendError('google', 200, 'the token endpoint returned no access token');
229
+ }
230
+ for (const request of buildGoogleMemberRequests(config, token.access_token, 'remove', diff.toRemove)){
231
+ await send(fetcher, 'google', request);
232
+ }
233
+ for (const request of buildGoogleMemberRequests(config, token.access_token, 'ingest', diff.toAdd)){
234
+ await send(fetcher, 'google', request);
235
+ }
236
+ }
237
+ async function sendLinkedIn(fetcher, config, diff) {
238
+ const requests = [
239
+ ...buildLinkedInUserRequests(config, 'REMOVE', diff.toRemove),
240
+ ...buildLinkedInUserRequests(config, 'ADD', diff.toAdd)
241
+ ];
242
+ for (const request of requests){
243
+ const refused = linkedInRefusedCount(await send(fetcher, 'linkedin', request));
244
+ if (refused) {
245
+ throw new AudienceSendError('linkedin', 400, `${refused} of the batch were refused`);
246
+ }
247
+ }
248
+ }
249
+ export async function runAdAudienceSync(options) {
250
+ var _options_log;
251
+ const log = (_options_log = options.log) != null ? _options_log : (line)=>console.info(line);
252
+ const selection = selectAdAudience(options.accounts, options.internalDomains);
253
+ const providers = {};
254
+ for (const provider of AD_AUDIENCE_PROVIDERS){
255
+ const hashes = providerHashes(provider, selection.eligible);
256
+ const config = options.config[provider];
257
+ const base = {
258
+ eligible: hashes.length,
259
+ previous: 0,
260
+ toAdd: 0,
261
+ toRemove: 0
262
+ };
263
+ if (!config) {
264
+ providers[provider] = _extends({}, base, {
265
+ outcome: 'unconfigured',
266
+ missing: options.config.missing[provider]
267
+ });
268
+ log(`[ad-audience-sync] ${provider} skipped — set ${options.config.missing[provider].join(', ')}`);
269
+ continue;
270
+ }
271
+ const previous = await readSentHashes(options.firestore, provider);
272
+ const diff = diffAudience(previous, hashes);
273
+ const counts = _extends({}, base, {
274
+ previous: previous.length,
275
+ toAdd: diff.toAdd.length,
276
+ toRemove: diff.toRemove.length
277
+ });
278
+ const outcome = (value, extra = {})=>{
279
+ providers[provider] = _extends({}, counts, {
280
+ outcome: value
281
+ }, extra);
282
+ log(`[ad-audience-sync] ${provider} ${value}: eligible ${counts.eligible}, ` + `previous ${counts.previous}, add ${counts.toAdd}, remove ${counts.toRemove}` + (extra.error ? ` — ${extra.error}` : ''));
283
+ };
284
+ if (options.dryRun) {
285
+ outcome('dry-run');
286
+ continue;
287
+ }
288
+ if (!options.flagOn) {
289
+ outcome('flag-off');
290
+ continue;
291
+ }
292
+ const stamp = {
293
+ lastRunAtMs: options.now,
294
+ eligible: counts.eligible
295
+ };
296
+ if (!diff.toAdd.length && !diff.toRemove.length) {
297
+ await writeRunState(options.firestore, provider, _extends({}, stamp, {
298
+ lastOutcome: 'unchanged'
299
+ }));
300
+ outcome('unchanged');
301
+ continue;
302
+ }
303
+ // Meta replaces with what it is sent, and an empty replace is no replace.
304
+ if (!hashes.length) {
305
+ await writeRunState(options.firestore, provider, _extends({}, stamp, {
306
+ lastOutcome: 'empty'
307
+ }));
308
+ outcome('empty', {
309
+ error: 'no eligible account; the audience was left as it is'
310
+ });
311
+ continue;
312
+ }
313
+ if (!options.force && isSuspiciousShrink(previous.length, hashes.length)) {
314
+ await writeRunState(options.firestore, provider, _extends({}, stamp, {
315
+ lastOutcome: 'held-shrink'
316
+ }));
317
+ outcome('held-shrink', {
318
+ error: `the audience would shrink from ${previous.length} to ${hashes.length}; held until a run with force`
319
+ });
320
+ continue;
321
+ }
322
+ try {
323
+ if (provider === 'meta') {
324
+ await sendMeta(options.fetch, config, hashes, options.now);
325
+ } else if (provider === 'google') {
326
+ await sendGoogle(options.fetch, config, diff);
327
+ } else {
328
+ await sendLinkedIn(options.fetch, config, diff);
329
+ }
330
+ } catch (error) {
331
+ // The stored set stays as it was: the next run retries the whole diff,
332
+ // and every provider takes a repeated add or remove without harm.
333
+ const message = error instanceof AudienceSendError ? error.message : `${provider} send failed`;
334
+ if (!(error instanceof AudienceSendError)) console.error(`[ad-audience-sync] ${provider}`, error);
335
+ await writeRunState(options.firestore, provider, _extends({}, stamp, {
336
+ lastOutcome: 'failed',
337
+ lastError: message
338
+ }));
339
+ outcome('failed', {
340
+ error: message
341
+ });
342
+ continue;
343
+ }
344
+ await writeSentState(options.firestore, provider, hashes, _extends({}, stamp, {
345
+ lastOutcome: 'sent',
346
+ lastSentAtMs: options.now,
347
+ lastAdded: counts.toAdd,
348
+ lastRemoved: counts.toRemove,
349
+ lastError: null
350
+ }));
351
+ outcome('sent');
352
+ }
353
+ return {
354
+ dryRun: options.dryRun,
355
+ flagOn: options.flagOn,
356
+ scanned: selection.scanned,
357
+ eligible: selection.eligible.length,
358
+ excluded: selection.excluded,
359
+ countrySource: selection.countrySource,
360
+ providers
361
+ };
362
+ }
363
+
364
+ //# sourceMappingURL=ad-audience-sync-run.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/ad-audience-sync-run.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 { AD_AUDIENCE_OPT_OUT_FIELD, readAdAudienceOptOut } from '@aglyn/aglyn/app-utils/ad-audience'\nimport { ACCOUNT_ACQUISITION_FIELD } from '@aglyn/aglyn/app-utils/account-acquisition'\nimport {\n isAccountBanLockdownReason,\n LOCKDOWNS_COLLECTION,\n} from '@aglyn/aglyn/app-utils/lockdown'\nimport { FieldPath } from 'firebase-admin/firestore'\nimport {\n AD_AUDIENCE_EXCLUSIONS,\n AD_AUDIENCE_PROVIDERS,\n adAudienceExclusion,\n buildGoogleMemberRequests,\n buildGoogleTokenRequest,\n buildLinkedInUserRequests,\n buildMetaReplaceRequests,\n diffAudience,\n hashAudienceEmail,\n isSuspiciousShrink,\n linkedInRefusedCount,\n resolveAccountCountry,\n type AdAudienceAccount,\n type AdAudienceConfig,\n type AdAudienceExclusion,\n type AdAudienceProvider,\n type AudienceRequest,\n} from './ad-audience-sync'\n\n/**\n * THE ACCOUNT-HOLDER AD AUDIENCE (AGL-3780) — reading, sending, recording.\n *\n * One run: every account from Auth (every pool), joined with the Firestore\n * facts eligibility needs, reduced to one hash set per provider, diffed\n * against what that provider was last sent, and — unless it is a dry run —\n * sent. Each provider is independent: an unconfigured one is skipped with a\n * line saying which variables it lacks, and a failing one fails alone.\n *\n * WHAT IS KEPT. `adAudienceSync/{provider}` holds the counts and times of the\n * last run, and `adAudienceSync/{provider}/members/{n}` the hashes it last\n * sent, in chunks. The hashes are kept because two of the three providers\n * have no replace: an account that is erased leaves no address behind, so\n * the only way to take it out of a Google list or a LinkedIn segment is the\n * hash this sync sent it under. Admin-SDK only; no client rule opens either.\n * No address is stored, and neither addresses nor hashes are ever logged.\n */\n\nexport const AD_AUDIENCE_SYNC_COLLECTION = 'adAudienceSync'\nexport const AD_AUDIENCE_MEMBERS_SUBCOLLECTION = 'members'\n/** Hashes per member chunk: 5,000 × 64 bytes stays far under a document's 1 MiB. */\nconst MEMBER_CHUNK = 5_000\n/** Auth pages walked per run (1,000 accounts each). */\nconst MAX_AUTH_PAGES = 500\n\n/** The Auth page reader, as `listUsersAcrossPools` answers it. */\nexport type ListUsersPage = (\n pageSize: number,\n pageToken?: string,\n) => Promise<{\n users: Array<{\n record: {\n uid: string\n email?: string\n emailVerified: boolean\n disabled: boolean\n customClaims?: Record<string, unknown>\n }\n }>\n nextPageToken: string | null | undefined\n}>\n\nexport interface AdAudienceReadDeps {\n firestore: FirebaseFirestore.Firestore\n listUserPage: ListUsersPage\n /** The account-ban list's answer for these addresses, lowercased. */\n bannedEmails: (emails: readonly string[]) => Promise<Set<string>>\n now: number\n}\n\n/** A number for each reason; never a person. */\nexport type ExclusionCounts = Record<AdAudienceExclusion, number>\n\nexport interface AdAudienceSelection {\n scanned: number\n eligible: AdAudienceAccount[]\n excluded: ExclusionCounts\n /** How the eligible accounts' country was decided. */\n countrySource: { billing: number; signup: number }\n}\n\n/** Every account, with the facts eligibility reads. */\nexport async function readAdAudienceAccounts(\n deps: AdAudienceReadDeps,\n): Promise<{ accounts: AdAudienceAccount[] }> {\n const db = deps.firestore\n\n // Profiles: the opt-out and the sign-up country, in one query.\n const profiles = await db\n .collection('users')\n .select(AD_AUDIENCE_OPT_OUT_FIELD, `${ACCOUNT_ACQUISITION_FIELD}.geo.country`)\n .get()\n const optedOut = new Set<string>()\n const signupCountry = new Map<string, string>()\n for (const doc of profiles.docs) {\n const data = (doc.data() ?? {}) as Record<string, unknown>\n if (readAdAudienceOptOut(data)) optedOut.add(doc.id)\n const geo = (data[ACCOUNT_ACQUISITION_FIELD] as { geo?: { country?: unknown } } | undefined)?.geo\n if (typeof geo?.country === 'string') signupCountry.set(doc.id, geo.country)\n }\n\n // Billing countries of the workspaces each account owns.\n const orgs = await db.collection('orgs').select('ownerUid', 'contact.address.country').get()\n const billing = new Map<string, string[]>()\n for (const doc of orgs.docs) {\n const owner = doc.get('ownerUid')\n const country = doc.get('contact.address.country')\n if (typeof owner !== 'string' || !owner || typeof country !== 'string' || !country) continue\n billing.set(owner, [...(billing.get(owner) ?? []), country])\n }\n\n // Account locks: `lockdowns/user--{uid}`, by document id range.\n const locks = await db\n .collection(LOCKDOWNS_COLLECTION)\n .where(FieldPath.documentId(), '>=', 'user--')\n .where(FieldPath.documentId(), '<', 'user-.')\n .get()\n const locked = new Set<string>()\n for (const doc of locks.docs) {\n const uid = doc.id.slice('user--'.length)\n const untilMs = doc.get('untilMs')\n // An `abuse` lock is a permanent ban whatever its expiry says.\n const live =\n isAccountBanLockdownReason(doc.get('reason')) ||\n !(typeof untilMs === 'number' && untilMs <= deps.now)\n if (uid && live) locked.add(uid)\n }\n\n const accounts: AdAudienceAccount[] = []\n let pageToken: string | undefined\n for (let page = 0; page < MAX_AUTH_PAGES; page += 1) {\n const listed = await deps.listUserPage(1000, pageToken)\n for (const { record } of listed.users) {\n accounts.push({\n uid: record.uid,\n email: record.email ?? null,\n emailVerified: Boolean(record.emailVerified),\n disabled: Boolean(record.disabled),\n staff: Boolean(record.customClaims?.['staff']),\n optedOut: optedOut.has(record.uid),\n locked: locked.has(record.uid),\n banned: false,\n billingCountries: billing.get(record.uid) ?? [],\n signupCountry: signupCountry.get(record.uid) ?? null,\n })\n }\n pageToken = listed.nextPageToken ?? undefined\n if (!pageToken) break\n }\n if (pageToken) {\n // A partial read would look like mass churn and remove real members.\n throw new Error(`more than ${MAX_AUTH_PAGES * 1000} accounts; raise MAX_AUTH_PAGES`)\n }\n\n const banned = await deps.bannedEmails(\n accounts.map((account) => account.email).filter((email): email is string => Boolean(email)),\n )\n for (const account of accounts) {\n if (account.email && banned.has(account.email.trim().toLowerCase())) account.banned = true\n }\n return { accounts }\n}\n\n/** The eligible accounts and the counts of everyone left out. */\nexport function selectAdAudience(\n accounts: readonly AdAudienceAccount[],\n internalDomains: readonly string[],\n): AdAudienceSelection {\n const excluded = Object.fromEntries(\n AD_AUDIENCE_EXCLUSIONS.map((reason) => [reason, 0]),\n ) as ExclusionCounts\n const eligible: AdAudienceAccount[] = []\n const countrySource = { billing: 0, signup: 0 }\n for (const account of accounts) {\n const reason = adAudienceExclusion(account, { internalDomains })\n if (reason) {\n excluded[reason] += 1\n continue\n }\n eligible.push(account)\n const { source } = resolveAccountCountry(account)\n if (source) countrySource[source] += 1\n }\n return { scanned: accounts.length, eligible, excluded, countrySource }\n}\n\n/** The set one provider should hold. Two addresses may share a hash. */\nexport function providerHashes(\n provider: AdAudienceProvider,\n eligible: readonly AdAudienceAccount[],\n): string[] {\n const hashes = new Set<string>()\n for (const account of eligible) {\n const hash = hashAudienceEmail(provider, account.email)\n if (hash) hashes.add(hash)\n }\n return [...hashes].sort()\n}\n\n/*==========================================\n * STATE\n *=========================================*/\n\n/** What one provider was last sent. */\nexport async function readSentHashes(\n firestore: FirebaseFirestore.Firestore,\n provider: AdAudienceProvider,\n): Promise<string[]> {\n const chunks = await firestore\n .collection(AD_AUDIENCE_SYNC_COLLECTION)\n .doc(provider)\n .collection(AD_AUDIENCE_MEMBERS_SUBCOLLECTION)\n .get()\n const hashes: string[] = []\n for (const doc of chunks.docs) {\n const held = doc.get('hashes')\n if (Array.isArray(held)) hashes.push(...held.filter((hash): hash is string => typeof hash === 'string'))\n }\n return hashes\n}\n\n/** Replace what one provider was last sent, and its counts. */\nasync function writeSentState(\n firestore: FirebaseFirestore.Firestore,\n provider: AdAudienceProvider,\n hashes: readonly string[],\n state: Record<string, unknown>,\n): Promise<void> {\n const ref = firestore.collection(AD_AUDIENCE_SYNC_COLLECTION).doc(provider)\n const members = ref.collection(AD_AUDIENCE_MEMBERS_SUBCOLLECTION)\n const existing = await members.get()\n const runs: string[][] = []\n for (let index = 0; index < hashes.length; index += MEMBER_CHUNK) {\n runs.push(hashes.slice(index, index + MEMBER_CHUNK))\n }\n for (const [index, run] of runs.entries()) {\n await members.doc(String(index).padStart(4, '0')).set({ hashes: run })\n }\n const keep = new Set(runs.map((_, index) => String(index).padStart(4, '0')))\n for (const doc of existing.docs) {\n if (!keep.has(doc.id)) await doc.ref.delete()\n }\n await ref.set({ provider, ...state, members: hashes.length, chunks: runs.length }, { merge: true })\n}\n\n/** The counts of a run on the provider's state document, members untouched. */\nasync function writeRunState(\n firestore: FirebaseFirestore.Firestore,\n provider: AdAudienceProvider,\n state: Record<string, unknown>,\n): Promise<void> {\n await firestore\n .collection(AD_AUDIENCE_SYNC_COLLECTION)\n .doc(provider)\n .set({ provider, ...state }, { merge: true })\n}\n\n/*==========================================\n * SENDING\n *=========================================*/\n\nexport type AudienceFetch = (\n url: string,\n init: { method: string; headers: Record<string, string>; body: string },\n) => Promise<{ ok: boolean; status: number; json: () => Promise<unknown> }>\n\n/** Thrown with the provider's status only; a response body may echo a token. */\nexport class AudienceSendError extends Error {\n constructor(\n readonly provider: AdAudienceProvider,\n readonly status: number,\n detail: string,\n ) {\n super(`${provider} answered ${status}: ${detail}`)\n }\n}\n\n/** The provider's error message, when it gave one, clipped. Never the request. */\nfunction errorDetail(answer: unknown): string {\n const error = (answer as { error?: unknown; message?: unknown } | null) ?? null\n const message =\n (typeof error?.error === 'object' && error?.error\n ? (error.error as { message?: unknown }).message\n : error?.error) ?? error?.message\n return typeof message === 'string' ? message.slice(0, 300) : 'no detail'\n}\n\nasync function send(\n fetcher: AudienceFetch,\n provider: AdAudienceProvider,\n request: AudienceRequest,\n): Promise<unknown> {\n const response = await fetcher(request.url, {\n method: request.method,\n headers: request.headers,\n body: typeof request.body === 'string' ? request.body : JSON.stringify(request.body),\n })\n const answer = await response.json().catch(() => null)\n if (!response.ok) throw new AudienceSendError(provider, response.status, errorDetail(answer))\n return answer\n}\n\nasync function sendMeta(\n fetcher: AudienceFetch,\n config: NonNullable<AdAudienceConfig['meta']>,\n hashes: readonly string[],\n now: number,\n): Promise<void> {\n // A session id unique within the ad account: the run's clock is.\n for (const request of buildMetaReplaceRequests(config, hashes, now)) {\n await send(fetcher, 'meta', request)\n }\n}\n\nasync function sendGoogle(\n fetcher: AudienceFetch,\n config: NonNullable<AdAudienceConfig['google']>,\n diff: { toAdd: string[]; toRemove: string[] },\n): Promise<void> {\n const token = (await send(fetcher, 'google', buildGoogleTokenRequest(config))) as {\n access_token?: unknown\n } | null\n if (typeof token?.access_token !== 'string') {\n throw new AudienceSendError('google', 200, 'the token endpoint returned no access token')\n }\n for (const request of buildGoogleMemberRequests(config, token.access_token, 'remove', diff.toRemove)) {\n await send(fetcher, 'google', request)\n }\n for (const request of buildGoogleMemberRequests(config, token.access_token, 'ingest', diff.toAdd)) {\n await send(fetcher, 'google', request)\n }\n}\n\nasync function sendLinkedIn(\n fetcher: AudienceFetch,\n config: NonNullable<AdAudienceConfig['linkedin']>,\n diff: { toAdd: string[]; toRemove: string[] },\n): Promise<void> {\n const requests = [\n ...buildLinkedInUserRequests(config, 'REMOVE', diff.toRemove),\n ...buildLinkedInUserRequests(config, 'ADD', diff.toAdd),\n ]\n for (const request of requests) {\n const refused = linkedInRefusedCount(await send(fetcher, 'linkedin', request))\n if (refused) {\n throw new AudienceSendError('linkedin', 400, `${refused} of the batch were refused`)\n }\n }\n}\n\n/*==========================================\n * ONE RUN\n *=========================================*/\n\nexport type ProviderOutcome =\n | 'unconfigured'\n | 'dry-run'\n | 'flag-off'\n | 'unchanged'\n | 'empty'\n | 'held-shrink'\n | 'sent'\n | 'failed'\n\nexport interface ProviderReport {\n outcome: ProviderOutcome\n eligible: number\n previous: number\n toAdd: number\n toRemove: number\n /** For `unconfigured`: the variables it lacks. */\n missing?: string[]\n /** For `failed`: the provider's status and message, never a payload. */\n error?: string\n}\n\nexport interface AdAudienceSyncReport {\n dryRun: boolean\n flagOn: boolean\n scanned: number\n eligible: number\n excluded: ExclusionCounts\n countrySource: { billing: number; signup: number }\n providers: Record<AdAudienceProvider, ProviderReport>\n}\n\nexport interface AdAudienceRunOptions {\n /** Report what would change and send nothing. */\n dryRun: boolean\n /** `release_ad_audience_sync`. Off, a run that is not a dry run sends nothing. */\n flagOn: boolean\n /** Send a set that shrank by more than half anyway. */\n force?: boolean\n config: AdAudienceConfig\n internalDomains: readonly string[]\n accounts: readonly AdAudienceAccount[]\n firestore: FirebaseFirestore.Firestore\n fetch: AudienceFetch\n now: number\n /** One line per provider, for the function log. Counts only. */\n log?: (line: string) => void\n}\n\nexport async function runAdAudienceSync(options: AdAudienceRunOptions): Promise<AdAudienceSyncReport> {\n const log = options.log ?? ((line: string) => console.info(line))\n const selection = selectAdAudience(options.accounts, options.internalDomains)\n const providers = {} as Record<AdAudienceProvider, ProviderReport>\n\n for (const provider of AD_AUDIENCE_PROVIDERS) {\n const hashes = providerHashes(provider, selection.eligible)\n const config = options.config[provider]\n const base = { eligible: hashes.length, previous: 0, toAdd: 0, toRemove: 0 }\n if (!config) {\n providers[provider] = { ...base, outcome: 'unconfigured', missing: options.config.missing[provider] }\n log(`[ad-audience-sync] ${provider} skipped — set ${options.config.missing[provider].join(', ')}`)\n continue\n }\n const previous = await readSentHashes(options.firestore, provider)\n const diff = diffAudience(previous, hashes)\n const counts = {\n ...base,\n previous: previous.length,\n toAdd: diff.toAdd.length,\n toRemove: diff.toRemove.length,\n }\n const outcome = (value: ProviderOutcome, extra: Partial<ProviderReport> = {}) => {\n providers[provider] = { ...counts, outcome: value, ...extra }\n log(\n `[ad-audience-sync] ${provider} ${value}: eligible ${counts.eligible}, ` +\n `previous ${counts.previous}, add ${counts.toAdd}, remove ${counts.toRemove}` +\n (extra.error ? ` — ${extra.error}` : ''),\n )\n }\n if (options.dryRun) {\n outcome('dry-run')\n continue\n }\n if (!options.flagOn) {\n outcome('flag-off')\n continue\n }\n const stamp = { lastRunAtMs: options.now, eligible: counts.eligible }\n if (!diff.toAdd.length && !diff.toRemove.length) {\n await writeRunState(options.firestore, provider, { ...stamp, lastOutcome: 'unchanged' })\n outcome('unchanged')\n continue\n }\n // Meta replaces with what it is sent, and an empty replace is no replace.\n if (!hashes.length) {\n await writeRunState(options.firestore, provider, { ...stamp, lastOutcome: 'empty' })\n outcome('empty', { error: 'no eligible account; the audience was left as it is' })\n continue\n }\n if (!options.force && isSuspiciousShrink(previous.length, hashes.length)) {\n await writeRunState(options.firestore, provider, { ...stamp, lastOutcome: 'held-shrink' })\n outcome('held-shrink', {\n error: `the audience would shrink from ${previous.length} to ${hashes.length}; held until a run with force`,\n })\n continue\n }\n try {\n if (provider === 'meta') {\n await sendMeta(options.fetch, config as NonNullable<AdAudienceConfig['meta']>, hashes, options.now)\n } else if (provider === 'google') {\n await sendGoogle(options.fetch, config as NonNullable<AdAudienceConfig['google']>, diff)\n } else {\n await sendLinkedIn(options.fetch, config as NonNullable<AdAudienceConfig['linkedin']>, diff)\n }\n } catch (error) {\n // The stored set stays as it was: the next run retries the whole diff,\n // and every provider takes a repeated add or remove without harm.\n const message = error instanceof AudienceSendError ? error.message : `${provider} send failed`\n if (!(error instanceof AudienceSendError)) console.error(`[ad-audience-sync] ${provider}`, error)\n await writeRunState(options.firestore, provider, {\n ...stamp,\n lastOutcome: 'failed',\n lastError: message,\n })\n outcome('failed', { error: message })\n continue\n }\n await writeSentState(options.firestore, provider, hashes, {\n ...stamp,\n lastOutcome: 'sent',\n lastSentAtMs: options.now,\n lastAdded: counts.toAdd,\n lastRemoved: counts.toRemove,\n lastError: null,\n })\n outcome('sent')\n }\n\n return {\n dryRun: options.dryRun,\n flagOn: options.flagOn,\n scanned: selection.scanned,\n eligible: selection.eligible.length,\n excluded: selection.excluded,\n countrySource: selection.countrySource,\n providers,\n }\n}\n"],"names":["AD_AUDIENCE_OPT_OUT_FIELD","readAdAudienceOptOut","ACCOUNT_ACQUISITION_FIELD","isAccountBanLockdownReason","LOCKDOWNS_COLLECTION","FieldPath","AD_AUDIENCE_EXCLUSIONS","AD_AUDIENCE_PROVIDERS","adAudienceExclusion","buildGoogleMemberRequests","buildGoogleTokenRequest","buildLinkedInUserRequests","buildMetaReplaceRequests","diffAudience","hashAudienceEmail","isSuspiciousShrink","linkedInRefusedCount","resolveAccountCountry","AD_AUDIENCE_SYNC_COLLECTION","AD_AUDIENCE_MEMBERS_SUBCOLLECTION","MEMBER_CHUNK","MAX_AUTH_PAGES","readAdAudienceAccounts","deps","db","firestore","profiles","collection","select","get","optedOut","Set","signupCountry","Map","doc","docs","data","add","id","geo","country","set","orgs","billing","owner","locks","where","documentId","locked","uid","slice","length","untilMs","live","now","accounts","pageToken","page","listed","listUserPage","record","users","push","email","emailVerified","Boolean","disabled","staff","customClaims","has","banned","billingCountries","nextPageToken","undefined","Error","bannedEmails","map","account","filter","trim","toLowerCase","selectAdAudience","internalDomains","excluded","Object","fromEntries","reason","eligible","countrySource","signup","source","scanned","providerHashes","provider","hashes","hash","sort","readSentHashes","chunks","held","Array","isArray","writeSentState","state","ref","members","existing","runs","index","run","entries","String","padStart","keep","_","delete","merge","writeRunState","AudienceSendError","status","detail","errorDetail","answer","error","message","send","fetcher","request","response","url","method","headers","body","JSON","stringify","json","catch","ok","sendMeta","config","sendGoogle","diff","token","access_token","toRemove","toAdd","sendLinkedIn","requests","refused","runAdAudienceSync","options","log","line","console","info","selection","providers","base","previous","outcome","missing","join","counts","value","extra","dryRun","flagOn","stamp","lastRunAtMs","lastOutcome","force","fetch","lastError","lastSentAtMs","lastAdded","lastRemoved"],"mappings":"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"}