@aglyn/tenant-data-admin 1.0.0-beta.249 → 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,250 @@
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
+ * THE ACCOUNT-HOLDER AD AUDIENCE (AGL-3780) — the pure half.
19
+ *
20
+ * Once a night the operator's own account holders are kept in step with a
21
+ * customer-list audience on each of the operator's OWN ad accounts — Meta
22
+ * Custom Audiences, Google Customer Match and LinkedIn DMP segments — so
23
+ * signed-up people can be retargeted and left out of prospecting. This is
24
+ * the platform's marketing, not a merchant feature: a merchant's own pixels
25
+ * and conversion APIs are the ad-conversions plugin, which this shares
26
+ * nothing with.
27
+ *
28
+ * Everything here is pure — normalization, hashing, who is eligible, the
29
+ * add/remove diff and each provider's request — so the spec walks every rule
30
+ * without a network. `ad-audience-sync-run.ts` reads the accounts, sends and
31
+ * records.
32
+ *
33
+ * No address ever leaves in the clear: each provider receives the SHA-256 hex
34
+ * digest of the address normalized the way that provider documents, and
35
+ * nothing here logs either one.
36
+ */
37
+ export declare const AD_AUDIENCE_PROVIDERS: readonly ["meta", "google", "linkedin"];
38
+ export type AdAudienceProvider = (typeof AD_AUDIENCE_PROVIDERS)[number];
39
+ export declare const AD_AUDIENCE_PROVIDER_LABELS: Record<AdAudienceProvider, string>;
40
+ /**
41
+ * The address the way one provider says to normalize it before hashing, or
42
+ * null when it is not an address at all.
43
+ *
44
+ * - **Meta** (Custom Audiences, `EMAIL`): trim leading and trailing white
45
+ * space, lowercase.
46
+ * - **LinkedIn** (DMP segment users, `SHA256_EMAIL`): lowercase, remove any
47
+ * white space.
48
+ * - **Google** (Data Manager API, Customer Match): lowercase, remove leading,
49
+ * trailing and inner white space, and for `gmail.com` and `googlemail.com`
50
+ * only, remove the dots from the local part and drop a `+` and everything
51
+ * after it. Other domains keep both.
52
+ *
53
+ * Inner white space is never part of a deliverable address, so removing it
54
+ * for Meta as well would change nothing real; it is left exactly as Meta
55
+ * documents it.
56
+ */
57
+ export declare function normalizeAudienceEmail(provider: AdAudienceProvider, raw: unknown): string | null;
58
+ /** Lowercase SHA-256 hex of a UTF-8 string: what all three providers take. */
59
+ export declare function sha256Hex(value: string): string;
60
+ /** The hash one provider receives for an address, or null for no address. */
61
+ export declare function hashAudienceEmail(provider: AdAudienceProvider, raw: unknown): string | null;
62
+ /** What the sync knows about one account, read from Auth and Firestore. */
63
+ export interface AdAudienceAccount {
64
+ uid: string;
65
+ email: string | null;
66
+ emailVerified: boolean;
67
+ disabled: boolean;
68
+ /** The `staff` custom claim. */
69
+ staff: boolean;
70
+ /** `users/{uid}.adAudienceOptOut` is set. */
71
+ optedOut: boolean;
72
+ /** An account lockdown is in force (`lockdowns/user--{uid}`). */
73
+ locked: boolean;
74
+ /** The address is on the account-ban suppression list (an `abuse` ban). */
75
+ banned: boolean;
76
+ /**
77
+ * The billing-address countries (`orgs/{id}.contact.address.country`) of
78
+ * the workspaces the account OWNS.
79
+ */
80
+ billingCountries: readonly string[];
81
+ /** `users/{uid}.acquisition.geo.country`: where the sign-up request came from. */
82
+ signupCountry: string | null;
83
+ }
84
+ /** Why an account is left out. Counted in every report; never per person. */
85
+ export type AdAudienceExclusion = 'no-email' | 'disabled' | 'staff' | 'internal' | 'locked' | 'banned' | 'opted-out' | 'unverified' | 'no-country' | 'not-us';
86
+ export declare const AD_AUDIENCE_EXCLUSIONS: readonly AdAudienceExclusion[];
87
+ /** The one country the audience is built for. */
88
+ export declare const AD_AUDIENCE_COUNTRY = "US";
89
+ export type AdAudienceCountrySource = 'billing' | 'signup';
90
+ /**
91
+ * The account's country, and which signal said so.
92
+ *
93
+ * 1. **The billing address** of a workspace the account owns. It is the
94
+ * address the account holder typed or gave Stripe, it is the address tax
95
+ * is computed on, and it is the strongest country fact the platform holds.
96
+ * When the account owns workspaces billed in different countries, the
97
+ * answer is US only when EVERY one of them is US: an account that bills
98
+ * from abroad is not counted as a US account holder on the strength of
99
+ * one US workspace.
100
+ * 2. Otherwise **the sign-up geography**: the country the edge reported for
101
+ * the request that created the account (AGL-3289). It says where the
102
+ * person was when they signed up, which is a weaker fact, and it is the
103
+ * one most accounts have.
104
+ * 3. Otherwise nothing, and an account with no country is left out. A phone
105
+ * number's `+1` is deliberately not a signal: it is shared with Canada and
106
+ * the Caribbean.
107
+ */
108
+ export declare function resolveAccountCountry(account: {
109
+ billingCountries: readonly string[];
110
+ signupCountry: string | null;
111
+ }): {
112
+ country: string | null;
113
+ source: AdAudienceCountrySource | null;
114
+ };
115
+ /** The domain part of an operator address, lowercased, or null. */
116
+ export declare function emailDomain(address: string | null | undefined): string | null;
117
+ /**
118
+ * Why this account is left out, or null when it belongs in the audience.
119
+ *
120
+ * Every condition must hold: an address, not disabled, not staff, not on the
121
+ * operator's own domain, no account lock (an `abuse` lock is a permanent ban),
122
+ * not on the ban list, not opted out, a verified address, and a United
123
+ * States country. Account deletion needs no rule: an erased account has no
124
+ * Auth record, so it is never read at all, and the diff removes it.
125
+ *
126
+ * The address must be VERIFIED because anyone can sign up with an address
127
+ * that is not theirs, and uploading it would put a person who never signed up
128
+ * into the audience. A Google or SSO sign-in arrives verified.
129
+ */
130
+ export declare function adAudienceExclusion(account: AdAudienceAccount, options?: {
131
+ internalDomains?: readonly string[];
132
+ }): AdAudienceExclusion | null;
133
+ /** What has to change for a provider to hold `next` when it holds `previous`. */
134
+ export declare function diffAudience(previous: Iterable<string>, next: Iterable<string>): {
135
+ toAdd: string[];
136
+ toRemove: string[];
137
+ };
138
+ /**
139
+ * Whether a night's set shrank so far it is more likely a failed read than
140
+ * real churn. Below twenty there is no telling the two apart, so it is never
141
+ * refused; above it, losing more than half the audience in one night is held
142
+ * until a person passes `force`.
143
+ */
144
+ export declare function isSuspiciousShrink(previousSize: number, nextSize: number): boolean;
145
+ /** `items` in runs of at most `size`. */
146
+ export declare function chunk<T>(items: readonly T[], size: number): T[][];
147
+ export interface MetaAudienceConfig {
148
+ accessToken: string;
149
+ audienceId: string;
150
+ }
151
+ export interface GoogleAudienceConfig {
152
+ clientId: string;
153
+ clientSecret: string;
154
+ refreshToken: string;
155
+ /** The Google Ads customer that owns the list, digits only. */
156
+ customerId: string;
157
+ /** A manager account the credentials reach the customer through, digits only. */
158
+ loginCustomerId: string | null;
159
+ /** The Customer Match user list id. */
160
+ userListId: string;
161
+ }
162
+ export interface LinkedInAudienceConfig {
163
+ accessToken: string;
164
+ segmentId: string;
165
+ }
166
+ export interface AdAudienceConfig {
167
+ meta: MetaAudienceConfig | null;
168
+ google: GoogleAudienceConfig | null;
169
+ linkedin: LinkedInAudienceConfig | null;
170
+ /** For each provider left unconfigured, the variables it is missing. */
171
+ missing: Record<AdAudienceProvider, string[]>;
172
+ }
173
+ /**
174
+ * The variables, read here by their literal names so the self-host
175
+ * environment reference can see every one of them.
176
+ */
177
+ export declare function adAudienceEnv(): Record<string, string | undefined>;
178
+ /**
179
+ * Each provider's settings, or null with the names it lacks. A provider is
180
+ * configured only when every required variable is set; a half-configured one
181
+ * is skipped, never half-sent.
182
+ *
183
+ * The Meta audience is never created here. Creating one when the id is unset
184
+ * would mint a fresh, empty audience on every run whose variable was lost, and
185
+ * the ads targeting the real one would go on pointing at it, so the id is
186
+ * required and the audience is made once, by hand.
187
+ */
188
+ export declare function readAdAudienceConfig(env?: Record<string, string | undefined>): AdAudienceConfig;
189
+ /** One HTTP request, built without sending it. */
190
+ export interface AudienceRequest {
191
+ method: 'POST';
192
+ url: string;
193
+ headers: Record<string, string>;
194
+ /** A JSON body, or a form body when `headers` says so. */
195
+ body: Record<string, unknown> | string;
196
+ }
197
+ /**
198
+ * META — the replace session.
199
+ *
200
+ * `POST /{audience}/usersreplace` swaps the audience's whole membership for
201
+ * the batches of one session: `batch_seq` 1 opens it and `last_batch_flag`
202
+ * closes it, so a removal needs no separate call and nothing has to be
203
+ * remembered about who was there before. Each batch carries at most 10,000
204
+ * hashes, and the session must finish inside 90 minutes. A replace does not
205
+ * reset an ad set's learning phase, which an add and a delete would.
206
+ */
207
+ export declare const META_GRAPH_VERSION = "v25.0";
208
+ export declare const META_REPLACE_BATCH = 10000;
209
+ export declare function buildMetaReplaceRequests(config: MetaAudienceConfig, hashes: readonly string[], sessionId: number): AudienceRequest[];
210
+ /**
211
+ * GOOGLE — the Data Manager API, not the Google Ads API.
212
+ *
213
+ * Since 2026-04-01 the Google Ads API's `OfflineUserDataJobService` refuses
214
+ * Customer Match uploads for a developer token with no Customer Match
215
+ * activity in the six months before, and Google points every such project at
216
+ * the Data Manager API instead. It needs an OAuth token with the
217
+ * `datamanager` scope and no developer token.
218
+ *
219
+ * Adds go to `audienceMembers:ingest` and removals to `audienceMembers:remove`,
220
+ * 10,000 members a request. The Data Manager API has no replace, so the
221
+ * removals come from the set this sync last sent.
222
+ *
223
+ * No `consent` is sent. The audience is United States account holders only,
224
+ * for whom Google requires no consent signal, and stating `CONSENT_GRANTED`
225
+ * would assert a consent this platform does not collect.
226
+ * `customerMatchTermsOfServiceStatus: ACCEPTED` is the operator's own
227
+ * acceptance of the Customer Match terms, which Google requires on an
228
+ * ingest; they are accepted in the Google Ads account before this is
229
+ * configured.
230
+ */
231
+ export declare const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
232
+ export declare const GOOGLE_DATA_MANAGER_BASE = "https://datamanager.googleapis.com/v1";
233
+ export declare const GOOGLE_MEMBERS_BATCH = 10000;
234
+ export declare function buildGoogleTokenRequest(config: GoogleAudienceConfig): AudienceRequest;
235
+ export declare function buildGoogleMemberRequests(config: GoogleAudienceConfig, accessToken: string, action: 'ingest' | 'remove', hashes: readonly string[]): AudienceRequest[];
236
+ /**
237
+ * LINKEDIN — DMP segment users, streamed.
238
+ *
239
+ * `POST /rest/dmpSegments/{id}/users` with `X-RestLi-Method: BATCH_CREATE`
240
+ * takes up to 5,000 `ADD` or `REMOVE` elements a request. There is no
241
+ * replace, so the removals come from the set this sync last sent. Adding an
242
+ * id twice or removing one already gone both answer 201, so a retry is safe.
243
+ * The API is open only to developers LinkedIn has approved for Matched
244
+ * Audiences, with the `rw_dmp_segments` permission.
245
+ */
246
+ export declare const LINKEDIN_VERSION = "202609";
247
+ export declare const LINKEDIN_USERS_BATCH = 5000;
248
+ export declare function buildLinkedInUserRequests(config: LinkedInAudienceConfig, action: 'ADD' | 'REMOVE', hashes: readonly string[]): AudienceRequest[];
249
+ /** How many elements of a LinkedIn batch response were refused. */
250
+ export declare function linkedInRefusedCount(answer: unknown): number;