@aglyn/tenant-data-admin 1.0.0-beta.172 → 1.0.0-beta.174

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.172",
3
+ "version": "1.0.0-beta.174",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,11 +25,11 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.172",
29
- "@aglyn/shared-util-email": "1.0.0-beta.172",
30
- "@aglyn/shared-util-fbserver": "1.0.0-beta.172",
31
- "@aglyn/shared-util-http": "1.0.0-beta.172",
32
- "@aglyn/shared-util-tools": "1.0.0-beta.172",
28
+ "@aglyn/aglyn": "1.0.0-beta.174",
29
+ "@aglyn/shared-util-email": "1.0.0-beta.174",
30
+ "@aglyn/shared-util-fbserver": "1.0.0-beta.174",
31
+ "@aglyn/shared-util-http": "1.0.0-beta.174",
32
+ "@aglyn/shared-util-tools": "1.0.0-beta.174",
33
33
  "@msgpack/msgpack": "^3.1.3",
34
34
  "@swc/helpers": "0.5.23",
35
35
  "sharp": "^0.35.3",
@@ -14,6 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
+ import { type DocumentReference, type Firestore } from 'firebase-admin/firestore';
17
18
  import { CAMPAIGN_ATTRIBUTIONS_COLLECTION, eraseCampaignAttributionsForPersonKey } from './campaign-attribution-store';
18
19
  /**
19
20
  * THE IDENTIFY-MOMENT JOIN — a form, a lead, a contact or a booking, credited
@@ -222,7 +223,7 @@ export declare function attributeCampaignConversion(options: {
222
223
  convertedAtMs?: number;
223
224
  }, firestore?: any): Promise<CampaignConversionRecord | null>;
224
225
  /**
225
- * The per-host collection of sequence rollups. Restated from the reader
226
+ * The per-org collection of sequence rollups. Restated from the reader
226
227
  * (`campaign-report.ts` in the campaigns UI library, which this package may
227
228
  * not import) and asserted equal by the reader's spec.
228
229
  */
@@ -241,10 +242,42 @@ export type CampaignSequenceOutcome = (typeof CAMPAIGN_SEQUENCE_OUTCOMES)[number
241
242
  * @returns how many campaigns were credited.
242
243
  */
243
244
  export declare function creditCampaignSequenceOutcome(options: {
245
+ /** The site the enrollment emails for; resolves the org when none is given. */
244
246
  hostId: string;
247
+ /**
248
+ * The org whose containers the campaigns are, when the caller holds it —
249
+ * an enrollment is an org document, so its routes do. Saves the
250
+ * `hostIndex` read.
251
+ */
252
+ orgId?: string | null;
245
253
  /** The campaigns the enrollment carries; unusable ids are skipped. */
246
254
  campaignIds: readonly string[];
247
255
  outcome: CampaignSequenceOutcome;
248
256
  /** When the outcome happened. Defaults to now. */
249
257
  atMs?: number;
250
258
  }, firestore?: any): Promise<number>;
259
+ /** `orgs/{orgId}/campaigns/{sendId}`. */
260
+ export declare function orgCampaignSendRef(orgId: string, sendId: string, firestore?: Firestore): DocumentReference;
261
+ /** `orgs/{orgId}/campaignSequenceReports/{campaignId}`. */
262
+ export declare function orgCampaignSequenceReportRef(orgId: string, campaignId: string, firestore?: Firestore): DocumentReference;
263
+ export interface ResolveCampaignSendRefInput {
264
+ /** The site the link or the message tag names. */
265
+ hostId: string;
266
+ /** The send id (`cid` on a link, `campaignId` on a message tag). */
267
+ sendId: string;
268
+ /**
269
+ * The org, when the caller already has it — a message tag minted since
270
+ * AGL-3273 carries it. Saves the `hostIndex` read.
271
+ */
272
+ orgId?: string | null;
273
+ firestore?: Firestore;
274
+ }
275
+ /**
276
+ * The send's document, wherever it currently is. Null when neither location
277
+ * holds it — a send id from a link whose send was discarded, or a forged one.
278
+ *
279
+ * Costs one read after the migration (the org document), plus the
280
+ * `hostIndex` lookup when the caller has no org id; that lookup is
281
+ * request-cached.
282
+ */
283
+ export declare function resolveCampaignSendRef(input: ResolveCampaignSendRefInput): Promise<DocumentReference | null>;
@@ -30,6 +30,7 @@ import { CAMPAIGN_ATTRIBUTIONS_COLLECTION, eraseCampaignAttributionsForPersonKey
30
30
  import { readEmailCampaignTouch } from "./email-delivery-log.js";
31
31
  import { isDocumentId } from "./document-id.js";
32
32
  import firebaseAdmin from "./firebase-admin.js";
33
+ import { resolveOrgIdForHost } from "./organizations.js";
33
34
  const defaultFirestore = ()=>firebaseAdmin.app().firestore();
34
35
  /**
35
36
  * THE IDENTIFY-MOMENT JOIN — a form, a lead, a contact or a booking, credited
@@ -257,7 +258,18 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
257
258
  } catch (unused) {
258
259
  return null;
259
260
  }
260
- if (touch.channel === 'email' && touch.campaignId) {
261
+ /*
262
+ * The send the touch names (`touch.campaignId` is a SEND id on the email
263
+ * channel), wherever it is: the org's `campaigns` collection, or the
264
+ * site's for a send the migration has not reached. A send in neither
265
+ * place was discarded, and its rollup is skipped — the record above
266
+ * already says what was credited.
267
+ */ const sendRef = touch.channel === 'email' && touch.campaignId && isDocumentId(touch.campaignId) ? await resolveCampaignSendRef({
268
+ hostId,
269
+ sendId: touch.campaignId,
270
+ firestore: db
271
+ }) : null;
272
+ if (sendRef) {
261
273
  /*
262
274
  * A merge-set that CREATES, for the reason `reports/revenue` gives: this
263
275
  * writes a document UNDER a campaign, so a campaign that no longer
@@ -267,7 +279,7 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
267
279
  *
268
280
  * Every figure is an increment, so two conversions settling at once
269
281
  * both land.
270
- */ await hostRef.collection('campaigns').doc(touch.campaignId).collection('reports').doc(CAMPAIGN_CONVERSIONS_REPORT_DOC).set({
282
+ */ await sendRef.collection('reports').doc(CAMPAIGN_CONVERSIONS_REPORT_DOC).set({
271
283
  model: ATTRIBUTION_MODEL,
272
284
  windowDays: ATTRIBUTION_WINDOW_DAYS,
273
285
  byKind: {
@@ -308,9 +320,10 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
308
320
  * credits what each enrollment produced — enrolled, first email sent, a
309
321
  * reply, a meeting booked from a sequence link, an enrolled lead converted
310
322
  * — to every campaign the sequence was in when the person was enrolled. The
311
- * counts live in `hosts/{hostId}/campaignSequenceReports/{campaignId}`:
312
- * one document per campaign, server-only like every other report here, and
313
- * never inside the campaign container, which the history list reads.
323
+ * counts live in `orgs/{orgId}/campaignSequenceReports/{campaignId}`, beside
324
+ * the org's containers: one document per campaign, server-only like every
325
+ * other report here, and never inside the campaign container, which the
326
+ * history list reads.
314
327
  *
315
328
  * Idempotency is the CALLER's: a plugin credits an outcome once per
316
329
  * enrollment from a state change that happens once — the enrollment is
@@ -318,7 +331,7 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
318
331
  * once, its lead converts once — so this writer is a plain increment and
319
332
  * keeps no per-enrollment record of its own.
320
333
  *=========================================*/ /**
321
- * The per-host collection of sequence rollups. Restated from the reader
334
+ * The per-org collection of sequence rollups. Restated from the reader
322
335
  * (`campaign-report.ts` in the campaigns UI library, which this package may
323
336
  * not import) and asserted equal by the reader's spec.
324
337
  */ export const CAMPAIGN_SEQUENCE_REPORTS_COLLECTION = 'campaignSequenceReports';
@@ -340,18 +353,19 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
340
353
  * @returns how many campaigns were credited.
341
354
  */ export async function creditCampaignSequenceOutcome(options, firestore) {
342
355
  try {
343
- var _options_hostId, _options_atMs;
344
- const hostId = String((_options_hostId = options.hostId) != null ? _options_hostId : '');
345
- if (!isDocumentId(hostId)) return 0;
356
+ var _options_atMs, _options_hostId, _options_orgId;
346
357
  if (!CAMPAIGN_SEQUENCE_OUTCOMES.includes(options.outcome)) return 0;
347
358
  const atMs = Number((_options_atMs = options.atMs) != null ? _options_atMs : Date.now());
348
359
  const ids = [
349
360
  ...new Set(options.campaignIds.map((id)=>String(id != null ? id : '').trim()))
350
361
  ].filter((id)=>isDocumentId(id));
351
362
  if (!ids.length) return 0;
363
+ const hostId = String((_options_hostId = options.hostId) != null ? _options_hostId : '');
364
+ const given = String((_options_orgId = options.orgId) != null ? _options_orgId : '');
365
+ const orgId = isDocumentId(given) ? given : isDocumentId(hostId) ? await resolveOrgIdForHost(hostId) : null;
366
+ if (!orgId || !isDocumentId(orgId)) return 0;
352
367
  const db = firestore != null ? firestore : defaultFirestore();
353
- const reports = db.collection('hosts').doc(hostId).collection(CAMPAIGN_SEQUENCE_REPORTS_COLLECTION);
354
- await Promise.all(ids.map((campaignId)=>reports.doc(campaignId).set(_extends({
368
+ await Promise.all(ids.map((campaignId)=>orgCampaignSequenceReportRef(orgId, campaignId, db).set(_extends({
355
369
  byOutcome: {
356
370
  [options.outcome]: FieldValue.increment(1)
357
371
  }
@@ -368,5 +382,69 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
368
382
  return 0;
369
383
  }
370
384
  }
385
+ /*==========================================
386
+ * WHERE A SEND IS (AGL-3273)
387
+ *=========================================*/ /**
388
+ * WHERE A SEND IS, given only what a link or a delivery event carries.
389
+ *
390
+ * Campaign containers and sends belong to the organization
391
+ * (`orgs/{orgId}/emailCampaigns`, `orgs/{orgId}/campaigns`, AGL-3273). Every
392
+ * reader that starts from inside the product already holds the org id and
393
+ * addresses those collections directly. This module is for the readers that
394
+ * start OUTSIDE it — the unsubscribe link, the provider's delivery webhook,
395
+ * the conversion join — which hold a site id and a send id and nothing else,
396
+ * because that is what was signed into the link or tagged on the message
397
+ * when it went out, possibly years ago.
398
+ *
399
+ * ## The fallback, and why it stays
400
+ *
401
+ * The site path (`hosts/{hostId}/campaigns/{sendId}`) is where every send
402
+ * lived before the move, and the migration copies each one up under its
403
+ * existing id. Between a deployment of this code and the migration reaching
404
+ * an org, the send is still only at the site; and an interrupted migration
405
+ * leaves some sends behind. A reader that looked only at the org would, in
406
+ * that window, drop an unsubscribe's count or a bounce — and a bounce that
407
+ * is dropped is an address that gets mailed again. So the org is asked
408
+ * first and the site second. After the migration the second read never
409
+ * happens, because the first one answers.
410
+ *
411
+ * The collection names are spelled here rather than imported from the
412
+ * campaign model (`@aglyn/shared-ui-email-campaigns`), which this package
413
+ * does not depend on. They are the same strings; `campaign-container.ts`
414
+ * documents them. The join above is this lookup's first reader, and the two
415
+ * move together when the campaign code leaves this package.
416
+ */ const sendRefFirestore = ()=>firebaseAdmin.app().firestore();
417
+ /** `orgs/{orgId}/campaigns` — one document per email send. */ const ORG_SENDS = 'campaigns';
418
+ /** `orgs/{orgId}/campaignSequenceReports` — keyed by the container id. */ const ORG_SEQUENCE_REPORTS = 'campaignSequenceReports';
419
+ /** `orgs/{orgId}/campaigns/{sendId}`. */ export function orgCampaignSendRef(orgId, sendId, firestore = sendRefFirestore()) {
420
+ return firestore.collection('orgs').doc(orgId).collection(ORG_SENDS).doc(sendId);
421
+ }
422
+ /** `orgs/{orgId}/campaignSequenceReports/{campaignId}`. */ export function orgCampaignSequenceReportRef(orgId, campaignId, firestore = sendRefFirestore()) {
423
+ return firestore.collection('orgs').doc(orgId).collection(ORG_SEQUENCE_REPORTS).doc(campaignId);
424
+ }
425
+ /** The pre-AGL-3273 location, read only as the fallback described above. */ function legacySiteSendRef(hostId, sendId, firestore) {
426
+ return firestore.collection('hosts').doc(hostId).collection(ORG_SENDS).doc(sendId);
427
+ }
428
+ /**
429
+ * The send's document, wherever it currently is. Null when neither location
430
+ * holds it — a send id from a link whose send was discarded, or a forged one.
431
+ *
432
+ * Costs one read after the migration (the org document), plus the
433
+ * `hostIndex` lookup when the caller has no org id; that lookup is
434
+ * request-cached.
435
+ */ export async function resolveCampaignSendRef(input) {
436
+ var _input_firestore;
437
+ const { hostId, sendId } = input;
438
+ if (!hostId || !sendId || sendId.includes('/') || hostId.includes('/')) return null;
439
+ const firestore = (_input_firestore = input.firestore) != null ? _input_firestore : sendRefFirestore();
440
+ const orgId = input.orgId || await resolveOrgIdForHost(hostId);
441
+ if (orgId) {
442
+ const orgRef = orgCampaignSendRef(orgId, sendId, firestore);
443
+ if ((await orgRef.get()).exists) return orgRef;
444
+ }
445
+ const siteRef = legacySiteSendRef(hostId, sendId, firestore);
446
+ if ((await siteRef.get()).exists) return siteRef;
447
+ return null;
448
+ }
371
449
 
372
450
  //# sourceMappingURL=campaign-conversion-attribution.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/campaign-conversion-attribution.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 { FieldValue } from 'firebase-admin/firestore'\nimport {\n parseCampaignTouch,\n type CampaignTouch,\n} from '@aglyn/aglyn/app-utils/campaign-touch'\n/*\n * Stamped from the REVENUE join's constants, aliased at the import.\n *\n * They are spelled `EMAIL_` because that is where both halves of that join\n * could reach them, not because the window is an email fact — it is the\n * platform's one answer to how long a touch may be credited. Reading them\n * from there is what makes a lead's record and an order's record carry the\n * same rule rather than two that happen to agree.\n */\nimport {\n EMAIL_ATTRIBUTION_MODEL as ATTRIBUTION_MODEL,\n EMAIL_ATTRIBUTION_WINDOW_DAYS as ATTRIBUTION_WINDOW_DAYS,\n emailTouchIsInWindow,\n} from '@aglyn/shared-util-email/email-revenue-window'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n CAMPAIGN_ATTRIBUTIONS_COLLECTION,\n eraseCampaignAttributionsForPersonKey,\n} from './campaign-attribution-store'\nimport { readEmailCampaignTouch } from './email-delivery-log'\nimport { isDocumentId } from './document-id'\nimport firebaseAdmin from './firebase-admin'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\n/**\n * THE IDENTIFY-MOMENT JOIN — a form, a lead, a contact or a booking, credited\n * to the campaign that led to it.\n *\n * ## Why there is a second join at all, and why it is not a second SCHEME\n *\n * `email-revenue-attribution.ts` credits an ORDER by looking the buyer's\n * address up in the touch map a click wrote. That works because an order\n * names its buyer. Every other outcome a campaign causes is produced by\n * somebody who was anonymous until the instant they produced it: they arrive\n * from a campaign link, browse, and only become identifiable when they submit\n * a form, sign up, book or check out. There is no address to look up until\n * the very moment being attributed.\n *\n * So the touch is carried on the VISITOR (`campaign-touch.ts`) and attached\n * here, at each moment they become somebody. What is NOT rebuilt is the\n * model: the same seven-day window, the same last-click rule, the same\n * `model`/`windowDays` stamped onto every record so a report can say what it\n * counted. The email channel is not re-derived either — it is READ from the\n * touch the delivery webhook already wrote, so a campaign email and a\n * campaign ad are two inputs to one comparison rather than two schemes with\n * two answers.\n *\n * ## Last touch, across both channels\n *\n * At the identify moment there can be two candidate touches:\n *\n * - the WEB touch the visitor's device carried, from an ad, a partner link\n * or a social post; and\n * - the EMAIL touch on `emailDeliveries/{personKey}.campaignTouches[hostId]`,\n * written server-side when they clicked a campaign's mail.\n *\n * Both are window-checked and the LATER one wins. Splitting the outcome\n * between them is the multi-touch model the revenue work rejected for the\n * reason that applies here unchanged: a rule the merchant did not choose\n * produces a figure nobody can check. One outcome, one campaign, and a rule\n * stated on the record.\n *\n * ## No campaign touch means NO RECORD\n *\n * Direct traffic writes nothing at all, and there is deliberately no fallback\n * — no \"most recent campaign on this site\", no referrer inference, no\n * `utm_source=direct`. A conversion nobody can be credited with is a\n * conversion nobody is credited with, and the absence of a record is how the\n * report says so. The cost of a miss is one uncounted outcome; the cost of a\n * guess is a campaign that reads as effective because it was the last one to\n * run.\n *\n * ## Three reads at most, and usually one\n *\n * Resolving a touch costs ONE keyed document read, and only when the visitor\n * gave an address — no query, no index, nothing that can be truncated. A door\n * resolves once per request and hands the result to every writer beneath it,\n * so a form submission that creates a submission, a contact and a lead pays\n * for the lookup once rather than three times.\n *\n * ## Never throws\n *\n * The same contract as the revenue join and for the same reason: the visitor\n * has already done the thing being attributed and the record of it is already\n * written. A lost attribution understates a campaign; a thrown one loses a\n * lead.\n */\n\n/**\n * The collection and the erasure sweep, re-exported so a caller needs one\n * import for the whole join. They are DEFINED in a leaf module because the\n * sweep runs from `email-delivery-log.ts`, which this file reads — see\n * `campaign-attribution-store.ts` for why that cycle is not merely untidy.\n */\nexport {\n CAMPAIGN_ATTRIBUTIONS_COLLECTION,\n eraseCampaignAttributionsForPersonKey,\n}\n\n/** The single conversion rollup document under an email campaign. */\nexport const CAMPAIGN_CONVERSIONS_REPORT_DOC = 'conversions'\n\n/**\n * Which identify moment a record credits.\n *\n * Kept apart rather than summed, which is the whole reason the kind is on the\n * record. One form submission by a new person creates a submission, a contact\n * and possibly a lead — three true statements about one visitor action — and\n * a report that added them would treble every campaign's conversions. A\n * reader asks for one kind at a time, exactly as the revenue rollup buckets\n * by currency and never totals across them.\n */\nexport type CampaignConversionKind = 'form' | 'lead' | 'contact' | 'booking'\n\n/**\n * Which channel the credited touch arrived through.\n *\n * `sequence` (AGL-3254) is a click on, or the sending of, a one-to-one\n * email a rep's sequence sent. It is read off the same touch map as the\n * email channel — the Outreach click route stamps it there with the\n * sequence and the enrollment beside the campaign the sequence is in — so\n * a booking made from a sequence link is credited by the door that credits\n * every other booking, and nothing in this join has to know what a\n * sequence is.\n */\nexport type CampaignTouchChannel = 'email' | 'web' | 'sequence'\n\n/** The touch a conversion is credited to, once both channels have been asked. */\nexport interface ResolvedCampaignTouch {\n channel: CampaignTouchChannel\n /**\n * The campaign document, when the touch was a click on our own mail — or\n * the campaign CONTAINER (`emailCampaigns/{id}`) a sequence is in, for a\n * sequence touch.\n */\n campaignId?: string\n /** The sequence the touch came through, for a `sequence` touch. */\n sequenceId?: string\n /** The enrollment the touch came through, for a `sequence` touch. */\n enrollmentId?: string\n /** `utm_source`, when the touch was a link on the web. */\n source?: string\n /** `utm_medium`, when the touch was a link on the web. */\n medium?: string\n /** `utm_campaign`, when the touch was a link on the web. */\n campaign?: string\n /** When the visitor followed the campaign link, epoch ms. */\n touchedAtMs: number\n /**\n * `sha256` of the normalized address the touch was resolved against, when\n * the visitor gave one. The handle an erasure erases by — see\n * {@link eraseCampaignAttributionsForPersonKey}.\n */\n personKey?: string\n}\n\n/** What one conversion attribution record holds. */\nexport interface CampaignConversionRecord extends ResolvedCampaignTouch {\n kind: CampaignConversionKind\n /** The submission, lead, contact or booking this credits. */\n refId: string\n /** When the visitor became identifiable. */\n convertedAtMs: number\n /** The model this credit was decided under. */\n model: string\n /** The window, in days, it was decided inside. */\n windowDays: number\n}\n\n/**\n * The document id for one conversion.\n *\n * `{kind}:{refId}`, so the same submission credited twice is the same\n * document — which is what makes {@link attributeCampaignConversion}'s\n * `create()` idempotent. Scoped by kind because the ids come from different\n * collections and a booking id has no reason to be distinct from a form\n * submission's.\n */\nexport function campaignConversionId(\n kind: CampaignConversionKind,\n refId: string,\n): string {\n return `${kind}:${refId}`\n}\n\n/**\n * The campaign touch to credit a conversion happening now, or `null`.\n *\n * Called ONCE per conversion request, at the door, and the result handed to\n * every writer beneath it. See the module docblock for the cost argument.\n *\n * @returns the winning touch, or `null` when neither channel has one inside\n * the window — which is the ordinary case and writes nothing.\n */\nexport async function resolveCampaignTouch(\n options: {\n hostId: string\n /**\n * The wire form the conversion request carried, when the door reports\n * one. Re-parsed through the allowlist that wrote it rather than trusted:\n * it arrives from the visitor's browser, so it may claim no more than an\n * inbound URL could.\n */\n wire?: unknown\n /** The address the visitor identified with, raw. */\n email?: unknown\n /** When they identified themselves. Defaults to now. */\n atMs?: number\n },\n firestore?: any,\n): Promise<ResolvedCampaignTouch | null> {\n try {\n const hostId = String(options.hostId ?? '')\n if (!isDocumentId(hostId)) return null\n const convertedAtMs = Number(options.atMs ?? Date.now())\n if (!Number.isFinite(convertedAtMs) || convertedAtMs <= 0) return null\n\n // The window is enforced inside the parser, so an expired or\n // future-dated wire value answers null here exactly as it does in the\n // browser that decided whether to send it.\n const web: CampaignTouch | null = parseCampaignTouch(\n options.wire,\n convertedAtMs,\n )\n\n const key = personKey(options.email)\n /*\n * The email channel is only askable of somebody who named an address, and\n * that is the whole reason this join needs the web channel at all. No\n * address means no keyed read: the ordinary anonymous conversion costs\n * nothing here.\n */\n const emailTouch = key\n ? await readEmailCampaignTouch(\n String(options.email ?? ''),\n hostId,\n firestore ?? defaultFirestore(),\n )\n : null\n const emailInWindow =\n emailTouch &&\n isDocumentId(emailTouch.campaignId) &&\n emailTouchIsInWindow(emailTouch.clickedAtMs, convertedAtMs)\n ? emailTouch\n : null\n\n /*\n * LAST TOUCH decides, and a tie goes to email. A tie is only reachable\n * when the two instants are the same millisecond, and in that case the\n * email touch is the better evidence: it was recorded by the provider's\n * own click event on the server, while the web touch is a value the\n * visitor's device supplied.\n */\n const emailWins =\n emailInWindow && (!web || emailInWindow.clickedAtMs >= web.atMs)\n if (emailWins) {\n const viaSequence = emailInWindow.sequenceId && emailInWindow.enrollmentId\n return {\n channel: viaSequence ? 'sequence' : 'email',\n campaignId: emailInWindow.campaignId,\n ...(viaSequence\n ? { sequenceId: emailInWindow.sequenceId, enrollmentId: emailInWindow.enrollmentId }\n : {}),\n touchedAtMs: emailInWindow.clickedAtMs,\n ...(key ? { personKey: key } : {}),\n }\n }\n if (!web) return null\n return {\n channel: 'web',\n ...(web.source ? { source: web.source } : {}),\n ...(web.medium ? { medium: web.medium } : {}),\n ...(web.campaign ? { campaign: web.campaign } : {}),\n touchedAtMs: web.atMs,\n ...(key ? { personKey: key } : {}),\n }\n } catch (error) {\n console.error('resolveCampaignTouch failed', error)\n return null\n }\n}\n\n/**\n * Credits one conversion to the campaign the visitor last came from.\n *\n * ## Why the record is a document and not a field\n *\n * The revenue join's three reasons, and the first is decisive here too.\n * `create()` fails when the document already exists, which is exact\n * idempotency for free: a retried form POST, a redelivered booking webhook\n * and a re-run capture all land here a second time and must all leave the\n * rollup where they found it. Second, the four kinds live in four different\n * collections — a contact is org-scoped and shared across every site in the\n * org — so there is no one document a field could go on. Third, the\n * submission, the lead and the booking are read by the inbox, the export and\n * the fulfilment path, and none of them wants a field about marketing.\n *\n * ## The rollup exists for the EMAIL channel only\n *\n * A campaign document is a real entity with a real id, so its conversions\n * roll up under it beside the revenue the same join already credits it with.\n * A web campaign is a LABEL the marketer typed into a URL, with no document,\n * no id and no bound on how many distinct values exist — the same unbounded\n * key space the analytics collector caps its per-day label map against. A\n * rollup keyed on it would be a map anybody who can vary a query string can\n * grow. So the web channel's records stand on their own and a report reads\n * them; see the module's report seam note.\n *\n * @returns the record written, or `null` when nothing was credited.\n */\nexport async function attributeCampaignConversion(\n options: {\n hostId: string\n kind: CampaignConversionKind\n /** The submission, lead, contact or booking being credited. */\n refId: string\n /** The touch {@link resolveCampaignTouch} picked, or `null` for direct. */\n touch: ResolvedCampaignTouch | null | undefined\n /** When the visitor became identifiable. Defaults to now. */\n convertedAtMs?: number\n },\n firestore?: any,\n): Promise<CampaignConversionRecord | null> {\n try {\n const { touch } = options\n if (!touch) return null\n const hostId = String(options.hostId ?? '')\n const refId = String(options.refId ?? '')\n if (!isDocumentId(hostId) || !isDocumentId(refId)) return null\n const convertedAtMs = Number(options.convertedAtMs ?? Date.now())\n if (!Number.isFinite(convertedAtMs) || convertedAtMs <= 0) return null\n // Re-checked at the write rather than trusted from the resolve. The two\n // are separated by the writes that produced the thing being credited, and\n // a touch that has aged out between them is a touch outside the window.\n if (!emailTouchIsInWindow(touch.touchedAtMs, convertedAtMs)) return null\n\n const record: CampaignConversionRecord = {\n kind: options.kind,\n refId,\n channel: touch.channel,\n ...(touch.campaignId ? { campaignId: touch.campaignId } : {}),\n ...(touch.sequenceId ? { sequenceId: touch.sequenceId } : {}),\n ...(touch.enrollmentId ? { enrollmentId: touch.enrollmentId } : {}),\n ...(touch.source ? { source: touch.source } : {}),\n ...(touch.medium ? { medium: touch.medium } : {}),\n ...(touch.campaign ? { campaign: touch.campaign } : {}),\n touchedAtMs: touch.touchedAtMs,\n convertedAtMs,\n model: ATTRIBUTION_MODEL,\n windowDays: ATTRIBUTION_WINDOW_DAYS,\n ...(touch.personKey ? { personKey: touch.personKey } : {}),\n }\n\n const db = firestore ?? defaultFirestore()\n const hostRef = db.collection('hosts').doc(hostId)\n /*\n * `create()`, never `set()` — the revenue join's argument, unchanged. The\n * ALREADY_EXISTS failure IS the idempotency, and the rollup below is\n * reached only when the create succeeded, so the two can never disagree\n * about whether this conversion was counted.\n */\n try {\n await hostRef\n .collection(CAMPAIGN_ATTRIBUTIONS_COLLECTION)\n .doc(campaignConversionId(options.kind, refId))\n .create({ ...record, createdAt: FieldValue.serverTimestamp() })\n } catch {\n return null\n }\n\n if (touch.channel === 'email' && touch.campaignId) {\n /*\n * A merge-set that CREATES, for the reason `reports/revenue` gives: this\n * writes a document UNDER a campaign, so a campaign that no longer\n * exists gains an orphaned report rather than being resurrected as a\n * husk in the merchant's history — and the campaign was proven to exist\n * when its click wrote the touch.\n *\n * Every figure is an increment, so two conversions settling at once\n * both land.\n */\n await hostRef\n .collection('campaigns')\n .doc(touch.campaignId)\n .collection('reports')\n .doc(CAMPAIGN_CONVERSIONS_REPORT_DOC)\n .set(\n {\n model: ATTRIBUTION_MODEL,\n windowDays: ATTRIBUTION_WINDOW_DAYS,\n byKind: { [options.kind]: FieldValue.increment(1) },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n }\n\n /*\n * A SEQUENCE touch rolls up under the campaign's sequences report, and\n * only a booking does: \"meetings booked from a sequence link\" is the one\n * identify moment a sequence's own outcomes do not already count. The\n * form, lead and contact kinds stand as records — visible on the record\n * they credit and on the site's conversions list — and never in\n * `byKind`, whose caveat promises the reader campaign emails only.\n */\n if (touch.channel === 'sequence' && touch.campaignId && options.kind === 'booking') {\n await creditCampaignSequenceOutcome(\n { hostId, campaignIds: [touch.campaignId], outcome: 'meetings', atMs: convertedAtMs },\n db,\n )\n }\n\n return record\n } catch (error) {\n console.error('attributeCampaignConversion failed', error)\n return null\n }\n}\n\n/*==========================================\n * WHAT A CAMPAIGN'S SEQUENCES PRODUCED (AGL-3254).\n *\n * A sequence joins a campaign the way a form does, and the Outreach runtime\n * credits what each enrollment produced — enrolled, first email sent, a\n * reply, a meeting booked from a sequence link, an enrolled lead converted\n * — to every campaign the sequence was in when the person was enrolled. The\n * counts live in `hosts/{hostId}/campaignSequenceReports/{campaignId}`:\n * one document per campaign, server-only like every other report here, and\n * never inside the campaign container, which the history list reads.\n *\n * Idempotency is the CALLER's: a plugin credits an outcome once per\n * enrollment from a state change that happens once — the enrollment is\n * created once, its first email is the first, its status moves to replied\n * once, its lead converts once — so this writer is a plain increment and\n * keeps no per-enrollment record of its own.\n *=========================================*/\n\n/**\n * The per-host collection of sequence rollups. Restated from the reader\n * (`campaign-report.ts` in the campaigns UI library, which this package may\n * not import) and asserted equal by the reader's spec.\n */\nexport const CAMPAIGN_SEQUENCE_REPORTS_COLLECTION = 'campaignSequenceReports'\n\n/** The outcomes the runtime credits, in funnel order. */\nexport const CAMPAIGN_SEQUENCE_OUTCOMES = [\n 'enrolled',\n 'sent',\n 'replied',\n 'meetings',\n 'converted',\n] as const\n\nexport type CampaignSequenceOutcome = (typeof CAMPAIGN_SEQUENCE_OUTCOMES)[number]\n\n/**\n * Credits one outcome, for one enrollment, to every campaign named.\n *\n * Never throws, and never refuses the batch for one bad id: the outcome\n * has already happened, and a campaign that is gone gains an orphaned\n * report rather than blocking the credit to one that is not — the\n * conversions rollup's own argument for a merge-set that creates.\n *\n * @returns how many campaigns were credited.\n */\nexport async function creditCampaignSequenceOutcome(\n options: {\n hostId: string\n /** The campaigns the enrollment carries; unusable ids are skipped. */\n campaignIds: readonly string[]\n outcome: CampaignSequenceOutcome\n /** When the outcome happened. Defaults to now. */\n atMs?: number\n },\n firestore?: any,\n): Promise<number> {\n try {\n const hostId = String(options.hostId ?? '')\n if (!isDocumentId(hostId)) return 0\n if (!(CAMPAIGN_SEQUENCE_OUTCOMES as readonly string[]).includes(options.outcome)) return 0\n const atMs = Number(options.atMs ?? Date.now())\n const ids = [...new Set(options.campaignIds.map((id) => String(id ?? '').trim()))].filter(\n (id) => isDocumentId(id),\n )\n if (!ids.length) return 0\n const db = firestore ?? defaultFirestore()\n const reports = db.collection('hosts').doc(hostId).collection(CAMPAIGN_SEQUENCE_REPORTS_COLLECTION)\n await Promise.all(\n ids.map((campaignId) =>\n reports.doc(campaignId).set(\n {\n byOutcome: { [options.outcome]: FieldValue.increment(1) },\n ...(Number.isFinite(atMs) && atMs > 0 ? { updatedAtMs: atMs } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n ),\n ),\n )\n return ids.length\n } catch (error) {\n console.error('creditCampaignSequenceOutcome failed', error)\n return 0\n }\n}\n"],"names":["FieldValue","parseCampaignTouch","EMAIL_ATTRIBUTION_MODEL","ATTRIBUTION_MODEL","EMAIL_ATTRIBUTION_WINDOW_DAYS","ATTRIBUTION_WINDOW_DAYS","emailTouchIsInWindow","personKey","CAMPAIGN_ATTRIBUTIONS_COLLECTION","eraseCampaignAttributionsForPersonKey","readEmailCampaignTouch","isDocumentId","firebaseAdmin","defaultFirestore","app","firestore","CAMPAIGN_CONVERSIONS_REPORT_DOC","campaignConversionId","kind","refId","resolveCampaignTouch","options","hostId","String","convertedAtMs","Number","atMs","Date","now","isFinite","web","wire","key","email","emailTouch","emailInWindow","campaignId","clickedAtMs","emailWins","viaSequence","sequenceId","enrollmentId","channel","touchedAtMs","source","medium","campaign","error","console","attributeCampaignConversion","touch","record","model","windowDays","db","hostRef","collection","doc","create","createdAt","serverTimestamp","set","byKind","increment","updatedAt","merge","creditCampaignSequenceOutcome","campaignIds","outcome","CAMPAIGN_SEQUENCE_REPORTS_COLLECTION","CAMPAIGN_SEQUENCE_OUTCOMES","includes","ids","Set","map","id","trim","filter","length","reports","Promise","all","byOutcome","updatedAtMs"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/campaign-conversion-attribution.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 { FieldValue, type DocumentReference, type Firestore } from 'firebase-admin/firestore'\nimport {\n parseCampaignTouch,\n type CampaignTouch,\n} from '@aglyn/aglyn/app-utils/campaign-touch'\n/*\n * Stamped from the REVENUE join's constants, aliased at the import.\n *\n * They are spelled `EMAIL_` because that is where both halves of that join\n * could reach them, not because the window is an email fact — it is the\n * platform's one answer to how long a touch may be credited. Reading them\n * from there is what makes a lead's record and an order's record carry the\n * same rule rather than two that happen to agree.\n */\nimport {\n EMAIL_ATTRIBUTION_MODEL as ATTRIBUTION_MODEL,\n EMAIL_ATTRIBUTION_WINDOW_DAYS as ATTRIBUTION_WINDOW_DAYS,\n emailTouchIsInWindow,\n} from '@aglyn/shared-util-email/email-revenue-window'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n CAMPAIGN_ATTRIBUTIONS_COLLECTION,\n eraseCampaignAttributionsForPersonKey,\n} from './campaign-attribution-store'\nimport { readEmailCampaignTouch } from './email-delivery-log'\nimport { isDocumentId } from './document-id'\nimport firebaseAdmin from './firebase-admin'\nimport { resolveOrgIdForHost } from './organizations'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\n/**\n * THE IDENTIFY-MOMENT JOIN — a form, a lead, a contact or a booking, credited\n * to the campaign that led to it.\n *\n * ## Why there is a second join at all, and why it is not a second SCHEME\n *\n * `email-revenue-attribution.ts` credits an ORDER by looking the buyer's\n * address up in the touch map a click wrote. That works because an order\n * names its buyer. Every other outcome a campaign causes is produced by\n * somebody who was anonymous until the instant they produced it: they arrive\n * from a campaign link, browse, and only become identifiable when they submit\n * a form, sign up, book or check out. There is no address to look up until\n * the very moment being attributed.\n *\n * So the touch is carried on the VISITOR (`campaign-touch.ts`) and attached\n * here, at each moment they become somebody. What is NOT rebuilt is the\n * model: the same seven-day window, the same last-click rule, the same\n * `model`/`windowDays` stamped onto every record so a report can say what it\n * counted. The email channel is not re-derived either — it is READ from the\n * touch the delivery webhook already wrote, so a campaign email and a\n * campaign ad are two inputs to one comparison rather than two schemes with\n * two answers.\n *\n * ## Last touch, across both channels\n *\n * At the identify moment there can be two candidate touches:\n *\n * - the WEB touch the visitor's device carried, from an ad, a partner link\n * or a social post; and\n * - the EMAIL touch on `emailDeliveries/{personKey}.campaignTouches[hostId]`,\n * written server-side when they clicked a campaign's mail.\n *\n * Both are window-checked and the LATER one wins. Splitting the outcome\n * between them is the multi-touch model the revenue work rejected for the\n * reason that applies here unchanged: a rule the merchant did not choose\n * produces a figure nobody can check. One outcome, one campaign, and a rule\n * stated on the record.\n *\n * ## No campaign touch means NO RECORD\n *\n * Direct traffic writes nothing at all, and there is deliberately no fallback\n * — no \"most recent campaign on this site\", no referrer inference, no\n * `utm_source=direct`. A conversion nobody can be credited with is a\n * conversion nobody is credited with, and the absence of a record is how the\n * report says so. The cost of a miss is one uncounted outcome; the cost of a\n * guess is a campaign that reads as effective because it was the last one to\n * run.\n *\n * ## Three reads at most, and usually one\n *\n * Resolving a touch costs ONE keyed document read, and only when the visitor\n * gave an address — no query, no index, nothing that can be truncated. A door\n * resolves once per request and hands the result to every writer beneath it,\n * so a form submission that creates a submission, a contact and a lead pays\n * for the lookup once rather than three times.\n *\n * ## Never throws\n *\n * The same contract as the revenue join and for the same reason: the visitor\n * has already done the thing being attributed and the record of it is already\n * written. A lost attribution understates a campaign; a thrown one loses a\n * lead.\n */\n\n/**\n * The collection and the erasure sweep, re-exported so a caller needs one\n * import for the whole join. They are DEFINED in a leaf module because the\n * sweep runs from `email-delivery-log.ts`, which this file reads — see\n * `campaign-attribution-store.ts` for why that cycle is not merely untidy.\n */\nexport {\n CAMPAIGN_ATTRIBUTIONS_COLLECTION,\n eraseCampaignAttributionsForPersonKey,\n}\n\n/** The single conversion rollup document under an email campaign. */\nexport const CAMPAIGN_CONVERSIONS_REPORT_DOC = 'conversions'\n\n/**\n * Which identify moment a record credits.\n *\n * Kept apart rather than summed, which is the whole reason the kind is on the\n * record. One form submission by a new person creates a submission, a contact\n * and possibly a lead — three true statements about one visitor action — and\n * a report that added them would treble every campaign's conversions. A\n * reader asks for one kind at a time, exactly as the revenue rollup buckets\n * by currency and never totals across them.\n */\nexport type CampaignConversionKind = 'form' | 'lead' | 'contact' | 'booking'\n\n/**\n * Which channel the credited touch arrived through.\n *\n * `sequence` (AGL-3254) is a click on, or the sending of, a one-to-one\n * email a rep's sequence sent. It is read off the same touch map as the\n * email channel — the Outreach click route stamps it there with the\n * sequence and the enrollment beside the campaign the sequence is in — so\n * a booking made from a sequence link is credited by the door that credits\n * every other booking, and nothing in this join has to know what a\n * sequence is.\n */\nexport type CampaignTouchChannel = 'email' | 'web' | 'sequence'\n\n/** The touch a conversion is credited to, once both channels have been asked. */\nexport interface ResolvedCampaignTouch {\n channel: CampaignTouchChannel\n /**\n * The campaign document, when the touch was a click on our own mail — or\n * the campaign CONTAINER (`emailCampaigns/{id}`) a sequence is in, for a\n * sequence touch.\n */\n campaignId?: string\n /** The sequence the touch came through, for a `sequence` touch. */\n sequenceId?: string\n /** The enrollment the touch came through, for a `sequence` touch. */\n enrollmentId?: string\n /** `utm_source`, when the touch was a link on the web. */\n source?: string\n /** `utm_medium`, when the touch was a link on the web. */\n medium?: string\n /** `utm_campaign`, when the touch was a link on the web. */\n campaign?: string\n /** When the visitor followed the campaign link, epoch ms. */\n touchedAtMs: number\n /**\n * `sha256` of the normalized address the touch was resolved against, when\n * the visitor gave one. The handle an erasure erases by — see\n * {@link eraseCampaignAttributionsForPersonKey}.\n */\n personKey?: string\n}\n\n/** What one conversion attribution record holds. */\nexport interface CampaignConversionRecord extends ResolvedCampaignTouch {\n kind: CampaignConversionKind\n /** The submission, lead, contact or booking this credits. */\n refId: string\n /** When the visitor became identifiable. */\n convertedAtMs: number\n /** The model this credit was decided under. */\n model: string\n /** The window, in days, it was decided inside. */\n windowDays: number\n}\n\n/**\n * The document id for one conversion.\n *\n * `{kind}:{refId}`, so the same submission credited twice is the same\n * document — which is what makes {@link attributeCampaignConversion}'s\n * `create()` idempotent. Scoped by kind because the ids come from different\n * collections and a booking id has no reason to be distinct from a form\n * submission's.\n */\nexport function campaignConversionId(\n kind: CampaignConversionKind,\n refId: string,\n): string {\n return `${kind}:${refId}`\n}\n\n/**\n * The campaign touch to credit a conversion happening now, or `null`.\n *\n * Called ONCE per conversion request, at the door, and the result handed to\n * every writer beneath it. See the module docblock for the cost argument.\n *\n * @returns the winning touch, or `null` when neither channel has one inside\n * the window — which is the ordinary case and writes nothing.\n */\nexport async function resolveCampaignTouch(\n options: {\n hostId: string\n /**\n * The wire form the conversion request carried, when the door reports\n * one. Re-parsed through the allowlist that wrote it rather than trusted:\n * it arrives from the visitor's browser, so it may claim no more than an\n * inbound URL could.\n */\n wire?: unknown\n /** The address the visitor identified with, raw. */\n email?: unknown\n /** When they identified themselves. Defaults to now. */\n atMs?: number\n },\n firestore?: any,\n): Promise<ResolvedCampaignTouch | null> {\n try {\n const hostId = String(options.hostId ?? '')\n if (!isDocumentId(hostId)) return null\n const convertedAtMs = Number(options.atMs ?? Date.now())\n if (!Number.isFinite(convertedAtMs) || convertedAtMs <= 0) return null\n\n // The window is enforced inside the parser, so an expired or\n // future-dated wire value answers null here exactly as it does in the\n // browser that decided whether to send it.\n const web: CampaignTouch | null = parseCampaignTouch(\n options.wire,\n convertedAtMs,\n )\n\n const key = personKey(options.email)\n /*\n * The email channel is only askable of somebody who named an address, and\n * that is the whole reason this join needs the web channel at all. No\n * address means no keyed read: the ordinary anonymous conversion costs\n * nothing here.\n */\n const emailTouch = key\n ? await readEmailCampaignTouch(\n String(options.email ?? ''),\n hostId,\n firestore ?? defaultFirestore(),\n )\n : null\n const emailInWindow =\n emailTouch &&\n isDocumentId(emailTouch.campaignId) &&\n emailTouchIsInWindow(emailTouch.clickedAtMs, convertedAtMs)\n ? emailTouch\n : null\n\n /*\n * LAST TOUCH decides, and a tie goes to email. A tie is only reachable\n * when the two instants are the same millisecond, and in that case the\n * email touch is the better evidence: it was recorded by the provider's\n * own click event on the server, while the web touch is a value the\n * visitor's device supplied.\n */\n const emailWins =\n emailInWindow && (!web || emailInWindow.clickedAtMs >= web.atMs)\n if (emailWins) {\n const viaSequence = emailInWindow.sequenceId && emailInWindow.enrollmentId\n return {\n channel: viaSequence ? 'sequence' : 'email',\n campaignId: emailInWindow.campaignId,\n ...(viaSequence\n ? { sequenceId: emailInWindow.sequenceId, enrollmentId: emailInWindow.enrollmentId }\n : {}),\n touchedAtMs: emailInWindow.clickedAtMs,\n ...(key ? { personKey: key } : {}),\n }\n }\n if (!web) return null\n return {\n channel: 'web',\n ...(web.source ? { source: web.source } : {}),\n ...(web.medium ? { medium: web.medium } : {}),\n ...(web.campaign ? { campaign: web.campaign } : {}),\n touchedAtMs: web.atMs,\n ...(key ? { personKey: key } : {}),\n }\n } catch (error) {\n console.error('resolveCampaignTouch failed', error)\n return null\n }\n}\n\n/**\n * Credits one conversion to the campaign the visitor last came from.\n *\n * ## Why the record is a document and not a field\n *\n * The revenue join's three reasons, and the first is decisive here too.\n * `create()` fails when the document already exists, which is exact\n * idempotency for free: a retried form POST, a redelivered booking webhook\n * and a re-run capture all land here a second time and must all leave the\n * rollup where they found it. Second, the four kinds live in four different\n * collections — a contact is org-scoped and shared across every site in the\n * org — so there is no one document a field could go on. Third, the\n * submission, the lead and the booking are read by the inbox, the export and\n * the fulfilment path, and none of them wants a field about marketing.\n *\n * ## The rollup exists for the EMAIL channel only\n *\n * A campaign document is a real entity with a real id, so its conversions\n * roll up under it beside the revenue the same join already credits it with.\n * A web campaign is a LABEL the marketer typed into a URL, with no document,\n * no id and no bound on how many distinct values exist — the same unbounded\n * key space the analytics collector caps its per-day label map against. A\n * rollup keyed on it would be a map anybody who can vary a query string can\n * grow. So the web channel's records stand on their own and a report reads\n * them; see the module's report seam note.\n *\n * @returns the record written, or `null` when nothing was credited.\n */\nexport async function attributeCampaignConversion(\n options: {\n hostId: string\n kind: CampaignConversionKind\n /** The submission, lead, contact or booking being credited. */\n refId: string\n /** The touch {@link resolveCampaignTouch} picked, or `null` for direct. */\n touch: ResolvedCampaignTouch | null | undefined\n /** When the visitor became identifiable. Defaults to now. */\n convertedAtMs?: number\n },\n firestore?: any,\n): Promise<CampaignConversionRecord | null> {\n try {\n const { touch } = options\n if (!touch) return null\n const hostId = String(options.hostId ?? '')\n const refId = String(options.refId ?? '')\n if (!isDocumentId(hostId) || !isDocumentId(refId)) return null\n const convertedAtMs = Number(options.convertedAtMs ?? Date.now())\n if (!Number.isFinite(convertedAtMs) || convertedAtMs <= 0) return null\n // Re-checked at the write rather than trusted from the resolve. The two\n // are separated by the writes that produced the thing being credited, and\n // a touch that has aged out between them is a touch outside the window.\n if (!emailTouchIsInWindow(touch.touchedAtMs, convertedAtMs)) return null\n\n const record: CampaignConversionRecord = {\n kind: options.kind,\n refId,\n channel: touch.channel,\n ...(touch.campaignId ? { campaignId: touch.campaignId } : {}),\n ...(touch.sequenceId ? { sequenceId: touch.sequenceId } : {}),\n ...(touch.enrollmentId ? { enrollmentId: touch.enrollmentId } : {}),\n ...(touch.source ? { source: touch.source } : {}),\n ...(touch.medium ? { medium: touch.medium } : {}),\n ...(touch.campaign ? { campaign: touch.campaign } : {}),\n touchedAtMs: touch.touchedAtMs,\n convertedAtMs,\n model: ATTRIBUTION_MODEL,\n windowDays: ATTRIBUTION_WINDOW_DAYS,\n ...(touch.personKey ? { personKey: touch.personKey } : {}),\n }\n\n const db = firestore ?? defaultFirestore()\n const hostRef = db.collection('hosts').doc(hostId)\n /*\n * `create()`, never `set()` — the revenue join's argument, unchanged. The\n * ALREADY_EXISTS failure IS the idempotency, and the rollup below is\n * reached only when the create succeeded, so the two can never disagree\n * about whether this conversion was counted.\n */\n try {\n await hostRef\n .collection(CAMPAIGN_ATTRIBUTIONS_COLLECTION)\n .doc(campaignConversionId(options.kind, refId))\n .create({ ...record, createdAt: FieldValue.serverTimestamp() })\n } catch {\n return null\n }\n\n /*\n * The send the touch names (`touch.campaignId` is a SEND id on the email\n * channel), wherever it is: the org's `campaigns` collection, or the\n * site's for a send the migration has not reached. A send in neither\n * place was discarded, and its rollup is skipped — the record above\n * already says what was credited.\n */\n const sendRef =\n touch.channel === 'email' && touch.campaignId && isDocumentId(touch.campaignId)\n ? await resolveCampaignSendRef({ hostId, sendId: touch.campaignId, firestore: db })\n : null\n if (sendRef) {\n /*\n * A merge-set that CREATES, for the reason `reports/revenue` gives: this\n * writes a document UNDER a campaign, so a campaign that no longer\n * exists gains an orphaned report rather than being resurrected as a\n * husk in the merchant's history — and the campaign was proven to exist\n * when its click wrote the touch.\n *\n * Every figure is an increment, so two conversions settling at once\n * both land.\n */\n await sendRef\n .collection('reports')\n .doc(CAMPAIGN_CONVERSIONS_REPORT_DOC)\n .set(\n {\n model: ATTRIBUTION_MODEL,\n windowDays: ATTRIBUTION_WINDOW_DAYS,\n byKind: { [options.kind]: FieldValue.increment(1) },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n }\n\n /*\n * A SEQUENCE touch rolls up under the campaign's sequences report, and\n * only a booking does: \"meetings booked from a sequence link\" is the one\n * identify moment a sequence's own outcomes do not already count. The\n * form, lead and contact kinds stand as records — visible on the record\n * they credit and on the site's conversions list — and never in\n * `byKind`, whose caveat promises the reader campaign emails only.\n */\n if (touch.channel === 'sequence' && touch.campaignId && options.kind === 'booking') {\n await creditCampaignSequenceOutcome(\n { hostId, campaignIds: [touch.campaignId], outcome: 'meetings', atMs: convertedAtMs },\n db,\n )\n }\n\n return record\n } catch (error) {\n console.error('attributeCampaignConversion failed', error)\n return null\n }\n}\n\n/*==========================================\n * WHAT A CAMPAIGN'S SEQUENCES PRODUCED (AGL-3254).\n *\n * A sequence joins a campaign the way a form does, and the Outreach runtime\n * credits what each enrollment produced — enrolled, first email sent, a\n * reply, a meeting booked from a sequence link, an enrolled lead converted\n * — to every campaign the sequence was in when the person was enrolled. The\n * counts live in `orgs/{orgId}/campaignSequenceReports/{campaignId}`, beside\n * the org's containers: one document per campaign, server-only like every\n * other report here, and never inside the campaign container, which the\n * history list reads.\n *\n * Idempotency is the CALLER's: a plugin credits an outcome once per\n * enrollment from a state change that happens once — the enrollment is\n * created once, its first email is the first, its status moves to replied\n * once, its lead converts once — so this writer is a plain increment and\n * keeps no per-enrollment record of its own.\n *=========================================*/\n\n/**\n * The per-org collection of sequence rollups. Restated from the reader\n * (`campaign-report.ts` in the campaigns UI library, which this package may\n * not import) and asserted equal by the reader's spec.\n */\nexport const CAMPAIGN_SEQUENCE_REPORTS_COLLECTION = 'campaignSequenceReports'\n\n/** The outcomes the runtime credits, in funnel order. */\nexport const CAMPAIGN_SEQUENCE_OUTCOMES = [\n 'enrolled',\n 'sent',\n 'replied',\n 'meetings',\n 'converted',\n] as const\n\nexport type CampaignSequenceOutcome = (typeof CAMPAIGN_SEQUENCE_OUTCOMES)[number]\n\n/**\n * Credits one outcome, for one enrollment, to every campaign named.\n *\n * Never throws, and never refuses the batch for one bad id: the outcome\n * has already happened, and a campaign that is gone gains an orphaned\n * report rather than blocking the credit to one that is not — the\n * conversions rollup's own argument for a merge-set that creates.\n *\n * @returns how many campaigns were credited.\n */\nexport async function creditCampaignSequenceOutcome(\n options: {\n /** The site the enrollment emails for; resolves the org when none is given. */\n hostId: string\n /**\n * The org whose containers the campaigns are, when the caller holds it —\n * an enrollment is an org document, so its routes do. Saves the\n * `hostIndex` read.\n */\n orgId?: string | null\n /** The campaigns the enrollment carries; unusable ids are skipped. */\n campaignIds: readonly string[]\n outcome: CampaignSequenceOutcome\n /** When the outcome happened. Defaults to now. */\n atMs?: number\n },\n firestore?: any,\n): Promise<number> {\n try {\n if (!(CAMPAIGN_SEQUENCE_OUTCOMES as readonly string[]).includes(options.outcome)) return 0\n const atMs = Number(options.atMs ?? Date.now())\n const ids = [...new Set(options.campaignIds.map((id) => String(id ?? '').trim()))].filter(\n (id) => isDocumentId(id),\n )\n if (!ids.length) return 0\n const hostId = String(options.hostId ?? '')\n const given = String(options.orgId ?? '')\n const orgId = isDocumentId(given)\n ? given\n : isDocumentId(hostId)\n ? await resolveOrgIdForHost(hostId)\n : null\n if (!orgId || !isDocumentId(orgId)) return 0\n const db = firestore ?? defaultFirestore()\n await Promise.all(\n ids.map((campaignId) =>\n orgCampaignSequenceReportRef(orgId, campaignId, db).set(\n {\n byOutcome: { [options.outcome]: FieldValue.increment(1) },\n ...(Number.isFinite(atMs) && atMs > 0 ? { updatedAtMs: atMs } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n ),\n ),\n )\n return ids.length\n } catch (error) {\n console.error('creditCampaignSequenceOutcome failed', error)\n return 0\n }\n}\n\n/*==========================================\n * WHERE A SEND IS (AGL-3273)\n *=========================================*/\n\n/**\n * WHERE A SEND IS, given only what a link or a delivery event carries.\n *\n * Campaign containers and sends belong to the organization\n * (`orgs/{orgId}/emailCampaigns`, `orgs/{orgId}/campaigns`, AGL-3273). Every\n * reader that starts from inside the product already holds the org id and\n * addresses those collections directly. This module is for the readers that\n * start OUTSIDE it — the unsubscribe link, the provider's delivery webhook,\n * the conversion join — which hold a site id and a send id and nothing else,\n * because that is what was signed into the link or tagged on the message\n * when it went out, possibly years ago.\n *\n * ## The fallback, and why it stays\n *\n * The site path (`hosts/{hostId}/campaigns/{sendId}`) is where every send\n * lived before the move, and the migration copies each one up under its\n * existing id. Between a deployment of this code and the migration reaching\n * an org, the send is still only at the site; and an interrupted migration\n * leaves some sends behind. A reader that looked only at the org would, in\n * that window, drop an unsubscribe's count or a bounce — and a bounce that\n * is dropped is an address that gets mailed again. So the org is asked\n * first and the site second. After the migration the second read never\n * happens, because the first one answers.\n *\n * The collection names are spelled here rather than imported from the\n * campaign model (`@aglyn/shared-ui-email-campaigns`), which this package\n * does not depend on. They are the same strings; `campaign-container.ts`\n * documents them. The join above is this lookup's first reader, and the two\n * move together when the campaign code leaves this package.\n */\n\nconst sendRefFirestore = (): Firestore => firebaseAdmin.app().firestore()\n\n/** `orgs/{orgId}/campaigns` — one document per email send. */\nconst ORG_SENDS = 'campaigns'\n\n/** `orgs/{orgId}/campaignSequenceReports` — keyed by the container id. */\nconst ORG_SEQUENCE_REPORTS = 'campaignSequenceReports'\n\n/** `orgs/{orgId}/campaigns/{sendId}`. */\nexport function orgCampaignSendRef(\n orgId: string,\n sendId: string,\n firestore: Firestore = sendRefFirestore(),\n): DocumentReference {\n return firestore.collection('orgs').doc(orgId).collection(ORG_SENDS).doc(sendId)\n}\n\n/** `orgs/{orgId}/campaignSequenceReports/{campaignId}`. */\nexport function orgCampaignSequenceReportRef(\n orgId: string,\n campaignId: string,\n firestore: Firestore = sendRefFirestore(),\n): DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(ORG_SEQUENCE_REPORTS)\n .doc(campaignId)\n}\n\n/** The pre-AGL-3273 location, read only as the fallback described above. */\nfunction legacySiteSendRef(\n hostId: string,\n sendId: string,\n firestore: Firestore,\n): DocumentReference {\n return firestore.collection('hosts').doc(hostId).collection(ORG_SENDS).doc(sendId)\n}\n\nexport interface ResolveCampaignSendRefInput {\n /** The site the link or the message tag names. */\n hostId: string\n /** The send id (`cid` on a link, `campaignId` on a message tag). */\n sendId: string\n /**\n * The org, when the caller already has it — a message tag minted since\n * AGL-3273 carries it. Saves the `hostIndex` read.\n */\n orgId?: string | null\n firestore?: Firestore\n}\n\n/**\n * The send's document, wherever it currently is. Null when neither location\n * holds it — a send id from a link whose send was discarded, or a forged one.\n *\n * Costs one read after the migration (the org document), plus the\n * `hostIndex` lookup when the caller has no org id; that lookup is\n * request-cached.\n */\nexport async function resolveCampaignSendRef(\n input: ResolveCampaignSendRefInput,\n): Promise<DocumentReference | null> {\n const { hostId, sendId } = input\n if (!hostId || !sendId || sendId.includes('/') || hostId.includes('/')) return null\n const firestore = input.firestore ?? sendRefFirestore()\n const orgId = input.orgId || (await resolveOrgIdForHost(hostId))\n if (orgId) {\n const orgRef = orgCampaignSendRef(orgId, sendId, firestore)\n if ((await orgRef.get()).exists) return orgRef\n }\n const siteRef = legacySiteSendRef(hostId, sendId, firestore)\n if ((await siteRef.get()).exists) return siteRef\n return null\n}\n"],"names":["FieldValue","parseCampaignTouch","EMAIL_ATTRIBUTION_MODEL","ATTRIBUTION_MODEL","EMAIL_ATTRIBUTION_WINDOW_DAYS","ATTRIBUTION_WINDOW_DAYS","emailTouchIsInWindow","personKey","CAMPAIGN_ATTRIBUTIONS_COLLECTION","eraseCampaignAttributionsForPersonKey","readEmailCampaignTouch","isDocumentId","firebaseAdmin","resolveOrgIdForHost","defaultFirestore","app","firestore","CAMPAIGN_CONVERSIONS_REPORT_DOC","campaignConversionId","kind","refId","resolveCampaignTouch","options","hostId","String","convertedAtMs","Number","atMs","Date","now","isFinite","web","wire","key","email","emailTouch","emailInWindow","campaignId","clickedAtMs","emailWins","viaSequence","sequenceId","enrollmentId","channel","touchedAtMs","source","medium","campaign","error","console","attributeCampaignConversion","touch","record","model","windowDays","db","hostRef","collection","doc","create","createdAt","serverTimestamp","sendRef","resolveCampaignSendRef","sendId","set","byKind","increment","updatedAt","merge","creditCampaignSequenceOutcome","campaignIds","outcome","CAMPAIGN_SEQUENCE_REPORTS_COLLECTION","CAMPAIGN_SEQUENCE_OUTCOMES","includes","ids","Set","map","id","trim","filter","length","given","orgId","Promise","all","orgCampaignSequenceReportRef","byOutcome","updatedAtMs","sendRefFirestore","ORG_SENDS","ORG_SEQUENCE_REPORTS","orgCampaignSendRef","legacySiteSendRef","input","orgRef","get","exists","siteRef"],"mappings":"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"}
@@ -16,6 +16,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
16
16
  * limitations under the License.
17
17
  */ import { FieldValue } from "firebase-admin/firestore";
18
18
  import { EMAIL_ATTRIBUTION_MODEL, EMAIL_ATTRIBUTION_WINDOW_DAYS, EMAIL_ATTRIBUTION_WINDOW_MS, emailTouchIsInWindow } from "@aglyn/shared-util-email";
19
+ import { resolveCampaignSendRef } from "./campaign-conversion-attribution.js";
19
20
  import { readEmailCampaignTouch } from "./email-delivery-log.js";
20
21
  import { isDocumentId } from "./document-id.js";
21
22
  import firebaseAdmin from "./firebase-admin.js";
@@ -182,8 +183,19 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
182
183
  * proven to exist a moment ago, when its click wrote the touch.
183
184
  *
184
185
  * Every amount is an increment, so two orders settling at once both land.
186
+ *
187
+ * The send is found where it is — the org's `campaigns`, or the site's
188
+ * for a send the migration has not reached — and a send in neither
189
+ * place was discarded: the record above stands, and no report is
190
+ * conjured under nothing.
185
191
  */ const currency = record.currency;
186
- await hostRef.collection('campaigns').doc(touch.campaignId).collection('reports').doc(CAMPAIGN_REVENUE_REPORT_DOC).set({
192
+ const sendRef = await resolveCampaignSendRef({
193
+ hostId,
194
+ sendId: touch.campaignId,
195
+ firestore: db
196
+ });
197
+ if (!sendRef) return record;
198
+ await sendRef.collection('reports').doc(CAMPAIGN_REVENUE_REPORT_DOC).set({
187
199
  model: EMAIL_ATTRIBUTION_MODEL,
188
200
  windowDays: EMAIL_ATTRIBUTION_WINDOW_DAYS,
189
201
  byCurrency: {
@@ -261,7 +273,16 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
261
273
  * — which happens when the credit was the charge and the refund includes
262
274
  * something the credit did not. The stored pair keeps both true figures
263
275
  * and the reader clamps the net it prints.
264
- */ await hostRef.collection('campaigns').doc(campaignId).collection('reports').doc(CAMPAIGN_REVENUE_REPORT_DOC).set({
276
+ *
277
+ * The send is found where it is, as the credit found it; a send that
278
+ * has since been discarded has no rollup left to reverse, and the
279
+ * record below still takes the refund.
280
+ */ const sendRef = await resolveCampaignSendRef({
281
+ hostId,
282
+ sendId: campaignId,
283
+ firestore: db
284
+ });
285
+ await (sendRef == null ? void 0 : sendRef.collection('reports').doc(CAMPAIGN_REVENUE_REPORT_DOC).set({
265
286
  byCurrency: {
266
287
  [currency]: _extends({
267
288
  refundedCents: FieldValue.increment(amountCents)
@@ -272,7 +293,7 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
272
293
  updatedAt: FieldValue.serverTimestamp()
273
294
  }, {
274
295
  merge: true
275
- });
296
+ }));
276
297
  /*
277
298
  * Stamped on the record as well as counted in the rollup. The rollup is a
278
299
  * sum and cannot say WHICH orders came back; this is the per-order half
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-revenue-attribution.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 { FieldValue } from 'firebase-admin/firestore'\nimport {\n EMAIL_ATTRIBUTION_MODEL,\n EMAIL_ATTRIBUTION_WINDOW_DAYS,\n EMAIL_ATTRIBUTION_WINDOW_MS,\n emailTouchIsInWindow,\n} from '@aglyn/shared-util-email'\nimport { readEmailCampaignTouch } from './email-delivery-log'\nimport { isDocumentId } from './document-id'\nimport firebaseAdmin from './firebase-admin'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\n/**\n * THE COMMERCE↔EMAIL JOIN — an order, credited to the campaign that led to it.\n *\n * ## Why this is a join\n *\n * Every compared ESP attributes revenue through an integration into a store\n * it does not own: a catalog sync, an on-site tracking snippet, an identity\n * graph, and a window wide enough to absorb what the reconciliation misses.\n * Commerce here is first-party and runs on the merchant's own Stripe Connect\n * account, so the click and the order are two rows in one database keyed the\n * same way. The window is not a fudge factor here — it is the model, and\n * nothing else about the number is estimated.\n *\n * ## Three writes, and what each one is for\n *\n * - **The touch**, `emailDeliveries/{personKey}.campaignTouches[hostId]`,\n * written by the delivery webhook on a click. Owned by\n * `email-delivery-log.ts`, because it lives on the person's document and\n * the erasure path has to be able to remove it.\n * - **The attribution record**, `hosts/{hostId}/emailAttributions/{orderId}`,\n * written here when an order is credited. It is the audit trail — which\n * campaign, which click, which model, which window — and it is what the\n * refund path reads to find out which campaign to take the money back off.\n * - **The rollup**, `campaigns/{campaignId}/reports/revenue`, incremented\n * here. One document per campaign, so the report reads it whole.\n *\n * ## Why the record is a document and not a field on the order\n *\n * Three reasons, and the first is decisive. `create()` fails when the\n * document already exists, which is exact idempotency for free: webhook\n * delivery is at-least-once, and a redelivered purchase must not be able to\n * credit a campaign twice. Second, not every purchase door writes an order —\n * a booking is a sale with no order document — and a field would have had to\n * either skip those or conjure the document, and conjuring is the\n * phantom-document shape this codebase spent a sweep removing. Third, the\n * order document is read by the console list, the fulfilment path, the\n * supplier outbox and the CSV export, and none of them wants a field about\n * email.\n *\n * ## Never throws\n *\n * Same contract as `upsertHostContact`, which calls it, and for the same\n * reason: the money has already moved and the order already records it, so\n * nothing here may fail a sale. A lost attribution understates a campaign; a\n * thrown one loses a checkout.\n */\n\n/** The per-host collection of attribution records. */\nexport const EMAIL_ATTRIBUTIONS_COLLECTION = 'emailAttributions'\n\n/** The single rollup document under a campaign. */\nexport const CAMPAIGN_REVENUE_REPORT_DOC = 'revenue'\n\n/**\n * The currency an amount is recorded under when the caller does not say.\n *\n * Every checkout door in this repo — cart, buy-now, POS, draft orders,\n * reservations, subscriptions — writes `currency: 'usd'` onto the Stripe line\n * items, and no order document carries a currency field to read back. So this\n * is a statement about what the code charges rather than a guess about the\n * money. The parameter exists so a door that ever charges in something else\n * says so and lands in its own bucket, because the one thing the report may\n * never do is add two currencies together.\n */\nexport const DEFAULT_ATTRIBUTION_CURRENCY = 'usd'\n\n/** What one attribution record holds. */\nexport interface EmailAttributionRecord {\n campaignId: string\n /** When the credited click happened. */\n clickedAtMs: number\n /** When the order was placed. */\n orderedAtMs: number\n /** Minor units credited, gross — the amount the buyer was charged. */\n amountCents: number\n /** Lowercase currency code the amount is in. */\n currency: string\n /** The model this credit was decided under. */\n model: string\n /** The window, in days, it was decided inside. */\n windowDays: number\n}\n\n/** Normalizes a currency code to the key its bucket is stored under. */\nfunction currencyKey(raw: unknown): string {\n const code = String(raw ?? '')\n .trim()\n .toLowerCase()\n // Letters only: the code becomes a MAP KEY, and a key carrying a dot would\n // be unreachable by any dotted field path a later reader wants to use.\n return /^[a-z]{3}$/.test(code) ? code : DEFAULT_ATTRIBUTION_CURRENCY\n}\n\n/** A positive integer number of minor units, or 0. */\nfunction minorUnits(raw: unknown): number {\n const value = Math.round(Number(raw))\n return Number.isFinite(value) && value > 0 ? value : 0\n}\n\n/**\n * Credits one order to the campaign whose link the buyer last clicked.\n *\n * ## What happens when there is nobody to credit\n *\n * Each case answers `null` and writes NOTHING, which is the whole design:\n *\n * - **No email on the order.** A guest checkout that never identified its\n * buyer cannot be joined to anybody's clicks. There is no fallback and\n * there deliberately is not one — the alternatives are an IP or a device\n * guess, which is the probabilistic attribution owning the checkout exists\n * to avoid.\n * - **A guest with an email but no contact record.** This is ATTRIBUTED\n * normally. The join keys on the address hash, exactly as the touch and\n * the suppression list do, so it never asks whether a contact document\n * exists — which matters, because contact creation is audience-band gated\n * and a Free org's dropped contact would otherwise silently drop the\n * revenue with it.\n * - **No touch, or a touch on another site.** Nobody clicked, so nobody is\n * credited. The touch map is keyed by host and the send path refuses\n * cross-site reach; the revenue join agrees with it.\n * - **A touch outside the window**, in either direction. See\n * {@link emailTouchIsInWindow} — a click AFTER the order is the receipt,\n * not the cause.\n *\n * None of these is counted anywhere. A miss costs no write, which is what\n * keeps the ordinary order — placed by somebody who is not on the mailing\n * list at all — at exactly one document read. What it means for the report is\n * stated on the report: the figure counts orders it could join, and is a\n * floor.\n *\n * @returns the record written, or `null` when nothing was credited.\n */\nexport async function attributeOrderToEmail(\n options: {\n hostId: string\n /** The order, booking or invoice the money came in on. */\n orderId: string\n /** The buyer as the sale recorded them, raw — normalized downstream. */\n email: unknown\n /** Gross minor units the buyer was charged. */\n amountCents: number\n /** Lowercase currency code, when the door knows one. */\n currency?: string\n /** When the order was placed. Defaults to now. */\n orderedAtMs?: number\n },\n firestore?: any,\n): Promise<EmailAttributionRecord | null> {\n try {\n const hostId = String(options.hostId ?? '')\n const orderId = String(options.orderId ?? '')\n const amountCents = minorUnits(options.amountCents)\n if (!isDocumentId(hostId) || !isDocumentId(orderId)) return null\n if (!amountCents) return null\n\n const orderedAtMs = Number(options.orderedAtMs ?? Date.now())\n const db = firestore ?? defaultFirestore()\n\n /*\n * `String()` on an `unknown`, then let `emailSuppressionKey` decide.\n *\n * The buyer's address arrives as the ORDER recorded it, which is not\n * consistently a string — the same rawness `recordContactRefund` takes\n * `email: unknown` for. The key derivation is the one place that knows\n * how an address becomes a document id, so nothing here tries to\n * pre-clean it; a value that is not an address answers `null` there and\n * the order is credited to nobody.\n */\n const rawEmail =\n typeof options.email === 'string' ? options.email : String(options.email ?? '')\n const touch = await readEmailCampaignTouch(rawEmail, hostId, db)\n if (!touch) return null\n if (!isDocumentId(touch.campaignId)) return null\n if (!emailTouchIsInWindow(touch.clickedAtMs, orderedAtMs)) return null\n\n const record: EmailAttributionRecord = {\n campaignId: touch.campaignId,\n clickedAtMs: touch.clickedAtMs,\n orderedAtMs,\n amountCents,\n currency: currencyKey(options.currency),\n model: EMAIL_ATTRIBUTION_MODEL,\n windowDays: EMAIL_ATTRIBUTION_WINDOW_DAYS,\n }\n\n const hostRef = db.collection('hosts').doc(hostId)\n /*\n * `create()`, never `set()`. It fails with ALREADY_EXISTS when this order\n * has been credited before, and that failure is the idempotency: a\n * webhook redelivery, a retried checkout completion and a replayed event\n * all land here a second time, and all three must leave the rollup where\n * they found it. The increment below is reached only when the create\n * succeeded, so the two can never disagree about whether this order was\n * counted.\n */\n try {\n await hostRef\n .collection(EMAIL_ATTRIBUTIONS_COLLECTION)\n .doc(orderId)\n .create({ ...record, createdAt: FieldValue.serverTimestamp() })\n } catch {\n return null\n }\n\n /*\n * A merge-set that CREATES, unlike the campaign counters the delivery\n * webhook writes through `updateExisting`.\n *\n * The distinction is which document is being conjured. Those counters\n * refuse to create because a merge-set against a DELETED CAMPAIGN\n * resurrects the campaign itself, as a husk holding a stats map and no\n * subject. This writes a subcollection document UNDER a campaign, so a\n * campaign that no longer exists gains an orphaned report rather than\n * coming back to life in the merchant's history — and the campaign was\n * proven to exist a moment ago, when its click wrote the touch.\n *\n * Every amount is an increment, so two orders settling at once both land.\n */\n const currency = record.currency\n await hostRef\n .collection('campaigns')\n .doc(touch.campaignId)\n .collection('reports')\n .doc(CAMPAIGN_REVENUE_REPORT_DOC)\n .set(\n {\n model: EMAIL_ATTRIBUTION_MODEL,\n windowDays: EMAIL_ATTRIBUTION_WINDOW_DAYS,\n byCurrency: {\n [currency]: {\n grossCents: FieldValue.increment(amountCents),\n orders: FieldValue.increment(1),\n },\n },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n return record\n } catch (error) {\n console.error('attributeOrderToEmail failed', error)\n return null\n }\n}\n\n/**\n * Takes back revenue a campaign was credited with, when it is refunded.\n *\n * ## Recorded beside the gross, never subtracted from it\n *\n * `grossCents` is left exactly as it is and the reversal lands in\n * `refundedCents` next to it. This is the shape `contact-refund.ts` chose for\n * `ltvCents`/`refundedCents` and the orders CSV chose for\n * `amountUsd`/`refundedUsd`, and it is chosen a third time here so all three\n * answer \"what did this earn, net\" identically. Decrementing would make a\n * stored number mean one thing for rollups written before a refund and\n * another after, with nothing on the document to tell them apart — and would\n * put the question \"can it go negative\" onto storage, where the answer cannot\n * be clamped without destroying evidence. Both stored figures are monotonic\n * counters of money that really moved in one direction; the derived net is\n * clamped at the point of display and nowhere else.\n *\n * ## The currency comes from the RECORD, not from the caller\n *\n * A refund reverses a specific sale, and the bucket it comes out of has to be\n * the bucket it went into. Reading the currency back off the attribution\n * record is what guarantees that, and it is the second thing the record is\n * for.\n *\n * ## Chargebacks come through here too\n *\n * Money reversed is money reversed, whichever door it left by, so a lost\n * dispute reverses the credit the same way a refund does. `kind` exists for\n * the caller's clarity and changes nothing about the arithmetic — the same\n * choice `recordContactRefund` made one field along.\n *\n * @returns whether a reversal was recorded.\n */\nexport async function reverseEmailAttributedRevenue(\n options: {\n hostId: string\n orderId: string\n /** Minor units reversed by THIS attempt, never the order total. */\n amountCents: number\n /** True only for the write that moved the order into `refunded`. */\n closedTheOrder: boolean\n kind?: 'refund' | 'chargeback'\n },\n firestore?: any,\n): Promise<boolean> {\n try {\n const hostId = String(options.hostId ?? '')\n const orderId = String(options.orderId ?? '')\n const amountCents = minorUnits(options.amountCents)\n if (!isDocumentId(hostId) || !isDocumentId(orderId)) return false\n if (!amountCents) return false\n\n const db = firestore ?? defaultFirestore()\n const hostRef = db.collection('hosts').doc(hostId)\n const snapshot = await hostRef\n .collection(EMAIL_ATTRIBUTIONS_COLLECTION)\n .doc(orderId)\n .get()\n // The ordinary case, and not an error: most orders were never credited to\n // a campaign, so most refunds have nothing to reverse.\n if (!snapshot.exists) return false\n\n const record = snapshot.data() ?? {}\n const campaignId = String(record.campaignId ?? '')\n if (!isDocumentId(campaignId)) return false\n const currency = currencyKey(record.currency)\n\n /*\n * NOT capped against what was credited, and that is deliberate. The\n * caller already caps each attempt against what is left on the ORDER, so\n * several partials sum to at most the order total; capping again here\n * against the attributed amount would silently discard the reversal of an\n * order refunded for more than the amount the campaign was credited with\n * — which happens when the credit was the charge and the refund includes\n * something the credit did not. The stored pair keeps both true figures\n * and the reader clamps the net it prints.\n */\n await hostRef\n .collection('campaigns')\n .doc(campaignId)\n .collection('reports')\n .doc(CAMPAIGN_REVENUE_REPORT_DOC)\n .set(\n {\n byCurrency: {\n [currency]: {\n refundedCents: FieldValue.increment(amountCents),\n ...(options.closedTheOrder\n ? { refundedOrders: FieldValue.increment(1) }\n : {}),\n },\n },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n /*\n * Stamped on the record as well as counted in the rollup. The rollup is a\n * sum and cannot say WHICH orders came back; this is the per-order half\n * of the same fact, and it is what a merchant asking \"why did this\n * campaign's revenue drop\" is eventually going to need. Increments, so\n * two partials on one order both land.\n */\n await snapshot.ref\n .set(\n {\n refundedCents: FieldValue.increment(amountCents),\n lastRefundAtMs: Date.now(),\n ...(options.kind === 'chargeback' ? { chargedBack: true } : {}),\n ...(options.closedTheOrder ? { fullyRefunded: true } : {}),\n },\n { merge: true },\n )\n .catch(() => undefined)\n\n return true\n } catch (error) {\n console.error('reverseEmailAttributedRevenue failed', error)\n return false\n }\n}\n\n/** The window, re-exported so a caller needs one import for the whole join. */\nexport { EMAIL_ATTRIBUTION_WINDOW_MS }\n"],"names":["FieldValue","EMAIL_ATTRIBUTION_MODEL","EMAIL_ATTRIBUTION_WINDOW_DAYS","EMAIL_ATTRIBUTION_WINDOW_MS","emailTouchIsInWindow","readEmailCampaignTouch","isDocumentId","firebaseAdmin","defaultFirestore","app","firestore","EMAIL_ATTRIBUTIONS_COLLECTION","CAMPAIGN_REVENUE_REPORT_DOC","DEFAULT_ATTRIBUTION_CURRENCY","currencyKey","raw","code","String","trim","toLowerCase","test","minorUnits","value","Math","round","Number","isFinite","attributeOrderToEmail","options","hostId","orderId","amountCents","orderedAtMs","Date","now","db","rawEmail","email","touch","campaignId","clickedAtMs","record","currency","model","windowDays","hostRef","collection","doc","create","createdAt","serverTimestamp","set","byCurrency","grossCents","increment","orders","updatedAt","merge","error","console","reverseEmailAttributedRevenue","snapshot","get","exists","data","refundedCents","closedTheOrder","refundedOrders","ref","lastRefundAtMs","kind","chargedBack","fullyRefunded","catch","undefined"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-revenue-attribution.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 { FieldValue } from 'firebase-admin/firestore'\nimport {\n EMAIL_ATTRIBUTION_MODEL,\n EMAIL_ATTRIBUTION_WINDOW_DAYS,\n EMAIL_ATTRIBUTION_WINDOW_MS,\n emailTouchIsInWindow,\n} from '@aglyn/shared-util-email'\nimport { resolveCampaignSendRef } from './campaign-conversion-attribution'\nimport { readEmailCampaignTouch } from './email-delivery-log'\nimport { isDocumentId } from './document-id'\nimport firebaseAdmin from './firebase-admin'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\n/**\n * THE COMMERCE↔EMAIL JOIN — an order, credited to the campaign that led to it.\n *\n * ## Why this is a join\n *\n * Every compared ESP attributes revenue through an integration into a store\n * it does not own: a catalog sync, an on-site tracking snippet, an identity\n * graph, and a window wide enough to absorb what the reconciliation misses.\n * Commerce here is first-party and runs on the merchant's own Stripe Connect\n * account, so the click and the order are two rows in one database keyed the\n * same way. The window is not a fudge factor here — it is the model, and\n * nothing else about the number is estimated.\n *\n * ## Three writes, and what each one is for\n *\n * - **The touch**, `emailDeliveries/{personKey}.campaignTouches[hostId]`,\n * written by the delivery webhook on a click. Owned by\n * `email-delivery-log.ts`, because it lives on the person's document and\n * the erasure path has to be able to remove it.\n * - **The attribution record**, `hosts/{hostId}/emailAttributions/{orderId}`,\n * written here when an order is credited. It is the audit trail — which\n * campaign, which click, which model, which window — and it is what the\n * refund path reads to find out which campaign to take the money back off.\n * - **The rollup**, `campaigns/{campaignId}/reports/revenue`, incremented\n * here. One document per campaign, so the report reads it whole.\n *\n * ## Why the record is a document and not a field on the order\n *\n * Three reasons, and the first is decisive. `create()` fails when the\n * document already exists, which is exact idempotency for free: webhook\n * delivery is at-least-once, and a redelivered purchase must not be able to\n * credit a campaign twice. Second, not every purchase door writes an order —\n * a booking is a sale with no order document — and a field would have had to\n * either skip those or conjure the document, and conjuring is the\n * phantom-document shape this codebase spent a sweep removing. Third, the\n * order document is read by the console list, the fulfilment path, the\n * supplier outbox and the CSV export, and none of them wants a field about\n * email.\n *\n * ## Never throws\n *\n * Same contract as `upsertHostContact`, which calls it, and for the same\n * reason: the money has already moved and the order already records it, so\n * nothing here may fail a sale. A lost attribution understates a campaign; a\n * thrown one loses a checkout.\n */\n\n/** The per-host collection of attribution records. */\nexport const EMAIL_ATTRIBUTIONS_COLLECTION = 'emailAttributions'\n\n/** The single rollup document under a campaign. */\nexport const CAMPAIGN_REVENUE_REPORT_DOC = 'revenue'\n\n/**\n * The currency an amount is recorded under when the caller does not say.\n *\n * Every checkout door in this repo — cart, buy-now, POS, draft orders,\n * reservations, subscriptions — writes `currency: 'usd'` onto the Stripe line\n * items, and no order document carries a currency field to read back. So this\n * is a statement about what the code charges rather than a guess about the\n * money. The parameter exists so a door that ever charges in something else\n * says so and lands in its own bucket, because the one thing the report may\n * never do is add two currencies together.\n */\nexport const DEFAULT_ATTRIBUTION_CURRENCY = 'usd'\n\n/** What one attribution record holds. */\nexport interface EmailAttributionRecord {\n campaignId: string\n /** When the credited click happened. */\n clickedAtMs: number\n /** When the order was placed. */\n orderedAtMs: number\n /** Minor units credited, gross — the amount the buyer was charged. */\n amountCents: number\n /** Lowercase currency code the amount is in. */\n currency: string\n /** The model this credit was decided under. */\n model: string\n /** The window, in days, it was decided inside. */\n windowDays: number\n}\n\n/** Normalizes a currency code to the key its bucket is stored under. */\nfunction currencyKey(raw: unknown): string {\n const code = String(raw ?? '')\n .trim()\n .toLowerCase()\n // Letters only: the code becomes a MAP KEY, and a key carrying a dot would\n // be unreachable by any dotted field path a later reader wants to use.\n return /^[a-z]{3}$/.test(code) ? code : DEFAULT_ATTRIBUTION_CURRENCY\n}\n\n/** A positive integer number of minor units, or 0. */\nfunction minorUnits(raw: unknown): number {\n const value = Math.round(Number(raw))\n return Number.isFinite(value) && value > 0 ? value : 0\n}\n\n/**\n * Credits one order to the campaign whose link the buyer last clicked.\n *\n * ## What happens when there is nobody to credit\n *\n * Each case answers `null` and writes NOTHING, which is the whole design:\n *\n * - **No email on the order.** A guest checkout that never identified its\n * buyer cannot be joined to anybody's clicks. There is no fallback and\n * there deliberately is not one — the alternatives are an IP or a device\n * guess, which is the probabilistic attribution owning the checkout exists\n * to avoid.\n * - **A guest with an email but no contact record.** This is ATTRIBUTED\n * normally. The join keys on the address hash, exactly as the touch and\n * the suppression list do, so it never asks whether a contact document\n * exists — which matters, because contact creation is audience-band gated\n * and a Free org's dropped contact would otherwise silently drop the\n * revenue with it.\n * - **No touch, or a touch on another site.** Nobody clicked, so nobody is\n * credited. The touch map is keyed by host and the send path refuses\n * cross-site reach; the revenue join agrees with it.\n * - **A touch outside the window**, in either direction. See\n * {@link emailTouchIsInWindow} — a click AFTER the order is the receipt,\n * not the cause.\n *\n * None of these is counted anywhere. A miss costs no write, which is what\n * keeps the ordinary order — placed by somebody who is not on the mailing\n * list at all — at exactly one document read. What it means for the report is\n * stated on the report: the figure counts orders it could join, and is a\n * floor.\n *\n * @returns the record written, or `null` when nothing was credited.\n */\nexport async function attributeOrderToEmail(\n options: {\n hostId: string\n /** The order, booking or invoice the money came in on. */\n orderId: string\n /** The buyer as the sale recorded them, raw — normalized downstream. */\n email: unknown\n /** Gross minor units the buyer was charged. */\n amountCents: number\n /** Lowercase currency code, when the door knows one. */\n currency?: string\n /** When the order was placed. Defaults to now. */\n orderedAtMs?: number\n },\n firestore?: any,\n): Promise<EmailAttributionRecord | null> {\n try {\n const hostId = String(options.hostId ?? '')\n const orderId = String(options.orderId ?? '')\n const amountCents = minorUnits(options.amountCents)\n if (!isDocumentId(hostId) || !isDocumentId(orderId)) return null\n if (!amountCents) return null\n\n const orderedAtMs = Number(options.orderedAtMs ?? Date.now())\n const db = firestore ?? defaultFirestore()\n\n /*\n * `String()` on an `unknown`, then let `emailSuppressionKey` decide.\n *\n * The buyer's address arrives as the ORDER recorded it, which is not\n * consistently a string — the same rawness `recordContactRefund` takes\n * `email: unknown` for. The key derivation is the one place that knows\n * how an address becomes a document id, so nothing here tries to\n * pre-clean it; a value that is not an address answers `null` there and\n * the order is credited to nobody.\n */\n const rawEmail =\n typeof options.email === 'string' ? options.email : String(options.email ?? '')\n const touch = await readEmailCampaignTouch(rawEmail, hostId, db)\n if (!touch) return null\n if (!isDocumentId(touch.campaignId)) return null\n if (!emailTouchIsInWindow(touch.clickedAtMs, orderedAtMs)) return null\n\n const record: EmailAttributionRecord = {\n campaignId: touch.campaignId,\n clickedAtMs: touch.clickedAtMs,\n orderedAtMs,\n amountCents,\n currency: currencyKey(options.currency),\n model: EMAIL_ATTRIBUTION_MODEL,\n windowDays: EMAIL_ATTRIBUTION_WINDOW_DAYS,\n }\n\n const hostRef = db.collection('hosts').doc(hostId)\n /*\n * `create()`, never `set()`. It fails with ALREADY_EXISTS when this order\n * has been credited before, and that failure is the idempotency: a\n * webhook redelivery, a retried checkout completion and a replayed event\n * all land here a second time, and all three must leave the rollup where\n * they found it. The increment below is reached only when the create\n * succeeded, so the two can never disagree about whether this order was\n * counted.\n */\n try {\n await hostRef\n .collection(EMAIL_ATTRIBUTIONS_COLLECTION)\n .doc(orderId)\n .create({ ...record, createdAt: FieldValue.serverTimestamp() })\n } catch {\n return null\n }\n\n /*\n * A merge-set that CREATES, unlike the campaign counters the delivery\n * webhook writes through `updateExisting`.\n *\n * The distinction is which document is being conjured. Those counters\n * refuse to create because a merge-set against a DELETED CAMPAIGN\n * resurrects the campaign itself, as a husk holding a stats map and no\n * subject. This writes a subcollection document UNDER a campaign, so a\n * campaign that no longer exists gains an orphaned report rather than\n * coming back to life in the merchant's history — and the campaign was\n * proven to exist a moment ago, when its click wrote the touch.\n *\n * Every amount is an increment, so two orders settling at once both land.\n *\n * The send is found where it is — the org's `campaigns`, or the site's\n * for a send the migration has not reached — and a send in neither\n * place was discarded: the record above stands, and no report is\n * conjured under nothing.\n */\n const currency = record.currency\n const sendRef = await resolveCampaignSendRef({ hostId, sendId: touch.campaignId, firestore: db })\n if (!sendRef) return record\n await sendRef\n .collection('reports')\n .doc(CAMPAIGN_REVENUE_REPORT_DOC)\n .set(\n {\n model: EMAIL_ATTRIBUTION_MODEL,\n windowDays: EMAIL_ATTRIBUTION_WINDOW_DAYS,\n byCurrency: {\n [currency]: {\n grossCents: FieldValue.increment(amountCents),\n orders: FieldValue.increment(1),\n },\n },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n return record\n } catch (error) {\n console.error('attributeOrderToEmail failed', error)\n return null\n }\n}\n\n/**\n * Takes back revenue a campaign was credited with, when it is refunded.\n *\n * ## Recorded beside the gross, never subtracted from it\n *\n * `grossCents` is left exactly as it is and the reversal lands in\n * `refundedCents` next to it. This is the shape `contact-refund.ts` chose for\n * `ltvCents`/`refundedCents` and the orders CSV chose for\n * `amountUsd`/`refundedUsd`, and it is chosen a third time here so all three\n * answer \"what did this earn, net\" identically. Decrementing would make a\n * stored number mean one thing for rollups written before a refund and\n * another after, with nothing on the document to tell them apart — and would\n * put the question \"can it go negative\" onto storage, where the answer cannot\n * be clamped without destroying evidence. Both stored figures are monotonic\n * counters of money that really moved in one direction; the derived net is\n * clamped at the point of display and nowhere else.\n *\n * ## The currency comes from the RECORD, not from the caller\n *\n * A refund reverses a specific sale, and the bucket it comes out of has to be\n * the bucket it went into. Reading the currency back off the attribution\n * record is what guarantees that, and it is the second thing the record is\n * for.\n *\n * ## Chargebacks come through here too\n *\n * Money reversed is money reversed, whichever door it left by, so a lost\n * dispute reverses the credit the same way a refund does. `kind` exists for\n * the caller's clarity and changes nothing about the arithmetic — the same\n * choice `recordContactRefund` made one field along.\n *\n * @returns whether a reversal was recorded.\n */\nexport async function reverseEmailAttributedRevenue(\n options: {\n hostId: string\n orderId: string\n /** Minor units reversed by THIS attempt, never the order total. */\n amountCents: number\n /** True only for the write that moved the order into `refunded`. */\n closedTheOrder: boolean\n kind?: 'refund' | 'chargeback'\n },\n firestore?: any,\n): Promise<boolean> {\n try {\n const hostId = String(options.hostId ?? '')\n const orderId = String(options.orderId ?? '')\n const amountCents = minorUnits(options.amountCents)\n if (!isDocumentId(hostId) || !isDocumentId(orderId)) return false\n if (!amountCents) return false\n\n const db = firestore ?? defaultFirestore()\n const hostRef = db.collection('hosts').doc(hostId)\n const snapshot = await hostRef\n .collection(EMAIL_ATTRIBUTIONS_COLLECTION)\n .doc(orderId)\n .get()\n // The ordinary case, and not an error: most orders were never credited to\n // a campaign, so most refunds have nothing to reverse.\n if (!snapshot.exists) return false\n\n const record = snapshot.data() ?? {}\n const campaignId = String(record.campaignId ?? '')\n if (!isDocumentId(campaignId)) return false\n const currency = currencyKey(record.currency)\n\n /*\n * NOT capped against what was credited, and that is deliberate. The\n * caller already caps each attempt against what is left on the ORDER, so\n * several partials sum to at most the order total; capping again here\n * against the attributed amount would silently discard the reversal of an\n * order refunded for more than the amount the campaign was credited with\n * — which happens when the credit was the charge and the refund includes\n * something the credit did not. The stored pair keeps both true figures\n * and the reader clamps the net it prints.\n *\n * The send is found where it is, as the credit found it; a send that\n * has since been discarded has no rollup left to reverse, and the\n * record below still takes the refund.\n */\n const sendRef = await resolveCampaignSendRef({ hostId, sendId: campaignId, firestore: db })\n await sendRef\n ?.collection('reports')\n .doc(CAMPAIGN_REVENUE_REPORT_DOC)\n .set(\n {\n byCurrency: {\n [currency]: {\n refundedCents: FieldValue.increment(amountCents),\n ...(options.closedTheOrder\n ? { refundedOrders: FieldValue.increment(1) }\n : {}),\n },\n },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n /*\n * Stamped on the record as well as counted in the rollup. The rollup is a\n * sum and cannot say WHICH orders came back; this is the per-order half\n * of the same fact, and it is what a merchant asking \"why did this\n * campaign's revenue drop\" is eventually going to need. Increments, so\n * two partials on one order both land.\n */\n await snapshot.ref\n .set(\n {\n refundedCents: FieldValue.increment(amountCents),\n lastRefundAtMs: Date.now(),\n ...(options.kind === 'chargeback' ? { chargedBack: true } : {}),\n ...(options.closedTheOrder ? { fullyRefunded: true } : {}),\n },\n { merge: true },\n )\n .catch(() => undefined)\n\n return true\n } catch (error) {\n console.error('reverseEmailAttributedRevenue failed', error)\n return false\n }\n}\n\n/** The window, re-exported so a caller needs one import for the whole join. */\nexport { EMAIL_ATTRIBUTION_WINDOW_MS }\n"],"names":["FieldValue","EMAIL_ATTRIBUTION_MODEL","EMAIL_ATTRIBUTION_WINDOW_DAYS","EMAIL_ATTRIBUTION_WINDOW_MS","emailTouchIsInWindow","resolveCampaignSendRef","readEmailCampaignTouch","isDocumentId","firebaseAdmin","defaultFirestore","app","firestore","EMAIL_ATTRIBUTIONS_COLLECTION","CAMPAIGN_REVENUE_REPORT_DOC","DEFAULT_ATTRIBUTION_CURRENCY","currencyKey","raw","code","String","trim","toLowerCase","test","minorUnits","value","Math","round","Number","isFinite","attributeOrderToEmail","options","hostId","orderId","amountCents","orderedAtMs","Date","now","db","rawEmail","email","touch","campaignId","clickedAtMs","record","currency","model","windowDays","hostRef","collection","doc","create","createdAt","serverTimestamp","sendRef","sendId","set","byCurrency","grossCents","increment","orders","updatedAt","merge","error","console","reverseEmailAttributedRevenue","snapshot","get","exists","data","refundedCents","closedTheOrder","refundedOrders","ref","lastRefundAtMs","kind","chargedBack","fullyRefunded","catch","undefined"],"mappings":"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"}
@@ -28,6 +28,7 @@ import { removeOrgMember } from "./organizations.js";
28
28
  import { runPluginUserErasers } from "@aglyn/aglyn/plugin-manager/plugin-user-erasure";
29
29
  import { runPluginOrgErasers } from "@aglyn/aglyn/plugin-manager/plugin-org-erasure";
30
30
  import { isBillingSubscription } from "@aglyn/aglyn/server";
31
+ import { listPluginOrgCollections } from "@aglyn/aglyn/plugin-manager/plugin-host-collections";
31
32
  import { readOrgBilling } from "./org-billing.js";
32
33
  import { disposeHostSendingDomain, readSendingDomainTeardownByLabel } from "./sending-domain-debt.js";
33
34
  /** The reversible hold before a requested erasure is executed (AGL-485). */ export const ERASURE_HOLD_MS = 7 * 24 * 60 * 60 * 1000;
@@ -120,6 +121,42 @@ import { disposeHostSendingDomain, readSendingDomainTeardownByLabel } from "./se
120
121
  return 0;
121
122
  }
122
123
  }
124
+ /**
125
+ * Destroy the org documents that belong to the site (AGL-3273).
126
+ *
127
+ * Some plugin storage is the organization's and names the site each document
128
+ * belongs to — Marketing's email sends, `orgs/{orgId}/campaigns`, each naming
129
+ * the site it was sent as. The `recursiveDelete(hosts/{hostId})` below does
130
+ * not reach them, and they are still the site's: a send's sender, unsubscribe
131
+ * page and the consent it relied on were that site's, and a send addressed to
132
+ * a hand-typed list carries those addresses.
133
+ *
134
+ * Which collections, and which field names the site, is each plugin's own
135
+ * declaration (`orgCollections` in `plugins.config.json`, read through
136
+ * `listPluginOrgCollections`): a declaration with a `siteField` is a
137
+ * collection whose documents belong to one site. One without — a campaign
138
+ * container, placed on sites rather than owned by one — is left alone. Each
139
+ * document goes with its subcollections, which is why this is a recursive
140
+ * delete per document.
141
+ *
142
+ * Best-effort, like every other trailing cleanup in `eraseHost`, and per
143
+ * collection, so one failed query leaves the others' cleanup standing.
144
+ */ async function eraseHostOwnedOrgDocuments(orgId, hostId) {
145
+ if (!orgId || !hostId) return 0;
146
+ const firestore = firebaseAdmin.app().firestore();
147
+ let erased = 0;
148
+ for (const declared of listPluginOrgCollections()){
149
+ if (!declared.siteField) continue;
150
+ try {
151
+ const rows = await firestore.collection('orgs').doc(orgId).collection(declared.name).where(declared.siteField, '==', hostId).get();
152
+ for (const doc of rows.docs)await firestore.recursiveDelete(doc.ref);
153
+ erased += rows.size;
154
+ } catch (error) {
155
+ console.error(`eraseHost: ${declared.name} cleanup failed for ${hostId}`, error);
156
+ }
157
+ }
158
+ return erased;
159
+ }
123
160
  export async function eraseHost(hostId, options = {}) {
124
161
  var _hostSnapshot_get;
125
162
  const firestore = firebaseAdmin.app().firestore();
@@ -191,6 +228,7 @@ export async function eraseHost(hostId, options = {}) {
191
228
  // Drop every member's reverse-index row for this host (AGL-844); the
192
229
  // members still exist here (recursiveDelete of the org, if any, is later).
193
230
  await deleteHostProjectionForAllMembers(orgId, hostId).catch(()=>undefined);
231
+ await eraseHostOwnedOrgDocuments(orgId, hostId);
194
232
  }
195
233
  // The host document tree (screens/layouts/versions/counters/products/…).
196
234
  await firestore.recursiveDelete(hostRef);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/erase.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 { FieldValue } from 'firebase-admin/firestore'\nimport { firebaseAdmin } from './firebase-admin'\nimport { eraseMediaDeliveryScope } from './media-delivery'\nimport { deleteHostProjectionForAllMembers } from './host-memberships'\nimport { detachWorkspaceDomain } from './workspace-domains'\nimport {\n CONSOLE_DOMAINS_COLLECTION,\n releaseConsoleDomain,\n} from './console-domains'\nimport { authForPool, findUserByUidAcrossPools } from './auth-pools'\nimport { eraseEmailDeliveriesForAddresses } from './email-delivery-log'\nimport {\n type AccountAddressSet,\n type AccountAddressSource,\n resolveAccountAddresses,\n} from './account-addresses'\nimport { EMAIL_IDENTITY_INDEX_COLLECTION } from './account-emails'\nimport { removeOrgMember } from './organizations'\nimport {\n runPluginUserErasers,\n type PluginUserErasureReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-user-erasure'\nimport {\n runPluginOrgErasers,\n type PluginOrgErasureReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-org-erasure'\nimport { isBillingSubscription } from '@aglyn/aglyn/server'\nimport { readOrgBilling } from './org-billing'\nimport {\n disposeHostSendingDomain,\n readSendingDomainTeardownByLabel,\n type SendingDomainDisposition,\n type TeardownSendingDomainDriver,\n} from './sending-domain-debt'\n\n/** The reversible hold before a requested erasure is executed (AGL-485). */\nexport const ERASURE_HOLD_MS = 7 * 24 * 60 * 60 * 1000\n\n/**\n * The admin app is initialized without a default storageBucket, so every\n * bucket access must name it explicitly (same as the media routes). Falls\n * through to the admin default if the env is somehow unset.\n */\nfunction storageBucket() {\n const name = process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET\n return firebaseAdmin.app().storage().bucket(name || undefined)\n}\n\n/**\n * `supplierDeliveries` — the dropship outbox, keyed by `hostId` as a FIELD.\n *\n * The literal rather than an import: the collection belongs to\n * `libs/plugins/commerce/src/lib/server/supplier-outbox.ts`\n * (`SUPPLIER_DELIVERY_COLLECTION`), which is tagged `aglyn:addons`, and this\n * library is `scope:data` — the nx boundary forbids the edge, and reaching\n * for a dynamic import to dodge it would poison the lint graph instead. The\n * emulator spec asserts the two spellings still agree by reading that file,\n * so a rename fails a test rather than silently un-wiring this sweep.\n */\nconst SUPPLIER_DELIVERY_COLLECTION = 'supplierDeliveries'\n\n/**\n * `outreachMailboxCredentials` — the OAuth grant behind each mailbox a rep\n * connects to Outreach, keyed by the mailbox id and carrying `orgId` as a\n * FIELD.\n *\n * The literal rather than an import, for `SUPPLIER_DELIVERY_COLLECTION`'s\n * reason: the collection belongs to `libs/plugins/outreach`\n * (`OUTREACH_COLLECTIONS.mailboxCredentials` in\n * `src/lib/model/outreach.types.ts`), and this `scope:data` library may not\n * import a plugin. The plugin's own spec asserts the two spellings agree, so\n * a rename fails a test rather than silently un-wiring the sweep.\n *\n * A static sweep here, not a hook the plugin registers, because an erasure\n * cannot rely on the plugin being present. An org that switched Outreach off\n * still holds the tokens its reps granted, and the process running the\n * erasure — the cron, the operator script, a replay — may never have loaded\n * the plugin's bundle at all. A credential's lifetime cannot depend on which\n * bundles an erasing process happened to load.\n */\nconst OUTREACH_MAILBOX_CREDENTIALS_COLLECTION = 'outreachMailboxCredentials'\n\n/**\n * Destroy the site's DEAD-LETTERED supplier deliveries (AGL-1448).\n *\n * `supplierDeliveries/{id}` is TOP-LEVEL and carries `hostId` as a field, so\n * neither `recursiveDelete(hosts/{hostId})` here nor\n * `recursiveDelete(orgs/{orgId})` in `eraseOrg` can see it. That placement is\n * deliberate and correct — a subcollection would be editor-writable under the\n * permissive host catch-all, and the row carries the exact JSON body we sign\n * and POST to the supplier — so the fix is a sweep, not a move.\n *\n * **The gap is the dead letter, and the module that created it named it.**\n * `supplier-outbox.ts` DELETES a row the moment delivery succeeds, and its\n * docblock says why in terms: the body holds the buyer's email and name, and\n * a top-level document is outside the recursive delete an erasure runs. The\n * happy path was solved by that deletion. The failure path was not: after\n * `SUPPLIER_DELIVERY_MAX_ATTEMPTS` the row is left standing as `failed`,\n * because a human is supposed to act on it and the merchant has been told.\n * Nothing ever collects it afterwards — no TTL policy keys on it, no cron\n * reaps it, and the erasure could not reach it. So the one class of row that\n * survives is the one holding a THIRD PARTY's name, email and shipping\n * address, against a site whose owner has since asked to be erased and whom\n * nobody can ask to act on the dead letter any more.\n *\n * Bounded by the `hostId` field, never a collection sweep: this collection\n * holds every other merchant's orders. Chunked, because a batch caps at 500\n * and nothing bounds a dead-letter backlog below that.\n *\n * In `eraseHost` rather than `eraseOrg` on purpose — it is host-scoped data,\n * so the self-serve \"delete this site\" path gets the same cleanup as a full\n * org erasure instead of leaving the residue behind for the smaller action.\n *\n * Best-effort, like every other trailing cleanup in `eraseHost`: a site\n * delete must not fail because an outbox query did.\n */\nasync function eraseHostSupplierDeliveries(hostId: string): Promise<number> {\n if (!hostId) return 0\n try {\n const firestore = firebaseAdmin.app().firestore()\n const rows = await firestore\n .collection(SUPPLIER_DELIVERY_COLLECTION)\n .where('hostId', '==', hostId)\n .get()\n for (let index = 0; index < rows.docs.length; index += 400) {\n const batch = firestore.batch()\n for (const doc of rows.docs.slice(index, index + 400)) {\n batch.delete(doc.ref)\n }\n await batch.commit()\n }\n return rows.size\n } catch (error) {\n console.error(`eraseHost: supplier outbox cleanup failed for ${hostId}`, error)\n return 0\n }\n}\n\n/**\n * Permanently erase a single site and everything it owns (AGL-488).\n * Unlike deleting the host doc alone (which orphans data), this cleans up\n * every trailing reference:\n * - Storage objects under `hosts/{hostId}/` (media, CDN variants),\n * - the `hostIndex/{hostId}` routing entry (subdomain/cname resolution),\n * - the owning org's `hosts` routing-map entry, and\n * - the host's Firestore tree (screens, layouts, versions, counters, …)\n * via `recursiveDelete`.\n *\n * Shared by the self-serve delete-site route and the org-erasure pipeline\n * (AGL-487). Fail-soft on Storage/index cleanup so a partial failure never\n * blocks the Firestore delete; safe to re-run.\n */\nexport interface EraseHostOptions {\n /**\n * The console-side sending-domain teardown, injected because this library\n * may not hold the credentials it needs — see `sending-domain-debt.ts`.\n *\n * Absent, the site's domain is not released inline: the debt is recorded on\n * the surviving label claim and `/api/admin/reap-sending-domains` settles\n * it. That is the only correct default here. A library the tenant runtime\n * imports cannot reach a full-access mail credential, and an erasure must\n * not fail or stall because a vendor is unreachable.\n */\n tearDownSendingDomain?: TeardownSendingDomainDriver | null\n}\n\nexport interface EraseHostResult {\n /** What became of the site's dedicated sending domain, if it had one. */\n sendingDomain: SendingDomainDisposition\n}\n\nexport async function eraseHost(\n hostId: string,\n options: EraseHostOptions = {},\n): Promise<EraseHostResult> {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n const orgId = hostSnapshot.get('orgId') as string | undefined\n\n /*==========================================\n * THE SENDING DOMAIN, READ FIRST AND RELEASED LAST.\n *\n * Read here because the pinned label lives on the host document and the\n * `recursiveDelete` at the bottom of this function destroys it. Read\n * afterwards there would be nothing left to say WHICH provider domain holds\n * a plan-capped account slot and WHICH records in our zone carry a signing\n * key for a name a future site can claim.\n *\n * Released after everything else, so a teardown that dies partway leaves the\n * claim standing and the domain findable rather than orphaning two vendors'\n * resources with nothing pointing at them.\n *=========================================*/\n const sendingLabel = String(hostSnapshot.get('sendingLabel') ?? '').trim()\n const sendingTeardown = sendingLabel\n ? await readSendingDomainTeardownByLabel(sendingLabel).catch(() => null)\n : null\n\n // Storage first (best-effort — the object tree is derived, regenerable).\n try {\n await storageBucket().deleteFiles({ prefix: `hosts/${hostId}/` })\n } catch (error) {\n console.error(`eraseHost: storage cleanup failed for ${hostId}`, error)\n }\n // And the delivery provider's copies of the site's video (AGL-2824), held\n // under the same library prefix. Nothing runs when no provider is\n // configured to store; a failure is logged, like the bucket's.\n await eraseMediaDeliveryScope({ collection: 'hosts', scopeId: hostId })\n\n // Dead-lettered supplier deliveries (AGL-1448). See the note on\n // SUPPLIER_DELIVERY_COLLECTION below for why this is here and not implied\n // by the recursiveDelete at the end of this function.\n await eraseHostSupplierDeliveries(hostId)\n\n // Routing: the middleware resolves a request to a host via hostIndex and\n // the owning org's hosts map — drop both so the subdomain/cname 404s.\n await firestore\n .collection('hostIndex')\n .doc(hostId)\n .delete()\n .catch(() => undefined)\n if (orgId) {\n // The routing entry and the site's POOLED SEATS go in the SAME write —\n // POS registers (AGL-1775) and collaborators (AGL-2439), which are the\n // same mechanism on two keys and must be released together or a deleted\n // site strands one pool while returning the other.\n //\n // `registerAllocations[hostId]` is capacity the org has\n // paid for and assigned here; a deleted site must return it to the pool\n // or the org keeps paying $89/mo for a seat pinned to a site that no\n // longer exists and cannot be reassigned from any surface. Releasing it\n // by deleting the key rather than by decrementing a counter means the\n // pool is `purchased - sum(allocations)` by arithmetic and has nothing to\n // drift out of step with.\n await firestore\n .collection('orgs')\n .doc(orgId)\n .set(\n {\n hosts: { [hostId]: FieldValue.delete() },\n registerAllocations: { [hostId]: FieldValue.delete() },\n collaboratorAllocations: { [hostId]: FieldValue.delete() },\n },\n { merge: true },\n )\n .catch(() => undefined)\n // Drop every member's reverse-index row for this host (AGL-844); the\n // members still exist here (recursiveDelete of the org, if any, is later).\n await deleteHostProjectionForAllMembers(orgId, hostId).catch(() => undefined)\n }\n\n // The host document tree (screens/layouts/versions/counters/products/…).\n await firestore.recursiveDelete(hostRef)\n\n // Never throws, and never leaves the slot unaccounted for: whatever the\n // vendors refuse is recorded on the label claim for the reaper to finish.\n return {\n sendingDomain: await disposeHostSendingDomain({\n teardown: sendingTeardown ? { ...sendingTeardown, hostId } : null,\n tearDown: options?.tearDownSendingDomain,\n }),\n }\n}\n\n/**\n * Delete a single resource document *and everything under it* (AGL-945).\n *\n * Firestore does not cascade: deleting `orgs/{o}/datasets/{d}` leaves every\n * `records/{r}` beneath it alive but unreachable from the console — still\n * billed, still matched by the rules (which match subcollection paths\n * independently of the parent), and resurrected wholesale if the parent id\n * is ever reused. Only the Admin SDK has `recursiveDelete`, so the console\n * cards route their deletes through /api/resources/erase, which lands here.\n *\n * Authorization belongs to the caller — this only walks the tree.\n */\nexport async function eraseSubtree(\n path: readonly [string, string, string, string],\n): Promise<void> {\n const firestore = firebaseAdmin.app().firestore()\n const [scope, scopeId, kind, id] = path\n await firestore.recursiveDelete(\n firestore.collection(scope).doc(scopeId).collection(kind).doc(id),\n )\n}\n\n/** Best-effort Stripe customer deletion — PII lives at the processor too. */\nasync function deleteStripeCustomer(customerId?: string): Promise<void> {\n const key = process.env.STRIPE_SECRET_KEY\n if (!key || !customerId) return\n try {\n await fetch(`https://api.stripe.com/v1/customers/${customerId}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${key}` },\n })\n } catch (error) {\n console.error(`eraseOrg: Stripe customer ${customerId} delete failed`, error)\n }\n}\n\n/**\n * Delete every API credential the org owns (AGL-1444).\n *\n * `apiKeys` is a TOP-LEVEL collection keyed by the SHA-256 of the token and\n * carrying `orgId` as a FIELD, so `recursiveDelete(orgRef)` cannot see it —\n * a path-scoped cascade is structurally blind to anything not under the path.\n * The credential therefore outlived the workspace: `verifyApiKey` kept\n * resolving the token to a live principal naming an org that no longer\n * existed. `authenticateApiV1` happens to refuse it (it reads the org doc and\n * 401s), but that is a gate one layer above the credential, and a key whose\n * revocation depends on every future caller repeating a lookup is not\n * revoked. The document is also a small record of the erased workspace in its\n * own right — a human-authored label, the creating uid, the granted scopes.\n *\n * Bounded by the `orgId` field, never a collection sweep: this collection\n * holds every other customer's integration credentials too.\n *\n * Returns the number destroyed, for the audit row — an erasure trail that\n * understates what it removed is the one record that has to be right.\n */\nasync function eraseOrgApiKeys(orgId: string, dryRun = false): Promise<number> {\n return deleteDocsByOrgId('apiKeys', orgId, dryRun)\n}\n\n/**\n * Delete every Outreach mailbox credential the org holds (AGL-2974).\n *\n * `outreachMailboxCredentials` is a TOP-LEVEL collection keyed by the mailbox\n * id and carrying `orgId` as a FIELD, so `recursiveDelete(orgRef)` cannot see\n * it — the blindness `eraseOrgApiKeys` answers, on a more dangerous document.\n * Left standing, the OAuth grant a rep gave for their own mailbox would\n * outlive the workspace it was given to: tokens that can still send as that\n * person, held for an organization that no longer exists and that nobody can\n * ask to disconnect them.\n *\n * This destroys the platform's copy of the grant; it does not call the\n * provider's revocation endpoint, which needs the plugin's provider adapter.\n * With the tokens gone the grant has nothing left to act through.\n *\n * Bounded by the `orgId` field, never a collection sweep: this collection\n * holds every other workspace's connected mailboxes too.\n *\n * Returns the number destroyed, for the audit row — an erasure trail that\n * understates what it removed is the one record that has to be right.\n */\nasync function eraseOrgOutreachMailboxCredentials(\n orgId: string,\n dryRun = false,\n): Promise<number> {\n return deleteDocsByOrgId(\n OUTREACH_MAILBOX_CREDENTIALS_COLLECTION,\n orgId,\n dryRun,\n )\n}\n\n/**\n * Delete every document in a TOP-LEVEL collection that names this org in its\n * `orgId` FIELD (AGL-1444/AGL-1448).\n *\n * The shared mechanism behind every collection a path-scoped cascade cannot\n * see. Two properties are the whole point, and neither is optional:\n *\n * - **Bounded by the field, never a collection sweep.** Every one of these\n * collections holds other customers' credentials, routing and billing\n * correlations. A `collection(name).get()` here would erase the estate.\n * - **Chunked**, because a batch caps at 500 writes and nothing bounds an\n * org's row count below that.\n *\n * Returns the number destroyed, for the audit row — an erasure trail that\n * understates what it removed is the one record that has to be right.\n *\n * `dryRun` runs the query and skips the commit (AGL-1481), so a plan counts\n * rows through the SAME function that deletes them. A separate counting pass\n * would be a second enumeration of the sweep list, which is precisely the\n * divergence AGL-1481 exists to remove.\n *\n * **`platformRevenue` must NEVER be added to this sweep (AGL-1811).** It is\n * org-keyed by field — exactly the shape this mechanism eats — and that is\n * the trap: those rows are per-transaction TAX FILING RECORDS (gross, tax,\n * jurisdiction) with a statutory retention obligation, and the quarterly\n * Texas return is their sum. GDPR Art. 17(3)(b) exempts them: erasure does\n * not extend to records retained for compliance with a legal obligation. An\n * erased org's rows deliberately outlive it — the\n * `erase-org-tax-retention.emulator.spec` pins survival, so an over-eager\n * future sweep fails a test instead of un-filing a tax period.\n *\n * **`storefrontTaxCollected` must NEVER be added either (AGL-1904).** Same\n * shape, same trap, same reason: it carries `orgId` as a field, and its rows\n * are the record of sales tax charged to shoppers on that org's storefront —\n * including the tax a `mode: 'stripe'` store collects under AGLYN's own\n * Texas registration, which Aglyn must be able to account for long after the\n * merchant has gone.\n */\nasync function deleteDocsByOrgId(\n collection: string,\n orgId: string,\n dryRun = false,\n): Promise<number> {\n if (!orgId) return 0\n const firestore = firebaseAdmin.app().firestore()\n const rows = await firestore\n .collection(collection)\n .where('orgId', '==', orgId)\n .get()\n if (dryRun) return rows.size\n for (let index = 0; index < rows.docs.length; index += 400) {\n const batch = firestore.batch()\n for (const doc of rows.docs.slice(index, index + 400)) batch.delete(doc.ref)\n await batch.commit()\n }\n return rows.size\n}\n\n/**\n * Drop the org's public SSO routing documents (AGL-1448).\n *\n * `ssoDomains/{domain}` is keyed by the DOMAIN and carries `orgId` as a field,\n * so `recursiveDelete(orgRef)` never reaches it. The org's own claims under\n * `orgs/{orgId}/ssoDomains/{domain}` do die with the tree; the public routing\n * doc — the one that decides where a sign-in goes — does not.\n *\n * **`unpublishSsoDomains` is not the fix, though it looks like it.** That\n * function is the reversible half of the console's SSO toggle: it sets\n * `active: false` and leaves the document standing precisely so re-enabling\n * restores the same pool and the same uids. Applied to an erasure it would\n * leave behind a record naming the deleted org, its GCIP tenant and provider\n * ids, its IdP display name and the customer's email domain — and, worse, a\n * live reservation: `issueDomainClaim` refuses a domain whose `ssoDomains` doc\n * belongs to another org REGARDLESS of `active`, so a ghost would hold a real\n * customer's domain against them with no way to appeal. Absence is the only\n * state that both stops the routing and releases the name.\n *\n * Deleting is safe on the read side: `/api/auth/sso-lookup` already treats a\n * missing doc and an inactive one identically (`{ ssoEnabled: false }`).\n */\nasync function eraseOrgSsoDomains(\n orgId: string,\n dryRun = false,\n): Promise<number> {\n return deleteDocsByOrgId('ssoDomains', orgId, dryRun)\n}\n\n/**\n * Release every custom console domain the org holds (AGL-1448).\n *\n * `releaseConsoleDomain` already does exactly the right thing and was called\n * from nothing but its own spec. It is wired rather than reimplemented, and\n * the ordering it enforces is the reason: Vercel detach FIRST, documents\n * second, and the claim KEPT if a detach fails. `resolveConsoleDomain`'s\n * `unknown` verdict — the one that lets localhost, previews and self-hosted\n * installs work — is only safe while every name the console project still\n * answers for has a document (AGL-1430). Deleting the claim here directly\n * would break that correspondence and hand the name to the next org that\n * claims it, with Vercel's `already-exists` reading as health.\n *\n * It takes one domain, so the org-bounded query is the part that was missing.\n * Every name in a claim set — the primary and its `www` twin — carries the\n * same `orgId` and the same `primaryHost`, so releasing once per distinct\n * primary covers the set exactly, without a second pass over the twins.\n *\n * Best-effort, like every other external cleanup here: an erasure must not\n * fail because Vercel did. A name that could not be detached keeps its claim\n * and is reported in the audit row as un-released.\n */\nasync function releaseOrgConsoleDomains(\n orgId: string,\n dryRun = false,\n): Promise<number> {\n const firestore = firebaseAdmin.app().firestore()\n const claims = await firestore\n .collection(CONSOLE_DOMAINS_COLLECTION)\n .where('orgId', '==', orgId)\n .get()\n const primaries = new Set(\n claims.docs.map((doc) => (doc.get('primaryHost') as string) || doc.id),\n )\n // A plan counts the claim sets it WOULD release. `releaseConsoleDomain`\n // detaches at Vercel, so it must not run for a dry run (AGL-1481).\n if (dryRun) return primaries.size\n let released = 0\n for (const domain of primaries) {\n const result = await releaseConsoleDomain({ orgId, domain }).catch(\n (error) => {\n console.error(`eraseOrg: console domain ${domain} release failed`, error)\n return { released: false }\n },\n )\n if (result.released) released += 1\n }\n return released\n}\n\n/**\n * Drop the local Stripe reverse index (AGL-1448, AGL-1028).\n *\n * `stripeCustomers/{stripeCustomerId} -> { orgId }` exists because AGL-1028\n * moved `stripeCustomerId` off the org doc into a manager-gated subcollection,\n * which broke the webhook's `where('stripeCustomerId', '==', …)` lookup. It is\n * denied to every client for one reason, stated in the rules: readable, it\n * maps a billing identity back to a workspace.\n *\n * The erasure had this exactly inverted. It deleted the customer AT STRIPE and\n * kept the local index — so the artefact that survived was the correlation the\n * whole issue existed to prevent, now pointing at an org that no longer exists\n * to be gated on. This deletes the local rows. The Stripe-side delete is\n * unchanged and still runs: PII lives at the processor too, and one is not a\n * substitute for the other.\n *\n * Bounded by the `orgId` field rather than by the org's CURRENT customer id,\n * because nothing cleans up the index when a customer id changes — an org that\n * was re-created in Stripe has more than one row pointing at it.\n */\nasync function eraseOrgStripeIndex(\n orgId: string,\n dryRun = false,\n): Promise<number> {\n return deleteDocsByOrgId('stripeCustomers', orgId, dryRun)\n}\n\n/**\n * Drop the org's REST API replay keys (AGL-1448).\n *\n * `apiIdempotency/{sha256(orgId:key)}` dedupes `POST`s by `Idempotency-Key`\n * (AGL-618). Low severity — a replay key names an org and a record id and\n * authorises nothing — but it does NOT age out on its own, which is the part\n * worth writing down because the assumption runs the other way: the only TTL\n * policy on the database is `rateLimits.expiresAt`\n * (`docs/FIRESTORE_MANUAL_CONFIG.md`), and these documents carry no expiry\n * field for a policy to key on. Left alone they accumulate against a dead org\n * indefinitely.\n *\n * Taken with the credentials rather than at the end: the record ids they carry\n * point into the org tree that step 4 destroys.\n */\nasync function eraseOrgIdempotencyKeys(\n orgId: string,\n dryRun = false,\n): Promise<number> {\n return deleteDocsByOrgId('apiIdempotency', orgId, dryRun)\n}\n\n/**\n * Release every slug the org has EVER held (AGL-1448).\n *\n * The erasure deleted `orgSlugs/{org.slug}` — the current name only. But\n * `changeOrgSlug` leaves a tombstone at the previous slug (`{ orgId, movedTo,\n * renamedAt }`) so old workspace URLs keep redirecting (AGL-585/AGL-236), and\n * nothing ever collected them. Every historical name an org held therefore\n * survived its erasure, in the one collection the rules make\n * `allow read: if true` — public because it doubles as the pre-auth health\n * probe. An erased workspace's naming history stayed world-readable, each\n * tombstone still naming the dead org id.\n *\n * Bounded by the `orgId` field, which is exactly the right boundary here and\n * not merely the safe one: claiming a tombstoned slug FULL-REPLACES the\n * document with the new owner's `{ orgId }`, so a name this org renamed away\n * from and somebody else has since taken does not match — and must not be\n * swept, because it is now a live tenant's reservation.\n *\n * The current slug is unioned in rather than assumed present: an org whose\n * reservation was lost to a legacy write would otherwise keep its workspace\n * subdomain attached to the project. ⛔ ONLY WHEN NOBODY HOLDS IT (AGL-2585).\n * A workspace address taken by an unverified signup is a reservation that\n * lapses, and a live workspace may since have claimed the name — at which\n * point `orgSlugs/{slug}` names THAT org, and the erasure of this one would\n * otherwise delete a working tenant's reservation and detach their subdomain.\n * The `orgId` query above already refuses that document; this is the one line\n * that used to add it back by assumption.\n *\n * Each name's `{slug}.aglyn.com` is detached too. `changeOrgSlug` deliberately\n * KEEPS the old subdomain attached so the tombstone's 308 has a hostname to\n * run on; once the tombstone goes, the attachment is a name resolving to a\n * console for an org that does not exist. Best-effort, like every other\n * external cleanup here: an erasure must not fail because a DNS API did.\n */\nasync function eraseOrgSlugs(\n orgId: string,\n currentSlug?: string,\n dryRun = false,\n): Promise<number> {\n const firestore = firebaseAdmin.app().firestore()\n const held = await firestore\n .collection('orgSlugs')\n .where('orgId', '==', orgId)\n .get()\n const slugs = new Set(held.docs.map((doc) => doc.id))\n if (currentSlug && !slugs.has(currentSlug)) {\n // Absent means the reservation was lost and only the DNS attachment is\n // left to clean up. Present but not matched by the query above means it\n // belongs to another org now, and is not this erasure's to touch.\n const reservation = await firestore\n .collection('orgSlugs')\n .doc(currentSlug)\n .get()\n .catch(() => null)\n if (reservation && !reservation.exists) slugs.add(currentSlug)\n }\n // `detachWorkspaceDomain` calls a live DNS API — never on a plan (AGL-1481).\n if (dryRun) return slugs.size\n for (const slug of slugs) {\n await firestore.collection('orgSlugs').doc(slug).delete().catch(() => undefined)\n await detachWorkspaceDomain(slug).catch(() => undefined)\n }\n return slugs.size\n}\n\n/**\n * Sweep up ORPHANED `hostIndex` rows the per-host loop cannot enumerate\n * (AGL-1448).\n *\n * `hostIndex/{hostId}` is `{ orgId, subdomain }` and `allow read: if\n * isSignedIn()`. It is deleted today inside `eraseHost`, which `eraseOrg`\n * calls once per document in `hosts where orgId == org` — so the row is\n * reached DERIVATIVELY, through a second collection, and only for as long as\n * the two agree.\n *\n * **The erasure is itself one of the ways they stop agreeing.** `eraseHost`'s\n * `hostIndex` delete is `.catch(() => undefined)` — fail-soft on purpose, so\n * a transient failure never blocks the Firestore delete — and the very next\n * statement `recursiveDelete`s `hosts/{hostId}`. The instant that lands, the\n * orphaned index row is unreachable from the org side forever: nothing points\n * at it, so neither this run nor any retry can ever enumerate it again. A\n * fail-soft that manufactures a PERMANENT orphan is not fail-soft, and the\n * residue is a signed-in-readable row naming a dead org and the subdomain its\n * customer chose.\n *\n * Bounded by the `orgId` field — this is the table the middleware resolves\n * every request on, so an unbounded sweep here does not lose an index, it\n * takes the estate offline.\n *\n * A BACKSTOP, not a replacement: the per-host loop still does the real work\n * (Storage, the org routing map, the member projections, the host tree). This\n * only collects what that loop structurally cannot see.\n */\nasync function eraseOrgHostIndex(\n orgId: string,\n dryRun = false,\n): Promise<number> {\n return deleteDocsByOrgId('hostIndex', orgId, dryRun)\n}\n\n/**\n * Sweep up `hostMemberships` projections belonging to FORMER members\n * (AGL-1448).\n *\n * `users/{uid}/hostMemberships/{hostId}` carries `{ orgId, subdomain,\n * displayName, nameLower, role }` — the site switcher's row. It lives under a\n * USER, so `recursiveDelete(orgs/{orgId})` cannot reach it, and `eraseOrg`'s\n * members loop only walks `orgs/{orgId}/members`.\n *\n * `deleteHostProjectionForAllMembers(orgId, hostId)` clears the row for\n * everyone in that member list at the moment the erasure runs. **A former\n * member is not in it.** Their row is written by the projection fan-out and\n * removed by `removeOrgMember`, which is fail-soft in the same way as\n * everything else on that path; one missed fan-out leaves a row no later\n * erasure can find, because the enumeration starts from a membership that no\n * longer exists. The erased workspace's site NAME and subdomain then live on\n * in the user document of somebody who left the company.\n *\n * ## This is a COLLECTION-GROUP query, and that owes an index\n *\n * `hostMemberships` has two composite indexes, both `queryScope: COLLECTION`.\n * A collection-group equality on `orgId` needs a `COLLECTION_GROUP`\n * single-field index, which Firestore does NOT create automatically — the\n * automatic single-field indexes are collection-scoped except for\n * array-contains. `cloud/firebase-firestore.indexes.json` therefore gains a\n * `fieldOverrides` entry alongside the existing `members.uid` and\n * `messages.authorId` ones, and **it must be deployed by hand.**\n *\n * Until it is, the query throws `FAILED_PRECONDITION`. AGL-1793 is this\n * codebase's record of exactly that failure mode — both commerce crons\n * silently dead in production for want of a `COLLECTION_GROUP_ASC` override —\n * so this refuses to be the thing that kills an erasure. It is caught and\n * reported as `null`, which is deliberately NOT `0`: a zero would say \"there\n * was nothing to sweep\" and a missing index would wear that as a clean bill\n * of health. `null` in the audit row says \"attempted, could not run\", and the\n * spec asserts a NUMBER so the wired-and-indexed path is what is pinned.\n */\nasync function eraseOrgHostMemberships(\n orgId: string,\n dryRun = false,\n): Promise<number | null> {\n if (!orgId) return 0\n try {\n const firestore = firebaseAdmin.app().firestore()\n const rows = await firestore\n .collectionGroup('hostMemberships')\n .where('orgId', '==', orgId)\n .get()\n if (dryRun) return rows.size\n for (let index = 0; index < rows.docs.length; index += 400) {\n const batch = firestore.batch()\n for (const doc of rows.docs.slice(index, index + 400)) {\n batch.delete(doc.ref)\n }\n await batch.commit()\n }\n return rows.size\n } catch (error) {\n console.error(\n `eraseOrg: hostMemberships projection sweep failed for ${orgId} — a missing COLLECTION_GROUP index on hostMemberships.orgId is the likeliest cause`,\n error,\n )\n return null\n }\n}\n\n/**\n * What an erasure did with `publisherProfiles/{orgId}` (AGL-1970).\n *\n * Three outcomes rather than a boolean, because the third one is a decision\n * the audit row has to be able to state: a listing the erased org published\n * can outlive it, and a listing whose publisher document is simply gone is\n * unattributable in a way nobody chose.\n */\nexport type PublisherProfileDisposition = 'absent' | 'deleted' | 'tombstoned'\n\n/**\n * Drop the org's PUBLIC marketplace identity — the profile and every handle\n * it reserved (AGL-1970).\n *\n * Both survived every erasure until now, and both are `allow read: if true`\n * (`cloud/firebase-firestore.rules`, `match /publisherProfiles/{orgId}`). This\n * is the AGL-1448 shape with one twist that is the whole reason it was missed:\n * `publisherProfiles` is keyed by the org id **as the document id**, so it is\n * invisible to `deleteDocsByOrgId` *and* to a reader scanning this file for\n * the field-keyed sweep list. Nothing prompts you to notice it.\n *\n * What survived is not cosmetic. `stripeAccountId` is a **payout\n * destination** — written server-side only after Connect onboarding/KYC and\n * trusted by every checkout path — so an erased org stayed world-readable with\n * a live payment-account identifier attached to a dead identity. That is the\n * `stripeCustomers` correlation AGL-1448 removed, pointing the other way.\n *\n * `publisherHandles` is the easy half and goes unconditionally: it carries\n * `orgId` as a FIELD, so `deleteDocsByOrgId` fits exactly, and it is a **live\n * reservation** — `claimPublisherHandle` refuses a handle another org's row\n * names, so a ghost holds a marketplace name against a real customer with no\n * way to appeal. That is the `ssoDomains` argument verbatim, and absence is\n * again the only state that both stops the read and releases the name. Rename\n * tombstones (`{ orgId, movedTo }`) carry the same `orgId` and go with it, and\n * the field bound is what makes that safe: re-claiming a tombstone FULL-\n * REPLACES it with the new owner's `{ orgId }`, so a handle this org renamed\n * away from and somebody else has since taken does not match the query.\n *\n * **The profile is the half with a genuine tension, and it is resolved here\n * rather than deferred.** `marketplaceListings` outlives an erasure — that is\n * AGL-1448's parked Tier 3 product decision, an erased org's listing being\n * something buyers paid for — so deleting the publisher document outright can\n * leave a listing attributed to nothing. So:\n *\n * - **No surviving listing** → `recursiveDelete` the profile. `deleted`.\n * `recursiveDelete` and not `delete`: `publish-plugin` keeps its daily\n * publish-rate window at `publisherProfiles/{orgId}/meta/publishWindow`,\n * and a document delete would orphan it under a path with no owner.\n * - **A listing survives** → the same `recursiveDelete`, then a minimal\n * `{ erased: true, erasedAt }` in its place. `tombstoned`. The tombstone\n * carries no `handle`, no `displayName`, no `bio`/`avatarUrl`/`website`,\n * no `publisherAgreement` and — the point — no `stripeAccountId`. It is\n * \"an internal record that the erasure happened\", which is exactly what\n * the Privacy Policy §5 sentence reserves; every byte the sentence calls\n * content is gone either way. Readers already handle it: with no `handle`,\n * `resolvePublisherProfile` returns `null`, which is the pre-existing\n * \"this org has no profile\" path.\n *\n * The count of surviving listings is returned either way, so an erasure that\n * left something standing SAYS SO in its audit row instead of reporting a\n * clean success — the Tier 3 decision is still open, and an erasure is the\n * one place its cost is measurable.\n */\nasync function eraseOrgPublisherIdentity(\n orgId: string,\n dryRun = false,\n): Promise<{\n handles: number\n profile: PublisherProfileDisposition\n listingsRetained: number\n}> {\n const firestore = firebaseAdmin.app().firestore()\n const handles = await deleteDocsByOrgId('publisherHandles', orgId, dryRun)\n const profileRef = firestore.collection('publisherProfiles').doc(orgId)\n const [profileSnapshot, listings] = await Promise.all([\n profileRef.get(),\n // `profileId` is the listing's publishing-org id (AGL-652) — the same\n // value as this profile's document id, under the older field name.\n firestore.collection('marketplaceListings').where('profileId', '==', orgId).get(),\n ])\n const listingsRetained = listings.size\n if (!profileSnapshot.exists) {\n return { handles, profile: 'absent', listingsRetained }\n }\n if (dryRun) {\n return {\n handles,\n profile: listingsRetained ? 'tombstoned' : 'deleted',\n listingsRetained,\n }\n }\n await firestore.recursiveDelete(profileRef)\n if (!listingsRetained) return { handles, profile: 'deleted', listingsRetained }\n await profileRef.set({ erased: true, erasedAt: FieldValue.serverTimestamp() })\n return { handles, profile: 'tombstoned', listingsRetained }\n}\n\n/**\n * Destroy every support ticket the org opened, and the messages under each\n * (AGL-1971).\n *\n * `supportTickets/{id}` is top-level with `orgId` as a field — the exact shape\n * `deleteDocsByOrgId` exists for — and it was in neither sweep list. It is\n * also the highest-PII-density collection outside the org tree: `messages`\n * rows carry `authorId`, `authorEmail` and up to 5000 characters of prose a\n * customer wrote to us, which is where somebody pastes the invoice, the member\n * list, or the very data they are asking us to delete.\n *\n * **`deleteDocsByOrgId` is deliberately NOT used here**, though the query is\n * identical. It deletes documents, not subtrees, and a batch delete of the\n * ticket documents would leave every `messages` row alive under a parent that\n * no longer exists — the prose and the email surviving the delete that was\n * supposed to remove them, now unreachable by any query that starts from\n * `orgId`. `recursiveDelete` per ticket is the only correct shape.\n *\n * **No retention exception, and the alternative was considered.** A closed\n * support thread is arguable dispute evidence, which is the class of argument\n * that keeps `platformRevenue` alive (AGL-1811). It does not reach here: that\n * exemption is GDPR Art. 17(3)(b), *records retained to comply with a legal\n * obligation*, and no law requires us to keep a support thread — a Texas tax\n * return is filed from `platformRevenue`; nothing is filed from a ticket.\n * Live DPA §11 commits the other way in terms (\"delete or return Customer\n * Personal Data, except to the extent retention is required by law\"). What\n * remains as evidence is the `adminAudit` row: the erasure happened, when, at\n * whose instruction, and how many tickets it destroyed — ids and counts,\n * never content (AGL-1443).\n *\n * Sequential rather than parallel: `recursiveDelete` is itself a bulk writer,\n * and fanning N of them out at once is how a 60-second cron loses an erasure.\n */\nasync function eraseOrgSupportTickets(\n orgId: string,\n dryRun = false,\n): Promise<number> {\n if (!orgId) return 0\n const firestore = firebaseAdmin.app().firestore()\n const tickets = await firestore\n .collection('supportTickets')\n .where('orgId', '==', orgId)\n .get()\n if (dryRun) return tickets.size\n for (const ticket of tickets.docs) {\n await firestore.recursiveDelete(ticket.ref)\n }\n return tickets.size\n}\n\n/**\n * The steps of `eraseOrg` after the hold check, named so a failed attempt can\n * say WHERE it stopped (AGL-1455).\n *\n * `export` used to lead this list, and its removal is the AGL-1443 change: an\n * erasure no longer writes anything before it starts deleting, so there is no\n * longer a step whose meaning is \"nothing is gone yet\". `credentials` is now\n * the first thing that can fail, and it destroys as it goes.\n */\ntype EraseStep =\n | 'plugins'\n | 'credentials'\n | 'support'\n | 'hosts'\n | 'org-storage'\n | 'stripe'\n | 'members'\n | 'org-tree'\n | 'slugs'\n\n/**\n * The two things an OPERATOR needs that a served caller does not (AGL-1481).\n *\n * `tools/scripts/erase-tenant.mjs` is the manual path staff reach for when the\n * cron is stuck. It used to be a second implementation of this function, and it\n * drifted: by the time AGL-1481 was filed it was missing four sweeps added that\n * week and still wrote a complete dump of the workspace to the operator's\n * working directory. The fix is that it CALLS this function — which means the\n * capabilities it had and this function lacked have to live here instead.\n *\n * Neither is a hold bypass, deliberately. The script refused to run before the\n * 7-day hold elapsed and this function refuses too; an `ignoreHold` flag would\n * have been the easy way to keep the two paths identical and is the one\n * difference worth preserving as a refusal on both.\n */\nexport interface EraseOrgOptions {\n /**\n * Count what an erasure WOULD destroy and destroy nothing. Every sweep runs\n * its query and skips its write, so the plan is produced by the same list of\n * sweeps that the erasure is — a separate counting pass would be exactly the\n * second enumeration this issue removed. Nothing external is touched (no\n * Storage, no Stripe, no Vercel, no DNS) and no audit row is written.\n *\n * Returns `ok: false` with `skippedReason: 'dry-run'`: `ok` means the org was\n * erased, and a plan did not erase it. A caller that treats a plan as an\n * erasure is the one mistake this shape has to make impossible.\n */\n dryRun?: boolean\n /**\n * Who is answerable for this erasure, for the audit row. Defaults to the\n * cron. A staff member running the script by hand is not the scheduler, and\n * an erasure trail that says otherwise names the wrong actor for the single\n * most irreversible action the platform performs.\n */\n actorUid?: string\n /**\n * The console-side sending-domain teardown, passed through to every host\n * this erasure destroys. See {@link EraseHostOptions.tearDownSendingDomain}.\n *\n * Never consulted on a dry run: a plan touches no vendor.\n */\n tearDownSendingDomain?: TeardownSendingDomainDriver | null\n /**\n * Erase a workspace that carries NO erasure request at all (AGL-2585).\n *\n * ⛔ EXACTLY ONE CALLER, and the option exists so that caller has to say so\n * out loud. `/api/admin/reap-unverified-orgs` collects workspaces created by\n * a signup whose address was never confirmed — there is nobody to ask for an\n * erasure, because nobody has been shown to exist. Every other path reaches\n * this function through `erasureRequestedAt`, which is a person's decision\n * with a seven-day hold on it, and none of them passes this.\n *\n * What it turns off is narrow and named: the `no-request` refusal and the\n * hold, and nothing else. Every sweep, every audit row and `dryRun` behave\n * identically — the caller supplies the proof that this workspace is\n * nobody's, and this records WHAT that proof was on the audit row.\n *\n * It does not skip the request when one exists. An org somebody has already\n * asked to erase is still that person's decision, and the reaper refuses\n * such an org before it ever gets here.\n */\n withoutRequest?: { reason: string } | null\n}\n\nexport interface EraseOrgResult {\n ok: boolean\n /**\n * Set when the org was NOT erased (flag missing, hold not elapsed, gone) —\n * and `'dry-run'` when nothing was erased because nothing was meant to be.\n */\n skippedReason?: string\n hosts?: number\n /** Member back-references cleaned up — reported so a plan can show them. */\n members?: number\n /** API credentials destroyed (AGL-1444) — outside the org path. */\n apiKeys?: number\n /**\n * What each plugin's workspace eraser did (AGL-2978), keyed by plugin id:\n * counts and flags, such as grants revoked at a provider. The erasers run\n * before anything below is deleted. **`null` means that plugin's eraser\n * failed** and its share may not be done; it is not zero. A plan carries\n * each eraser's count of what it would do.\n */\n plugins?: Record<string, PluginOrgErasureReport | null>\n /** Outreach mailbox grants destroyed (AGL-2974) — outside the org path. */\n outreachMailboxCredentials?: number\n /** Public SSO routing docs destroyed (AGL-1448) — outside the org path. */\n ssoDomains?: number\n /** Custom console domains released (AGL-1448) — outside the org path. */\n consoleDomains?: number\n /** REST API replay keys destroyed (AGL-1448) — no TTL reaps these. */\n apiIdempotency?: number\n /** Local Stripe reverse-index rows destroyed (AGL-1448/AGL-1028). */\n stripeIndex?: number\n /** Slugs released — the current one AND every tombstone (AGL-1448). */\n slugs?: number\n /**\n * ORPHANED `hostIndex` rows collected (AGL-1448) — ones with no surviving\n * `hosts` document for the per-host loop to reach them through.\n */\n hostIndex?: number\n /**\n * `hostMemberships` projections collected (AGL-1448), including FORMER\n * members' rows that no membership list names.\n *\n * `null` means the sweep could not RUN — almost always the missing\n * `COLLECTION_GROUP` index on `hostMemberships.orgId`. Deliberately not\n * `0`: an erasure that could not reach a collection must not report the\n * same number as one that reached it and found nothing.\n */\n hostMemberships?: number | null\n /** Marketplace handle reservations released (AGL-1970) — id-keyed, public. */\n publisherHandles?: number\n /** What became of the org's public publisher profile (AGL-1970). */\n publisherProfile?: PublisherProfileDisposition\n /**\n * Marketplace listings that OUTLIVE this erasure and still name the org\n * (AGL-1970/AGL-1448 Tier 3). Reported rather than deleted: an erasure that\n * leaves something standing has to say so, and this is the number that makes\n * the parked listing-survival decision cost something measurable.\n */\n listingsRetained?: number\n /**\n * Support threads destroyed, each with its `messages` subtree (AGL-1971) —\n * the highest-PII-density collection outside the org path.\n */\n supportTickets?: number\n /**\n * What became of the erased sites' dedicated sending domains.\n *\n * `deferred` is the number an operator reads: those provider slots and zone\n * records are still held, the debt is on each label claim, and\n * `/api/admin/reap-sending-domains` is what settles them. The erasure itself\n * is complete either way — a vendor outage may delay a slot's release and\n * may not delay a deletion somebody has a legal right to.\n */\n sendingDomains?: {\n released: number\n deferred: number\n /** Shared pool members refused. Always 0; a non-zero is a real alarm. */\n protected: number\n }\n}\n\n/**\n * What an in-flight erasure has destroyed so far — counts only.\n *\n * `members` is excluded: the inventory already reports it as `before.members`\n * on both audit rows, and a second copy under `after` would say the same\n * number twice.\n */\ntype EraseProgress = Omit<EraseOrgResult, 'ok' | 'skippedReason' | 'members'>\n\n/**\n * A stable label for an error, carrying no free text (AGL-1455).\n *\n * The class name and the SDK's error code are enough to tell a Firestore\n * permission denial from a deadline exceeded from an out-of-memory kill. The\n * MESSAGE is deliberately not stored: a Firestore or Storage error quotes the\n * path — and sometimes the payload — it choked on, and this row lands in a\n * collection that must not become a second copy of what the erasure failed to\n * delete (AGL-1443). The full error still goes to `console.error`, which is\n * the log, not the record.\n */\nfunction errorLabel(error: unknown): string {\n const source = (error ?? {}) as { name?: unknown; code?: unknown }\n const parts = [source.name, source.code].filter(\n (part) => typeof part === 'string' || typeof part === 'number',\n )\n return parts.length ? parts.join(':') : 'unknown'\n}\n\n/**\n * Record a failed erasure attempt where somebody will actually find it\n * (AGL-1455).\n *\n * Aborting when the export cannot be written is correct and stays — the bug\n * was that the abort was invisible. `skippedReason` reached exactly one\n * place, the cron endpoint's HTTP response body, and nobody reads a\n * scheduler's 200. Meanwhile `erasureRequestedAt` stays set, so the same org\n * is retried on the next run and fails the same way indefinitely, and the\n * customer who requested the erasure is told nothing.\n *\n * An `adminAudit` row is the cheapest durable form and the one the success\n * path already uses, so the two read as a pair: same `actorUid`, same\n * `target`, same `before` inventory, and `after` carrying what actually\n * happened. Staff can find every stuck erasure by filtering the audit log on\n * the action, which is exactly the search that was impossible before.\n *\n * `after.exportWritten`/`after.exportPath` used to be the fields that\n * mattered here: they named the complete dump of a still-existing workspace\n * that a post-export failure left in the bucket. AGL-1443 removed the write,\n * so that state is no longer reachable and the fields no longer exist. What\n * replaces them is `after.requestedAt` — WHICH erasure request is stuck,\n * which the object's name used to be the only record of.\n *\n * Ids, counts, a step name and a timestamp. Never customer data.\n *\n * Best-effort and never throws: this runs while an erasure is already\n * failing, and losing the original error to a bookkeeping write would be\n * worse than losing the row.\n */\nasync function recordErasureFailure(entry: {\n orgId: string\n step: EraseStep\n error: unknown\n /** Whoever asked for the erasure — the cron, or a named operator. */\n actorUid: string\n /** Millis of the `erasureRequestedAt` this attempt was fulfilling. */\n requestedAt: number\n before: { hosts: number; members: number }\n after: Record<string, unknown>\n}): Promise<void> {\n await firebaseAdmin\n .app()\n .firestore()\n .collection('adminAudit')\n .add({\n actorUid: entry.actorUid,\n action: 'org.erase-failed',\n target: `orgs/${entry.orgId}`,\n before: entry.before,\n after: {\n failedStep: entry.step,\n error: errorLabel(entry.error),\n requestedAt: entry.requestedAt,\n ...entry.after,\n },\n at: FieldValue.serverTimestamp(),\n })\n .catch((auditError) => {\n console.error(\n `eraseOrg: could not record the failed attempt for ${entry.orgId}`,\n auditError,\n )\n })\n}\n\n/**\n * Permanently erase an organization once its 7-day hold has elapsed\n * (AGL-485/487). Runs from the automated cron and the manual staff path.\n *\n * **Both of those are literally this function (AGL-1481).** The manual path,\n * `tools/scripts/erase-tenant.mjs`, used to be a second implementation, and a\n * second implementation of a cascade delete is a divergence with a schedule:\n * within a week of `eraseOrgApiKeys`, the SSO/console-domain release and the\n * org-keyed index sweeps landing here, the script was missing all four and\n * reporting success without them — leaving a live API credential, live domain\n * reservations, `orgSlugs` tombstones and the `stripeCustomers` reverse index\n * behind an erasure a human had been told was complete. The script now calls\n * this, and `EraseOrgOptions` is where the two capabilities it had and this\n * did not (a plan, and a named actor) live instead.\n *\n * Order matters and every step is defensive:\n * 1. Re-read the org and re-verify erasureRequestedAt + hold — never\n * delete on a stale/cancelled request.\n * 2. Nothing is written. This step used to persist a final JSON export of\n * the org and host trees to `erasures/{orgId}/…` and abort if it could\n * not — see the note below for why it is gone (AGL-1443).\n * 3. Revoke the org's API credentials (AGL-1444), its public routing — SSO\n * domains and custom console domains (AGL-1448) — and its public\n * marketplace identity: the publisher profile and every handle it\n * reserved (AGL-1970); and destroy the OAuth grants behind its reps'\n * Outreach mailboxes (AGL-2974). All of them are top-level collections\n * keyed by something other than a path under the org, so the org-tree\n * delete cannot reach them; doing them before the content delete also\n * closes the mid-erasure window, in which a credential, a domain or a\n * public publisher page still resolves to a half-deleted workspace.\n * 4. Delete each host (eraseHost), org-level Storage, the Stripe customer\n * AND its local reverse index, member back-references, then the org tree\n * and every slug the org ever held — tombstones included (AGL-1448).\n * 5. Audit.\n *\n * Every ending is audited, not just the successful one (AGL-1455). A throw\n * from any step writes an `org.erase-failed` row before re-throwing, because\n * `erasureRequestedAt` stays set on a failure: without a record the org is\n * retried and fails identically on every subsequent run, forever, and nothing\n * anywhere says so.\n *\n * **The erasure writes no copy of the workspace (AGL-1443).** It used to\n * persist `erasures/{orgId}/{requestedMs}.json` — and \"export\" understated\n * it: `exportDocTree` recursed `listCollections()` with no bound and copied\n * `snapshot.data()` wholesale, so the object was a complete verbatim copy of\n * the org tree and every host tree, carrying `webhooks.secret` (a plaintext\n * HMAC key), `orders.paymentLinkUrl` (a live bearer URL that lets its holder\n * pay), `screens.protection.passwordHash` and `ssoDomains.token`. It landed\n * on a prefix this function's own storage sweep does not cover, in a bucket\n * with no lifecycle rule, so the most sensitive object the platform can\n * produce outlived the request that deleted everything else — indefinitely,\n * unread, and belonging to a customer who had been told their workspace was\n * gone. Four facts decided it, and none of them is about tidiness:\n *\n * - **Nothing read it.** One producer, zero consumers in the repo; the\n * owner's confirmation email never mentioned it.\n * - **A governed full copy already exists.** Firestore backups run weekly\n * with 14-week retention (AGL-871), so the dump added no recoverability —\n * only a second, ungoverned place the data lived.\n * - **The minimum that proves an erasure was already being written.** The\n * `adminAudit` row below is ids and counts, in a collection with a\n * retention policy. The one thing the object's NAME carried that the row\n * did not — which request this run fulfilled — is now `after.requestedAt`.\n * - **The DPA commits to \"a limited period, after which deleted or\n * de-identified\"**, which indefinite retention of a full dump sits\n * outside of.\n *\n * Two consequences are deliberate, not side effects. The export-write abort\n * is gone with the write: there is nothing left to fail before the first\n * delete, so an erasure can no longer be blocked by Storage. And the export\n * was the only unbounded work in this function — it built the whole workspace\n * in memory inside a 60-second cron, so the larger the workspace the likelier\n * its erasure silently never happened (AGL-1455 half 1). That defect is\n * removed by deletion rather than by a streaming exporter.\n */\nexport async function eraseOrg(\n orgId: string,\n options: EraseOrgOptions = {},\n): Promise<EraseOrgResult> {\n const {\n dryRun = false,\n actorUid = 'cron:run-erasures',\n tearDownSendingDomain = null,\n withoutRequest = null,\n } = options\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const orgSnapshot = await orgRef.get()\n if (!orgSnapshot.exists) return { ok: false, skippedReason: 'not-found' }\n\n const requestedMs = orgSnapshot.get('erasureRequestedAt')?.toMillis?.() ?? null\n // The request and its hold, unless the caller carries its own proof\n // (AGL-2585). Both refusals are skipped together: a workspace with no\n // request has no stamp for a hold to run from, so keeping the second while\n // dropping the first would be a hold measured against `null`.\n if (!withoutRequest) {\n if (!requestedMs) return { ok: false, skippedReason: 'no-request' }\n if (Date.now() - requestedMs < ERASURE_HOLD_MS) {\n return { ok: false, skippedReason: 'hold-active' }\n }\n }\n\n const hosts = await firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .get()\n const members = await orgRef.collection('members').get()\n const slug = orgSnapshot.get('slug') as string | undefined\n // AGL-1028: moved to `orgs/{orgId}/billing/stripe`; the helper falls back\n // to the org doc for orgs the backfill has not reached.\n const stripeCustomerId = (await readOrgBilling(orgId)).stripeCustomerId as\n | string\n | undefined\n\n // The inventory both audit rows report, taken before anything is touched.\n const before = { hosts: hosts.size, members: members.size }\n\n // No export. The write that used to stand here is gone (AGL-1443) — see\n // the note above this function. Nothing is persisted anywhere before the\n // deleting starts, and `requestedMs` now travels in the audit row rather\n // than in an object's name.\n\n // None of what follows was guarded (AGL-1455) — `eraseOrgApiKeys`,\n // `eraseOrgSsoDomains`, `eraseOrgIdempotencyKeys` and `recursiveDelete` all\n // throw. A throw here leaves a workspace half destroyed and still standing,\n // recorded nowhere. Each step now names itself, and the error is re-thrown\n // unchanged — this adds a record, it does not swallow the failure. What it\n // can no longer leave behind is the state that used to be the worst one: a\n // surviving workspace with its own complete dump already in the bucket.\n const progress: EraseProgress = {}\n let step: EraseStep = 'plugins'\n try {\n // The plugins' share first (AGL-2978), while the org, its roster and\n // every record swept below still exist: an eraser that revokes a grant at\n // its provider opens the stored grant to do it. Each eraser is isolated,\n // so a failing one is recorded as `null` and stops nothing; on a plan\n // each one counts and touches no provider.\n progress.plugins = await runPluginOrgErasers({ orgId, dryRun })\n\n step = 'credentials'\n // Credentials and routing BEFORE content (AGL-1444/AGL-1448). The org doc\n // survives until the recursiveDelete at the end, so a key presented — or a\n // sign-in routed, or a console domain resolved — mid-erasure would still\n // pass the org gate and reach a half-deleted workspace. Revoking first\n // closes that window as well as the permanent one.\n progress.apiKeys = await eraseOrgApiKeys(orgId, dryRun)\n progress.outreachMailboxCredentials = await eraseOrgOutreachMailboxCredentials(\n orgId,\n dryRun,\n )\n progress.ssoDomains = await eraseOrgSsoDomains(orgId, dryRun)\n progress.consoleDomains = await releaseOrgConsoleDomains(orgId, dryRun)\n progress.apiIdempotency = await eraseOrgIdempotencyKeys(orgId, dryRun)\n\n // The org's PUBLIC marketplace identity (AGL-1970). Here rather than with\n // the Stripe step, though it carries a payout id, because `publisherHandles`\n // is a live name reservation and belongs with the other reservations this\n // step releases — and because the profile is world-readable, so the sooner\n // it stops resolving the smaller the mid-erasure window.\n const publisher = await eraseOrgPublisherIdentity(orgId, dryRun)\n progress.publisherHandles = publisher.handles\n progress.publisherProfile = publisher.profile\n progress.listingsRetained = publisher.listingsRetained\n\n // Support threads (AGL-1971). Its own step because it is the only sweep\n // here that walks a subtree per row, so it is the one most likely to be\n // where a large workspace's erasure runs out of time — and a failure that\n // cannot say WHICH step stopped it is the AGL-1455 defect again.\n step = 'support'\n progress.supportTickets = await eraseOrgSupportTickets(orgId, dryRun)\n\n /*\n * Hosts (Storage + routing + Firestore trees + the sending domain).\n *\n * Each site's dedicated domain is released here rather than by a sweep\n * that never existed: a workspace erasure used to leave one plan-capped\n * provider slot and three zone records per site standing forever, with\n * the DKIM key still live under a label a future site could claim.\n *\n * The driver is passed in and may be absent, and either way the erasure\n * finishes. What the vendors refuse is recorded as a debt on the label\n * claim, which survives the `recursiveDelete` of both the host and the\n * org, and the reaper settles it.\n */\n step = 'hosts'\n progress.hosts = 0\n progress.sendingDomains = { released: 0, deferred: 0, protected: 0 }\n for (const host of hosts.docs) {\n if (!dryRun) {\n const erased = await eraseHost(host.id, { tearDownSendingDomain })\n if (erased.sendingDomain === 'released') {\n progress.sendingDomains.released += 1\n }\n if (erased.sendingDomain === 'deferred') {\n progress.sendingDomains.deferred += 1\n }\n if (erased.sendingDomain === 'protected') {\n progress.sendingDomains.protected += 1\n }\n }\n progress.hosts += 1\n }\n\n // The backstop for what the loop above cannot enumerate (AGL-1448): a\n // `hostIndex` row whose `hosts` document is already gone. AFTER the loop\n // deliberately — the loop's own fail-soft delete is one of the things\n // that creates them, so sweeping first would leave this run's orphans\n // behind for a retry that can no longer see them.\n progress.hostIndex = await eraseOrgHostIndex(orgId, dryRun)\n\n // Org-level Storage (media/dataset assets outside the host prefix).\n step = 'org-storage'\n if (!dryRun) {\n try {\n await storageBucket().deleteFiles({ prefix: `orgs/${orgId}/` })\n } catch (error) {\n console.error(`eraseOrg: org storage cleanup failed for ${orgId}`, error)\n }\n // The org library's copies at the delivery provider (AGL-2824); each\n // site's went with its `eraseHost` above.\n await eraseMediaDeliveryScope({ collection: 'orgs', scopeId: orgId })\n }\n\n // Stripe: the customer at the processor, AND the local reverse index that\n // correlates that identity back to this workspace (AGL-1448). Deleting\n // only the first left exactly the record AGL-1028 denied to every client.\n step = 'stripe'\n // `readOrgBilling` hands back the customer for THIS deployment's Stripe\n // mode (AGL-2486), which is the only one `deleteStripeCustomer` could act\n // on anyway — it spends `STRIPE_SECRET_KEY`, and a live key cannot delete a\n // test customer. Known residue, deliberately not chased here: an org that\n // was also exercised in TEST mode leaves its test customer standing at\n // Stripe after a live erasure. That is synthetic data created by us, not\n // subject data supplied by the workspace, and reaching it would mean\n // erasure spending a second API key in a mode this deployment is not in.\n if (!dryRun) await deleteStripeCustomer(stripeCustomerId)\n progress.stripeIndex = await eraseOrgStripeIndex(orgId, dryRun)\n\n // Members' reverse index into this org.\n step = 'members'\n if (!dryRun) {\n for (const member of members.docs) {\n await firestore\n .collection('users')\n .doc(member.id)\n .collection('orgs')\n .doc(orgId)\n .delete()\n .catch(() => undefined)\n }\n }\n\n // The backstop for the members loop (AGL-1448): a `hostMemberships`\n // projection belonging to somebody who has LEFT the org, whom\n // `deleteHostProjectionForAllMembers` cannot enumerate because the\n // membership it would start from is already gone. Collection-group —\n // see the note on the function, and the index it owes.\n progress.hostMemberships = await eraseOrgHostMemberships(orgId, dryRun)\n\n // The org subtree, then every slug reservation it has ever held — the\n // current one and each rename tombstone (AGL-1448), with the matching\n // `{slug}.aglyn.com` detached (AGL-1136).\n step = 'org-tree'\n if (!dryRun) await firestore.recursiveDelete(orgRef)\n step = 'slugs'\n progress.slugs = await eraseOrgSlugs(orgId, slug, dryRun)\n } catch (error) {\n console.error(`eraseOrg: ${step} failed for ${orgId}`, error)\n await recordErasureFailure({\n orgId,\n step,\n error,\n actorUid,\n requestedAt: requestedMs,\n before,\n // How far it got, so the next reader knows what is already destroyed.\n after: { ...progress },\n })\n throw error\n }\n\n // A plan (AGL-1481). Every sweep above ran its query and skipped its write,\n // so these are the counts the real run would report — reached through the\n // same code, which is the whole point of the operator script calling this\n // function instead of listing the sweeps a second time. No audit row: an\n // erasure that did not happen is not a record of one.\n if (dryRun) {\n return {\n ok: false,\n skippedReason: 'dry-run',\n ...progress,\n hosts: hosts.size,\n members: members.size,\n }\n }\n\n // The proof of erasure, and now the only record of it: actor, action,\n // target, the request it fulfilled, the inventory found and what each sweep\n // destroyed. Ids and counts — never the content (AGL-1443).\n await firestore\n .collection('adminAudit')\n .add({\n actorUid,\n action: 'org.erased',\n target: `orgs/${orgId}`,\n before,\n after: {\n requestedAt: requestedMs,\n // What stood in for a request, when nothing did (AGL-2585). Absent on\n // every ordinary erasure, so its presence is the whole flag.\n ...(withoutRequest ? { withoutRequest: withoutRequest.reason } : {}),\n ...progress,\n },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n\n return { ok: true, ...progress, hosts: hosts.size, members: members.size }\n}\n\n/**\n * Strip an erased person from the support threads they wrote in, WITHOUT\n * destroying the threads (AGL-1971).\n *\n * The asymmetry with `eraseOrgSupportTickets` is the whole point and is worth\n * stating rather than assuming. A ticket belongs to an ORG, not to the person\n * who happened to open it: it is the workspace's support history, other people\n * on the roster can read it, and it is evidence in a dispute the workspace may\n * still be having with us. Erasing one member must not delete it — and cannot,\n * honestly: for that thread Aglyn is the processor and the ORG is the\n * controller, so deleting it on a member's instruction is acting without the\n * controller's instruction, which `docs/PRIVACY_REQUESTS.md` §6 refuses in the\n * mirror-image case. `eraseUser` also already refuses to cascade into\n * `eraseOrg` for exactly this reason.\n *\n * So the narrow correct action is redaction: `authorId` and `authorEmail` go\n * to `null` and `authorErased: true` marks why, on that person's messages\n * only. What remains is the message BODY, deliberately — it is the workspace's\n * record of a conversation with us, and it is the workspace's to delete. An\n * erasure of the ORG destroys it, which is the path that reaches it.\n *\n * **A collection-group query, not a walk over their current memberships.** The\n * membership walk is the tempting version and it is wrong in a specific way:\n * `eraseUser` only knows the orgs the person belongs to NOW, so every ticket\n * they wrote in a workspace they have since left would keep their email — the\n * residual is invisible and grows with tenure. `messages` is used as a\n * subcollection name by `supportTickets` and by nothing else in the repo, so\n * the group is exactly the right set. It needs the COLLECTION_GROUP index\n * override on `messages.authorId` in `cloud/firebase-firestore.indexes.json`,\n * added with this change.\n *\n * Returns the number of messages redacted, or **`null` when the query could\n * not run** — a missing index in an environment where it was never deployed.\n * `null` is not zero and the caller must not flatten it: it means this erasure\n * did not reach the support messages at all, and an erasure that quietly\n * reports success for a step it skipped is the failure this whole file is\n * organised against. It is reported in the result and in the audit row rather\n * than thrown, because the account deletion itself must still complete.\n */\nasync function redactUserSupportMessages(uid: string): Promise<number | null> {\n if (!uid) return 0\n const firestore = firebaseAdmin.app().firestore()\n let rows: FirebaseFirestore.QuerySnapshot\n try {\n rows = await firestore\n .collectionGroup('messages')\n .where('authorId', '==', uid)\n .get()\n } catch (error) {\n console.error(\n `eraseUser: support message redaction could not run for ${uid}`,\n error,\n )\n return null\n }\n for (let index = 0; index < rows.docs.length; index += 400) {\n const batch = firestore.batch()\n for (const doc of rows.docs.slice(index, index + 400)) {\n batch.set(\n doc.ref,\n { authorId: null, authorEmail: null, authorErased: true },\n { merge: true },\n )\n }\n await batch.commit()\n }\n return rows.size\n}\n\n/** An org that stops a person's account from being erased. */\nexport interface UserErasureBlocker {\n orgId: string\n orgName: string\n /** Whether the org still bills — the reason a human must intervene. */\n hasLiveSubscription: boolean\n /** Other people who would be stranded if this org lost its owner. */\n otherMembers: number\n}\n\nexport interface UserErasureCandidateOrg {\n orgId: string\n orgName: string\n ownerUid: string | null\n hasLiveSubscription: boolean\n memberCount: number\n}\n\n/**\n * Which orgs block erasing `uid` (AGL-1140).\n *\n * Pure so the policy can be tested without Firestore, because the policy is\n * the part worth arguing about — the deletion itself is mechanical.\n *\n * **Owning an org blocks erasure, always.** Not only when it bills, and not\n * only when other members would be stranded. The alternative — cascading\n * into `eraseOrg` — deletes a workspace, its sites and its data as a side\n * effect of someone closing a personal account, and no consent given to the\n * second act was given to the first. Blocking is recoverable; a cascade is\n * not.\n *\n * A live subscription and stranded members are reported rather than gating,\n * so the message can say WHY this org needs attention instead of a bare\n * refusal — \"transfer ownership\" is useless advice if you do not know which\n * of eleven workspaces is the problem.\n */\nexport function userErasureBlockers(\n uid: string,\n orgs: readonly UserErasureCandidateOrg[],\n): UserErasureBlocker[] {\n return orgs\n .filter((org) => org.ownerUid === uid)\n .map((org) => ({\n orgId: org.orgId,\n orgName: org.orgName,\n hasLiveSubscription: org.hasLiveSubscription,\n // The owner themselves is a member; anyone beyond that is stranded.\n otherMembers: Math.max(0, org.memberCount - 1),\n }))\n}\n\n/**\n * An address this account holds that a SECOND account also holds, which stops\n * the erasure until a human decides what it is.\n *\n * ⛔ Carries no address and no other uid. The whole question is about a second\n * customer, and a refusal that names them — or hands back an address in the\n * clear, which `emailDeliveries` is hashed precisely to avoid keeping —\n * discloses the person the refusal exists to protect. The staff account page\n * already lists this account's own addresses and marks the shared ones, so\n * the operator has the specifics without this record carrying them.\n */\nexport interface SharedAddressBlocker {\n /** `sha256(address)` — matches the delivery log's own key. Not readable. */\n key: string\n /** Where this account got it: `primary`, `provider`, `stored`. */\n sources: AccountAddressSource[]\n}\n\n/**\n * Which of an account's addresses a second account is also known to hold.\n *\n * Pure, and separate from the sweep, for the same reason `userErasureBlockers`\n * is: the policy is the arguable part. The mechanical part is deleting rows.\n *\n * ⚠️ `shared` is evidence in ONE direction. True proves a second holder;\n * false only means none was found, because no lookup exists for an account\n * holding an address through a federated provider. So this finds every case\n * we can SEE and cannot promise there is no other — which is exactly why the\n * sweep still tombstones every address it erases.\n */\nexport function sharedAddressBlockers(\n set: Pick<AccountAddressSet, 'addresses'>,\n): SharedAddressBlocker[] {\n return set.addresses\n .filter((entry) => entry.shared === true)\n .map((entry) => ({ key: entry.key, sources: [...entry.sources] }))\n}\n\nexport interface EraseUserResult {\n ok: boolean\n /** Set when the account was NOT erased. */\n skippedReason?: 'not-found' | 'owns-orgs' | 'shared-address'\n /** Orgs that must be handed over or deleted first. */\n blockers?: UserErasureBlocker[]\n /**\n * Addresses a second account also holds. Set with\n * `skippedReason: 'shared-address'`, and NOTHING was erased.\n */\n sharedAddresses?: SharedAddressBlocker[]\n /** What was actually removed, for the audit row and the caller's message. */\n deleted?: {\n subcollections: string[]\n authRecord: boolean\n photo: boolean\n /** `profiles/{uid}` — the world-readable public identity (AGL-1970). */\n profile: boolean\n /**\n * Support messages stripped of `authorId`/`authorEmail` (AGL-1971). The\n * threads themselves belong to the org and survive. **`null` means the\n * redaction could not run** — not that there was nothing to redact.\n */\n supportMessagesRedacted: number | null\n /**\n * What each plugin's eraser removed (AGL-2939), keyed by plugin id — the\n * data a plugin keeps about the person where neither the recursive\n * delete of the user document nor an org's cascade reaches, such as\n * documents under each org keyed by the uid. **`null` means that\n * plugin's eraser failed** and its data may remain; it is not zero.\n */\n plugins: Record<string, PluginUserErasureReport | null>\n /**\n * Messages removed from `emailDeliveries/{sha256(address)}/messages` —\n * the per-recipient delivery log. Zero for an account with no address on\n * file, or one we never mailed.\n *\n * Across EVERY address the account held, not just the current primary.\n */\n emailDeliveries: number\n /**\n * `emailIdentityIndex/{address}` rows released — the address-keyed\n * uniqueness claims, which no `recursiveDelete` of the user can reach.\n */\n emailIdentityIndex: number\n /**\n * How wide the address sweep actually was.\n *\n * ⚠️ `incomplete` is the field a compliance answer turns on: true means a\n * source could not be read and an address the account held may have been\n * missed, so this was not a complete erasure and must not be reported as\n * one. Counts only — never the addresses, which is the data being erased.\n */\n addressSweep: {\n /** Addresses found across the Auth record, its providers and the store. */\n resolved: number\n /** Addresses the delivery sweep actually visited. */\n erased: number\n /**\n * Addresses left INTACT because a second account also holds them.\n *\n * Always 0 on a successful erasure: a contested address refuses the\n * whole run before anything is destroyed. It is reported so the audit\n * row states that rather than leaving a reader to infer it.\n */\n contested: number\n incomplete: boolean\n }\n }\n}\n\n/**\n * Permanently erase a person's account (AGL-1140).\n *\n * Nothing did this before: `eraseHost`, `eraseSubtree` and `eraseOrg` existed\n * and no path anywhere deleted a `users/{uid}`. That was survivable while the\n * doc held a name and a phone; AGL-1133 added a postal address, and \"we have\n * no mechanism to delete it\" is a bad answer to an erasure request.\n *\n * `eraseOrg` is deliberately NOT the model here. An org is a workspace with\n * one owner; a person belongs to many orgs, so erasing them must not take\n * anything with it that outlives them.\n *\n * Order matters:\n * 1. Refuse if they own an org — see `userErasureBlockers`.\n * 1b. Refuse if a second account holds one of their addresses — see\n * `sharedAddressBlockers`. Both refusals come before any delete, so a\n * blocked erasure leaves the account exactly as it was.\n * 2. Remove their membership from every org they belong to, so no roster\n * keeps their email and no host projection keeps granting them access.\n * This runs BEFORE the profile delete: a half-erased account that still\n * appears on a roster is worse than one not yet started.\n * 3. Delete the `users/{uid}` subtree, the avatar, the world-readable\n * `profiles/{uid}` public identity (AGL-1970), redact their name and\n * email off every support message they wrote (AGL-1971), then the auth\n * record last — once that is gone there is no uid to retry with.\n */\nexport async function eraseUser(uid: string): Promise<EraseUserResult> {\n const firestore = firebaseAdmin.app().firestore()\n const userRef = firestore.collection('users').doc(uid)\n const snapshot = await userRef.get()\n const membership = await userRef.collection('orgs').get()\n // An absent profile doc is not an absent account — the doc is only born on\n // first save (AGL-1127), so the auth record and the org memberships can\n // outlive it. Treat \"nothing anywhere\" as not-found, not \"no doc\".\n if (!snapshot.exists && membership.empty) {\n const record = await findUserByUidAcrossPools(uid).catch(() => null)\n if (!record) return { ok: false, skippedReason: 'not-found' }\n }\n\n const candidates: UserErasureCandidateOrg[] = []\n for (const row of membership.docs) {\n const orgRef = firestore.collection('orgs').doc(row.id)\n const [org, members] = await Promise.all([\n orgRef.get(),\n orgRef.collection('members').count().get(),\n ])\n if (!org.exists) continue\n candidates.push({\n orgId: row.id,\n orgName: String(org.get('name') ?? row.id),\n ownerUid: (org.get('ownerUid') as string | undefined) ?? null,\n hasLiveSubscription: isBillingSubscription(org.data() as never),\n memberCount: Number(members.data().count ?? 0),\n })\n }\n const blockers = userErasureBlockers(uid, candidates)\n if (blockers.length) {\n return { ok: false, skippedReason: 'owns-orgs', blockers }\n }\n\n // Inventory BEFORE anything is deleted. Taken after the membership sweep\n // below, this under-reported: `removeOrgMember` empties `users/{uid}/orgs`,\n // and Firestore does not list a collection with no documents in it — so a\n // live run erased three subcollections and the audit record named two.\n // An erasure audit trail that understates what it destroyed is the one\n // record that has to be right. Measured 2026-08-01 (AGL-1140).\n const subcollections = (await userRef.listCollections()).map((c) => c.id)\n\n /*\n * EVERY ADDRESS, RESOLVED BEFORE ANY OF THEM IS DESTROYED.\n *\n * Two of the three sources are erased by this function itself:\n * `users/{uid}/emails` goes with the `recursiveDelete` below, and the Auth\n * record — the only place the primary lives — goes at the very end. Reading\n * them afterwards is reading nothing, and an erasure that resolves an empty\n * address set sweeps nothing while reporting success. That is the exact\n * shape of a spec passing because its fixture never reached the code, and\n * here it would be a live erasure reporting a completeness it does not have.\n *\n * `detectShared` because the SHARED case changes what the caller is told,\n * not what is destroyed — see `eraseEmailDeliveriesForAddresses`.\n */\n const pooledForAddresses = await findUserByUidAcrossPools(uid).catch(() => null)\n const addressSet: AccountAddressSet = await resolveAccountAddresses({\n uid,\n record: pooledForAddresses?.record ?? null,\n detectShared: true,\n firestore,\n }).catch(() => ({\n uid,\n primary: null,\n addresses: [],\n incomplete: true,\n }))\n\n /*\n * A SECOND ACCOUNT HOLDS ONE OF THESE ADDRESSES — REFUSE, HERE.\n *\n * The delivery log is filed under the address, so an address two accounts\n * hold has one set of rows answering \"what did you send me\" for two account\n * records. Whether that is one human with two accounts (the ordinary live\n * shape — a federated provider address that is another account's primary)\n * or a genuinely shared role mailbox like `billing@` decides whether\n * erasing those rows honours a request or destroys a stranger's history,\n * and NOTHING readable here distinguishes them. The data records that two\n * accounts name one address; it records nothing about the humans.\n *\n * Both automatic answers are unacceptable, and they fail differently:\n * erasing the second party's mail cannot be undone, while leaving it and\n * reporting the erasure complete is the gap this whole area was opened to\n * close. So neither is chosen — the erasure stops and says why.\n *\n * BEFORE the membership sweep, deliberately. A refusal raised after the\n * account is half-deleted is not a refusal, and the uid is the only handle\n * a human has to act on this: erase the auth record and there is no longer\n * anything to look at, no way to see which address was contested, and no\n * way to run the erasure once the question is settled. Refusing while the\n * account is whole is the only version of this that stays reversible.\n *\n * The remedy is ordinary staff work, not a special path: decide whether the\n * accounts are one person, detach the address from this one or merge them,\n * then run the erasure again. `/admin/users/[uid]` already lists this\n * account's addresses and marks the shared ones.\n */\n const contested = sharedAddressBlockers(addressSet)\n if (contested.length) {\n return {\n ok: false,\n skippedReason: 'shared-address',\n sharedAddresses: contested,\n }\n }\n\n // Memberships first — a roster row carries their email and a host\n // projection carries their access, and both outlive the profile doc.\n for (const candidate of candidates) {\n await removeOrgMember(candidate.orgId, uid).catch((error) => {\n console.error(`eraseUser: membership cleanup failed for ${candidate.orgId}`, error)\n })\n }\n\n // What the plugins keep about the person (AGL-2939). A document under an\n // org keyed by the uid is the shape no recursive delete on this path can\n // see — the same shape as the delivery log below. Each plugin's eraser\n // gets every workspace the person was in, because the memberships above\n // are already gone; one that fails is recorded as `null` and the erasure\n // goes on.\n const plugins = await runPluginUserErasers({\n uid,\n orgIds: candidates.map((candidate) => candidate.orgId),\n })\n\n // The avatar outlives the doc otherwise.\n let photo = false\n try {\n await storageBucket().deleteFiles({ prefix: `users/${uid}/` })\n photo = true\n } catch (error) {\n console.error(`eraseUser: storage cleanup failed for ${uid}`, error)\n }\n\n await firestore.recursiveDelete(userRef)\n\n // `profiles/{uid}` — the person's PUBLIC identity (AGL-1970).\n //\n // A separate top-level collection keyed by the uid AS THE DOCUMENT ID, so\n // `recursiveDelete(users/{uid})` above never reaches it and no reader\n // scanning this function for a sweep list is prompted to notice it. It is\n // `allow read: if true` in the rules and carries `handle`, `displayName`,\n // and `stripeAccountId`/`stripeChargesEnabled` — a Stripe Connect payout\n // destination written server-side after KYC. An erased person therefore\n // stayed publicly listed with a live payment-account identifier attached,\n // which Privacy Policy §5 (\"a genuine recursive delete… we keep no copy of\n // the erased content\") says in terms does not happen.\n //\n // No tombstone, unlike the org's publisher profile: the only reader of the\n // display name is the support forum's author rendering, and a forum post\n // losing its author name is the intended outcome of erasing its author.\n //\n // `recursiveDelete` rather than `delete` — nothing writes a subcollection\n // under a profile today, and a document delete would silently orphan the\n // first thing that does.\n let profile = false\n try {\n const profileRef = firestore.collection('profiles').doc(uid)\n profile = (await profileRef.get()).exists\n await firestore.recursiveDelete(profileRef)\n } catch (error) {\n console.error(`eraseUser: public profile delete failed for ${uid}`, error)\n }\n\n // Their name and email off every support message they wrote, in any\n // workspace, past or present (AGL-1971). The threads are the orgs' and\n // survive — see `redactUserSupportMessages`.\n const supportMessagesRedacted = await redactUserSupportMessages(uid)\n\n // Auth record LAST: once it is gone there is no uid to retry with, and\n // `authForPool` is required because a project-level delete cannot see an\n // SSO account at all (AGL-1122).\n //\n // The lookup is hoisted above the delete because the DELIVERY LOG is filed\n // under the ADDRESS, not the uid, and the auth record is the last place the\n // address exists. Erase it while there is still something to erase it by.\n let authRecord = false\n let emailDeliveries = 0\n /** Addresses actually swept — not the same as the ones we hold, on failure. */\n let erasedAddressCount = 0\n /** Addresses left intact because a second account also holds them. */\n let contestedAddressCount = 0\n /** Identity-index rows released, one per address that had one. */\n let emailIdentityIndex = 0\n try {\n const record = await findUserByUidAcrossPools(uid)\n if (record) {\n /*\n * `emailDeliveries/{sha256(address)}/messages` — every subject we ever\n * sent this person, with the times they opened and clicked. Personal\n * data by any reading of it, and reachable from neither\n * `recursiveDelete(users/{uid})` nor `profiles/{uid}` because it is\n * keyed by address in a third top-level collection. The same shape as\n * the `profiles/{uid}` omission this function already documents above:\n * a store nothing in the sweep list would prompt a reader to notice.\n *\n * EVERY ADDRESS, not the current primary. Passing one address left a\n * prior or provider-supplied address holding its full history —\n * recipient, subjects, open and click times — after the request had\n * been honoured and reported complete.\n *\n * Best-effort, like every other step here — a log that survives must\n * not stop the auth record from going.\n */\n const sweep = await eraseEmailDeliveriesForAddresses(\n addressSet.addresses,\n firestore,\n ).catch(() => null)\n emailDeliveries = sweep?.removed ?? 0\n erasedAddressCount = sweep?.addresses.length ?? 0\n contestedAddressCount = sweep?.contestedAddresses.length ?? 0\n\n /*\n * `emailIdentityIndex/{address}` — the SAME omission one collection\n * over.\n *\n * A top-level collection keyed by the address IN THE CLEAR, holding\n * `{uid, address, claimedAt}`. `recursiveDelete(users/{uid})` cannot\n * see it for exactly the reason it cannot see the delivery log, so an\n * erased account left a readable address still pointing at its uid.\n *\n * Released rather than tombstoned, unlike the delivery log: this is a\n * uniqueness claim, and `removeAccountEmail` already argues that\n * holding one after the address is gone burns the address for whoever\n * might legitimately want it next.\n *\n * ⛔ Only rows this uid actually owns. The index is the guard against\n * two accounts holding one address, and deleting another account's\n * claim would hand their sign-in identifier to the next taker.\n */\n for (const entry of addressSet.addresses) {\n try {\n const ref = firestore\n .collection(EMAIL_IDENTITY_INDEX_COLLECTION)\n .doc(entry.address)\n const indexed = await ref.get()\n if (indexed.exists && indexed.get('uid') === uid) {\n await ref.delete()\n emailIdentityIndex += 1\n }\n } catch (error) {\n console.error(`eraseUser: identity index release failed for ${uid}`, error)\n }\n }\n await authForPool(record.tenantId).deleteUser(uid)\n authRecord = true\n }\n } catch (error) {\n console.error(`eraseUser: auth record delete failed for ${uid}`, error)\n }\n\n /*\n * WHAT THE ADDRESS SWEEP ACTUALLY COVERED.\n *\n * Counts, never the addresses themselves: this collection is readable by\n * any staff role and `maskEmailAddress` exists because an audit row holding\n * a readable address made `adminAudit` the leakier of the two stores for\n * the same data. An erasure record listing the addresses it destroyed would\n * be the sharpest version of that — it would preserve, in the clear, the\n * exact set the request existed to remove.\n *\n * `addressesIncomplete` is the one that matters to a reader. It says a\n * source could not be read, so the sweep may have missed an address the\n * account held — the difference between \"erased everywhere\" and \"erased\n * everywhere we could see\", which is the difference a regulator asks about.\n */\n const addressSweep = {\n resolved: addressSet.addresses.length,\n erased: erasedAddressCount,\n contested: contestedAddressCount,\n incomplete: addressSet.incomplete,\n }\n\n await firestore\n .collection('adminAudit')\n .add({\n actorUid: 'system:erase-user',\n action: 'user.erased',\n target: `users/${uid}`,\n before: { orgs: candidates.length },\n after: {\n subcollections,\n authRecord,\n photo,\n profile,\n supportMessagesRedacted,\n plugins,\n emailDeliveries,\n emailIdentityIndex,\n addressSweep,\n },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n\n return {\n ok: true,\n deleted: {\n subcollections,\n authRecord,\n photo,\n profile,\n supportMessagesRedacted,\n plugins,\n emailDeliveries,\n emailIdentityIndex,\n addressSweep,\n },\n }\n}\n"],"names":["FieldValue","firebaseAdmin","eraseMediaDeliveryScope","deleteHostProjectionForAllMembers","detachWorkspaceDomain","CONSOLE_DOMAINS_COLLECTION","releaseConsoleDomain","authForPool","findUserByUidAcrossPools","eraseEmailDeliveriesForAddresses","resolveAccountAddresses","EMAIL_IDENTITY_INDEX_COLLECTION","removeOrgMember","runPluginUserErasers","runPluginOrgErasers","isBillingSubscription","readOrgBilling","disposeHostSendingDomain","readSendingDomainTeardownByLabel","ERASURE_HOLD_MS","storageBucket","name","process","env","NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET","app","storage","bucket","undefined","SUPPLIER_DELIVERY_COLLECTION","OUTREACH_MAILBOX_CREDENTIALS_COLLECTION","eraseHostSupplierDeliveries","hostId","firestore","rows","collection","where","get","index","docs","length","batch","doc","slice","delete","ref","commit","size","error","console","eraseHost","options","hostSnapshot","hostRef","orgId","sendingLabel","String","trim","sendingTeardown","catch","deleteFiles","prefix","scopeId","set","hosts","registerAllocations","collaboratorAllocations","merge","recursiveDelete","sendingDomain","teardown","tearDown","tearDownSendingDomain","eraseSubtree","path","scope","kind","id","deleteStripeCustomer","customerId","key","STRIPE_SECRET_KEY","fetch","method","headers","Authorization","eraseOrgApiKeys","dryRun","deleteDocsByOrgId","eraseOrgOutreachMailboxCredentials","eraseOrgSsoDomains","releaseOrgConsoleDomains","claims","primaries","Set","map","released","domain","result","eraseOrgStripeIndex","eraseOrgIdempotencyKeys","eraseOrgSlugs","currentSlug","held","slugs","has","reservation","exists","add","slug","eraseOrgHostIndex","eraseOrgHostMemberships","collectionGroup","eraseOrgPublisherIdentity","handles","profileRef","profileSnapshot","listings","Promise","all","listingsRetained","profile","erased","erasedAt","serverTimestamp","eraseOrgSupportTickets","tickets","ticket","errorLabel","source","parts","code","filter","part","join","recordErasureFailure","entry","actorUid","action","target","before","after","failedStep","step","requestedAt","at","auditError","eraseOrg","orgSnapshot","withoutRequest","orgRef","ok","skippedReason","requestedMs","toMillis","Date","now","members","stripeCustomerId","progress","plugins","apiKeys","outreachMailboxCredentials","ssoDomains","consoleDomains","apiIdempotency","publisher","publisherHandles","publisherProfile","supportTickets","sendingDomains","deferred","protected","host","hostIndex","stripeIndex","member","hostMemberships","reason","redactUserSupportMessages","uid","authorId","authorEmail","authorErased","userErasureBlockers","orgs","org","ownerUid","orgName","hasLiveSubscription","otherMembers","Math","max","memberCount","sharedAddressBlockers","addresses","shared","sources","eraseUser","userRef","snapshot","membership","empty","record","candidates","row","count","push","data","Number","blockers","subcollections","listCollections","c","pooledForAddresses","addressSet","detectShared","primary","incomplete","contested","sharedAddresses","candidate","orgIds","photo","supportMessagesRedacted","authRecord","emailDeliveries","erasedAddressCount","contestedAddressCount","emailIdentityIndex","sweep","removed","contestedAddresses","address","indexed","tenantId","deleteUser","addressSweep","resolved","deleted"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/erase.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 { FieldValue } from 'firebase-admin/firestore'\nimport { firebaseAdmin } from './firebase-admin'\nimport { eraseMediaDeliveryScope } from './media-delivery'\nimport { deleteHostProjectionForAllMembers } from './host-memberships'\nimport { detachWorkspaceDomain } from './workspace-domains'\nimport {\n CONSOLE_DOMAINS_COLLECTION,\n releaseConsoleDomain,\n} from './console-domains'\nimport { authForPool, findUserByUidAcrossPools } from './auth-pools'\nimport { eraseEmailDeliveriesForAddresses } from './email-delivery-log'\nimport {\n type AccountAddressSet,\n type AccountAddressSource,\n resolveAccountAddresses,\n} from './account-addresses'\nimport { EMAIL_IDENTITY_INDEX_COLLECTION } from './account-emails'\nimport { removeOrgMember } from './organizations'\nimport {\n runPluginUserErasers,\n type PluginUserErasureReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-user-erasure'\nimport {\n runPluginOrgErasers,\n type PluginOrgErasureReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-org-erasure'\nimport { isBillingSubscription } from '@aglyn/aglyn/server'\nimport { listPluginOrgCollections } from '@aglyn/aglyn/plugin-manager/plugin-host-collections'\nimport { readOrgBilling } from './org-billing'\nimport {\n disposeHostSendingDomain,\n readSendingDomainTeardownByLabel,\n type SendingDomainDisposition,\n type TeardownSendingDomainDriver,\n} from './sending-domain-debt'\n\n/** The reversible hold before a requested erasure is executed (AGL-485). */\nexport const ERASURE_HOLD_MS = 7 * 24 * 60 * 60 * 1000\n\n/**\n * The admin app is initialized without a default storageBucket, so every\n * bucket access must name it explicitly (same as the media routes). Falls\n * through to the admin default if the env is somehow unset.\n */\nfunction storageBucket() {\n const name = process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET\n return firebaseAdmin.app().storage().bucket(name || undefined)\n}\n\n/**\n * `supplierDeliveries` — the dropship outbox, keyed by `hostId` as a FIELD.\n *\n * The literal rather than an import: the collection belongs to\n * `libs/plugins/commerce/src/lib/server/supplier-outbox.ts`\n * (`SUPPLIER_DELIVERY_COLLECTION`), which is tagged `aglyn:addons`, and this\n * library is `scope:data` — the nx boundary forbids the edge, and reaching\n * for a dynamic import to dodge it would poison the lint graph instead. The\n * emulator spec asserts the two spellings still agree by reading that file,\n * so a rename fails a test rather than silently un-wiring this sweep.\n */\nconst SUPPLIER_DELIVERY_COLLECTION = 'supplierDeliveries'\n\n/**\n * `outreachMailboxCredentials` — the OAuth grant behind each mailbox a rep\n * connects to Outreach, keyed by the mailbox id and carrying `orgId` as a\n * FIELD.\n *\n * The literal rather than an import, for `SUPPLIER_DELIVERY_COLLECTION`'s\n * reason: the collection belongs to `libs/plugins/outreach`\n * (`OUTREACH_COLLECTIONS.mailboxCredentials` in\n * `src/lib/model/outreach.types.ts`), and this `scope:data` library may not\n * import a plugin. The plugin's own spec asserts the two spellings agree, so\n * a rename fails a test rather than silently un-wiring the sweep.\n *\n * A static sweep here, not a hook the plugin registers, because an erasure\n * cannot rely on the plugin being present. An org that switched Outreach off\n * still holds the tokens its reps granted, and the process running the\n * erasure — the cron, the operator script, a replay — may never have loaded\n * the plugin's bundle at all. A credential's lifetime cannot depend on which\n * bundles an erasing process happened to load.\n */\nconst OUTREACH_MAILBOX_CREDENTIALS_COLLECTION = 'outreachMailboxCredentials'\n\n/**\n * Destroy the site's DEAD-LETTERED supplier deliveries (AGL-1448).\n *\n * `supplierDeliveries/{id}` is TOP-LEVEL and carries `hostId` as a field, so\n * neither `recursiveDelete(hosts/{hostId})` here nor\n * `recursiveDelete(orgs/{orgId})` in `eraseOrg` can see it. That placement is\n * deliberate and correct — a subcollection would be editor-writable under the\n * permissive host catch-all, and the row carries the exact JSON body we sign\n * and POST to the supplier — so the fix is a sweep, not a move.\n *\n * **The gap is the dead letter, and the module that created it named it.**\n * `supplier-outbox.ts` DELETES a row the moment delivery succeeds, and its\n * docblock says why in terms: the body holds the buyer's email and name, and\n * a top-level document is outside the recursive delete an erasure runs. The\n * happy path was solved by that deletion. The failure path was not: after\n * `SUPPLIER_DELIVERY_MAX_ATTEMPTS` the row is left standing as `failed`,\n * because a human is supposed to act on it and the merchant has been told.\n * Nothing ever collects it afterwards — no TTL policy keys on it, no cron\n * reaps it, and the erasure could not reach it. So the one class of row that\n * survives is the one holding a THIRD PARTY's name, email and shipping\n * address, against a site whose owner has since asked to be erased and whom\n * nobody can ask to act on the dead letter any more.\n *\n * Bounded by the `hostId` field, never a collection sweep: this collection\n * holds every other merchant's orders. Chunked, because a batch caps at 500\n * and nothing bounds a dead-letter backlog below that.\n *\n * In `eraseHost` rather than `eraseOrg` on purpose — it is host-scoped data,\n * so the self-serve \"delete this site\" path gets the same cleanup as a full\n * org erasure instead of leaving the residue behind for the smaller action.\n *\n * Best-effort, like every other trailing cleanup in `eraseHost`: a site\n * delete must not fail because an outbox query did.\n */\nasync function eraseHostSupplierDeliveries(hostId: string): Promise<number> {\n if (!hostId) return 0\n try {\n const firestore = firebaseAdmin.app().firestore()\n const rows = await firestore\n .collection(SUPPLIER_DELIVERY_COLLECTION)\n .where('hostId', '==', hostId)\n .get()\n for (let index = 0; index < rows.docs.length; index += 400) {\n const batch = firestore.batch()\n for (const doc of rows.docs.slice(index, index + 400)) {\n batch.delete(doc.ref)\n }\n await batch.commit()\n }\n return rows.size\n } catch (error) {\n console.error(`eraseHost: supplier outbox cleanup failed for ${hostId}`, error)\n return 0\n }\n}\n\n/**\n * Destroy the org documents that belong to the site (AGL-3273).\n *\n * Some plugin storage is the organization's and names the site each document\n * belongs to — Marketing's email sends, `orgs/{orgId}/campaigns`, each naming\n * the site it was sent as. The `recursiveDelete(hosts/{hostId})` below does\n * not reach them, and they are still the site's: a send's sender, unsubscribe\n * page and the consent it relied on were that site's, and a send addressed to\n * a hand-typed list carries those addresses.\n *\n * Which collections, and which field names the site, is each plugin's own\n * declaration (`orgCollections` in `plugins.config.json`, read through\n * `listPluginOrgCollections`): a declaration with a `siteField` is a\n * collection whose documents belong to one site. One without — a campaign\n * container, placed on sites rather than owned by one — is left alone. Each\n * document goes with its subcollections, which is why this is a recursive\n * delete per document.\n *\n * Best-effort, like every other trailing cleanup in `eraseHost`, and per\n * collection, so one failed query leaves the others' cleanup standing.\n */\nasync function eraseHostOwnedOrgDocuments(orgId: string, hostId: string): Promise<number> {\n if (!orgId || !hostId) return 0\n const firestore = firebaseAdmin.app().firestore()\n let erased = 0\n for (const declared of listPluginOrgCollections()) {\n if (!declared.siteField) continue\n try {\n const rows = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(declared.name)\n .where(declared.siteField, '==', hostId)\n .get()\n for (const doc of rows.docs) await firestore.recursiveDelete(doc.ref)\n erased += rows.size\n } catch (error) {\n console.error(\n `eraseHost: ${declared.name} cleanup failed for ${hostId}`,\n error,\n )\n }\n }\n return erased\n}\n\n/**\n * Permanently erase a single site and everything it owns (AGL-488).\n * Unlike deleting the host doc alone (which orphans data), this cleans up\n * every trailing reference:\n * - Storage objects under `hosts/{hostId}/` (media, CDN variants),\n * - the `hostIndex/{hostId}` routing entry (subdomain/cname resolution),\n * - the owning org's `hosts` routing-map entry, and\n * - the host's Firestore tree (screens, layouts, versions, counters, …)\n * via `recursiveDelete`.\n *\n * Shared by the self-serve delete-site route and the org-erasure pipeline\n * (AGL-487). Fail-soft on Storage/index cleanup so a partial failure never\n * blocks the Firestore delete; safe to re-run.\n */\nexport interface EraseHostOptions {\n /**\n * The console-side sending-domain teardown, injected because this library\n * may not hold the credentials it needs — see `sending-domain-debt.ts`.\n *\n * Absent, the site's domain is not released inline: the debt is recorded on\n * the surviving label claim and `/api/admin/reap-sending-domains` settles\n * it. That is the only correct default here. A library the tenant runtime\n * imports cannot reach a full-access mail credential, and an erasure must\n * not fail or stall because a vendor is unreachable.\n */\n tearDownSendingDomain?: TeardownSendingDomainDriver | null\n}\n\nexport interface EraseHostResult {\n /** What became of the site's dedicated sending domain, if it had one. */\n sendingDomain: SendingDomainDisposition\n}\n\nexport async function eraseHost(\n hostId: string,\n options: EraseHostOptions = {},\n): Promise<EraseHostResult> {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n const orgId = hostSnapshot.get('orgId') as string | undefined\n\n /*==========================================\n * THE SENDING DOMAIN, READ FIRST AND RELEASED LAST.\n *\n * Read here because the pinned label lives on the host document and the\n * `recursiveDelete` at the bottom of this function destroys it. Read\n * afterwards there would be nothing left to say WHICH provider domain holds\n * a plan-capped account slot and WHICH records in our zone carry a signing\n * key for a name a future site can claim.\n *\n * Released after everything else, so a teardown that dies partway leaves the\n * claim standing and the domain findable rather than orphaning two vendors'\n * resources with nothing pointing at them.\n *=========================================*/\n const sendingLabel = String(hostSnapshot.get('sendingLabel') ?? '').trim()\n const sendingTeardown = sendingLabel\n ? await readSendingDomainTeardownByLabel(sendingLabel).catch(() => null)\n : null\n\n // Storage first (best-effort — the object tree is derived, regenerable).\n try {\n await storageBucket().deleteFiles({ prefix: `hosts/${hostId}/` })\n } catch (error) {\n console.error(`eraseHost: storage cleanup failed for ${hostId}`, error)\n }\n // And the delivery provider's copies of the site's video (AGL-2824), held\n // under the same library prefix. Nothing runs when no provider is\n // configured to store; a failure is logged, like the bucket's.\n await eraseMediaDeliveryScope({ collection: 'hosts', scopeId: hostId })\n\n // Dead-lettered supplier deliveries (AGL-1448). See the note on\n // SUPPLIER_DELIVERY_COLLECTION below for why this is here and not implied\n // by the recursiveDelete at the end of this function.\n await eraseHostSupplierDeliveries(hostId)\n\n // Routing: the middleware resolves a request to a host via hostIndex and\n // the owning org's hosts map — drop both so the subdomain/cname 404s.\n await firestore\n .collection('hostIndex')\n .doc(hostId)\n .delete()\n .catch(() => undefined)\n if (orgId) {\n // The routing entry and the site's POOLED SEATS go in the SAME write —\n // POS registers (AGL-1775) and collaborators (AGL-2439), which are the\n // same mechanism on two keys and must be released together or a deleted\n // site strands one pool while returning the other.\n //\n // `registerAllocations[hostId]` is capacity the org has\n // paid for and assigned here; a deleted site must return it to the pool\n // or the org keeps paying $89/mo for a seat pinned to a site that no\n // longer exists and cannot be reassigned from any surface. Releasing it\n // by deleting the key rather than by decrementing a counter means the\n // pool is `purchased - sum(allocations)` by arithmetic and has nothing to\n // drift out of step with.\n await firestore\n .collection('orgs')\n .doc(orgId)\n .set(\n {\n hosts: { [hostId]: FieldValue.delete() },\n registerAllocations: { [hostId]: FieldValue.delete() },\n collaboratorAllocations: { [hostId]: FieldValue.delete() },\n },\n { merge: true },\n )\n .catch(() => undefined)\n // Drop every member's reverse-index row for this host (AGL-844); the\n // members still exist here (recursiveDelete of the org, if any, is later).\n await deleteHostProjectionForAllMembers(orgId, hostId).catch(() => undefined)\n await eraseHostOwnedOrgDocuments(orgId, hostId)\n }\n\n // The host document tree (screens/layouts/versions/counters/products/…).\n await firestore.recursiveDelete(hostRef)\n\n // Never throws, and never leaves the slot unaccounted for: whatever the\n // vendors refuse is recorded on the label claim for the reaper to finish.\n return {\n sendingDomain: await disposeHostSendingDomain({\n teardown: sendingTeardown ? { ...sendingTeardown, hostId } : null,\n tearDown: options?.tearDownSendingDomain,\n }),\n }\n}\n\n/**\n * Delete a single resource document *and everything under it* (AGL-945).\n *\n * Firestore does not cascade: deleting `orgs/{o}/datasets/{d}` leaves every\n * `records/{r}` beneath it alive but unreachable from the console — still\n * billed, still matched by the rules (which match subcollection paths\n * independently of the parent), and resurrected wholesale if the parent id\n * is ever reused. Only the Admin SDK has `recursiveDelete`, so the console\n * cards route their deletes through /api/resources/erase, which lands here.\n *\n * Authorization belongs to the caller — this only walks the tree.\n */\nexport async function eraseSubtree(\n path: readonly [string, string, string, string],\n): Promise<void> {\n const firestore = firebaseAdmin.app().firestore()\n const [scope, scopeId, kind, id] = path\n await firestore.recursiveDelete(\n firestore.collection(scope).doc(scopeId).collection(kind).doc(id),\n )\n}\n\n/** Best-effort Stripe customer deletion — PII lives at the processor too. */\nasync function deleteStripeCustomer(customerId?: string): Promise<void> {\n const key = process.env.STRIPE_SECRET_KEY\n if (!key || !customerId) return\n try {\n await fetch(`https://api.stripe.com/v1/customers/${customerId}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${key}` },\n })\n } catch (error) {\n console.error(`eraseOrg: Stripe customer ${customerId} delete failed`, error)\n }\n}\n\n/**\n * Delete every API credential the org owns (AGL-1444).\n *\n * `apiKeys` is a TOP-LEVEL collection keyed by the SHA-256 of the token and\n * carrying `orgId` as a FIELD, so `recursiveDelete(orgRef)` cannot see it —\n * a path-scoped cascade is structurally blind to anything not under the path.\n * The credential therefore outlived the workspace: `verifyApiKey` kept\n * resolving the token to a live principal naming an org that no longer\n * existed. `authenticateApiV1` happens to refuse it (it reads the org doc and\n * 401s), but that is a gate one layer above the credential, and a key whose\n * revocation depends on every future caller repeating a lookup is not\n * revoked. The document is also a small record of the erased workspace in its\n * own right — a human-authored label, the creating uid, the granted scopes.\n *\n * Bounded by the `orgId` field, never a collection sweep: this collection\n * holds every other customer's integration credentials too.\n *\n * Returns the number destroyed, for the audit row — an erasure trail that\n * understates what it removed is the one record that has to be right.\n */\nasync function eraseOrgApiKeys(orgId: string, dryRun = false): Promise<number> {\n return deleteDocsByOrgId('apiKeys', orgId, dryRun)\n}\n\n/**\n * Delete every Outreach mailbox credential the org holds (AGL-2974).\n *\n * `outreachMailboxCredentials` is a TOP-LEVEL collection keyed by the mailbox\n * id and carrying `orgId` as a FIELD, so `recursiveDelete(orgRef)` cannot see\n * it — the blindness `eraseOrgApiKeys` answers, on a more dangerous document.\n * Left standing, the OAuth grant a rep gave for their own mailbox would\n * outlive the workspace it was given to: tokens that can still send as that\n * person, held for an organization that no longer exists and that nobody can\n * ask to disconnect them.\n *\n * This destroys the platform's copy of the grant; it does not call the\n * provider's revocation endpoint, which needs the plugin's provider adapter.\n * With the tokens gone the grant has nothing left to act through.\n *\n * Bounded by the `orgId` field, never a collection sweep: this collection\n * holds every other workspace's connected mailboxes too.\n *\n * Returns the number destroyed, for the audit row — an erasure trail that\n * understates what it removed is the one record that has to be right.\n */\nasync function eraseOrgOutreachMailboxCredentials(\n orgId: string,\n dryRun = false,\n): Promise<number> {\n return deleteDocsByOrgId(\n OUTREACH_MAILBOX_CREDENTIALS_COLLECTION,\n orgId,\n dryRun,\n )\n}\n\n/**\n * Delete every document in a TOP-LEVEL collection that names this org in its\n * `orgId` FIELD (AGL-1444/AGL-1448).\n *\n * The shared mechanism behind every collection a path-scoped cascade cannot\n * see. Two properties are the whole point, and neither is optional:\n *\n * - **Bounded by the field, never a collection sweep.** Every one of these\n * collections holds other customers' credentials, routing and billing\n * correlations. A `collection(name).get()` here would erase the estate.\n * - **Chunked**, because a batch caps at 500 writes and nothing bounds an\n * org's row count below that.\n *\n * Returns the number destroyed, for the audit row — an erasure trail that\n * understates what it removed is the one record that has to be right.\n *\n * `dryRun` runs the query and skips the commit (AGL-1481), so a plan counts\n * rows through the SAME function that deletes them. A separate counting pass\n * would be a second enumeration of the sweep list, which is precisely the\n * divergence AGL-1481 exists to remove.\n *\n * **`platformRevenue` must NEVER be added to this sweep (AGL-1811).** It is\n * org-keyed by field — exactly the shape this mechanism eats — and that is\n * the trap: those rows are per-transaction TAX FILING RECORDS (gross, tax,\n * jurisdiction) with a statutory retention obligation, and the quarterly\n * Texas return is their sum. GDPR Art. 17(3)(b) exempts them: erasure does\n * not extend to records retained for compliance with a legal obligation. An\n * erased org's rows deliberately outlive it — the\n * `erase-org-tax-retention.emulator.spec` pins survival, so an over-eager\n * future sweep fails a test instead of un-filing a tax period.\n *\n * **`storefrontTaxCollected` must NEVER be added either (AGL-1904).** Same\n * shape, same trap, same reason: it carries `orgId` as a field, and its rows\n * are the record of sales tax charged to shoppers on that org's storefront —\n * including the tax a `mode: 'stripe'` store collects under AGLYN's own\n * Texas registration, which Aglyn must be able to account for long after the\n * merchant has gone.\n */\nasync function deleteDocsByOrgId(\n collection: string,\n orgId: string,\n dryRun = false,\n): Promise<number> {\n if (!orgId) return 0\n const firestore = firebaseAdmin.app().firestore()\n const rows = await firestore\n .collection(collection)\n .where('orgId', '==', orgId)\n .get()\n if (dryRun) return rows.size\n for (let index = 0; index < rows.docs.length; index += 400) {\n const batch = firestore.batch()\n for (const doc of rows.docs.slice(index, index + 400)) batch.delete(doc.ref)\n await batch.commit()\n }\n return rows.size\n}\n\n/**\n * Drop the org's public SSO routing documents (AGL-1448).\n *\n * `ssoDomains/{domain}` is keyed by the DOMAIN and carries `orgId` as a field,\n * so `recursiveDelete(orgRef)` never reaches it. The org's own claims under\n * `orgs/{orgId}/ssoDomains/{domain}` do die with the tree; the public routing\n * doc — the one that decides where a sign-in goes — does not.\n *\n * **`unpublishSsoDomains` is not the fix, though it looks like it.** That\n * function is the reversible half of the console's SSO toggle: it sets\n * `active: false` and leaves the document standing precisely so re-enabling\n * restores the same pool and the same uids. Applied to an erasure it would\n * leave behind a record naming the deleted org, its GCIP tenant and provider\n * ids, its IdP display name and the customer's email domain — and, worse, a\n * live reservation: `issueDomainClaim` refuses a domain whose `ssoDomains` doc\n * belongs to another org REGARDLESS of `active`, so a ghost would hold a real\n * customer's domain against them with no way to appeal. Absence is the only\n * state that both stops the routing and releases the name.\n *\n * Deleting is safe on the read side: `/api/auth/sso-lookup` already treats a\n * missing doc and an inactive one identically (`{ ssoEnabled: false }`).\n */\nasync function eraseOrgSsoDomains(\n orgId: string,\n dryRun = false,\n): Promise<number> {\n return deleteDocsByOrgId('ssoDomains', orgId, dryRun)\n}\n\n/**\n * Release every custom console domain the org holds (AGL-1448).\n *\n * `releaseConsoleDomain` already does exactly the right thing and was called\n * from nothing but its own spec. It is wired rather than reimplemented, and\n * the ordering it enforces is the reason: Vercel detach FIRST, documents\n * second, and the claim KEPT if a detach fails. `resolveConsoleDomain`'s\n * `unknown` verdict — the one that lets localhost, previews and self-hosted\n * installs work — is only safe while every name the console project still\n * answers for has a document (AGL-1430). Deleting the claim here directly\n * would break that correspondence and hand the name to the next org that\n * claims it, with Vercel's `already-exists` reading as health.\n *\n * It takes one domain, so the org-bounded query is the part that was missing.\n * Every name in a claim set — the primary and its `www` twin — carries the\n * same `orgId` and the same `primaryHost`, so releasing once per distinct\n * primary covers the set exactly, without a second pass over the twins.\n *\n * Best-effort, like every other external cleanup here: an erasure must not\n * fail because Vercel did. A name that could not be detached keeps its claim\n * and is reported in the audit row as un-released.\n */\nasync function releaseOrgConsoleDomains(\n orgId: string,\n dryRun = false,\n): Promise<number> {\n const firestore = firebaseAdmin.app().firestore()\n const claims = await firestore\n .collection(CONSOLE_DOMAINS_COLLECTION)\n .where('orgId', '==', orgId)\n .get()\n const primaries = new Set(\n claims.docs.map((doc) => (doc.get('primaryHost') as string) || doc.id),\n )\n // A plan counts the claim sets it WOULD release. `releaseConsoleDomain`\n // detaches at Vercel, so it must not run for a dry run (AGL-1481).\n if (dryRun) return primaries.size\n let released = 0\n for (const domain of primaries) {\n const result = await releaseConsoleDomain({ orgId, domain }).catch(\n (error) => {\n console.error(`eraseOrg: console domain ${domain} release failed`, error)\n return { released: false }\n },\n )\n if (result.released) released += 1\n }\n return released\n}\n\n/**\n * Drop the local Stripe reverse index (AGL-1448, AGL-1028).\n *\n * `stripeCustomers/{stripeCustomerId} -> { orgId }` exists because AGL-1028\n * moved `stripeCustomerId` off the org doc into a manager-gated subcollection,\n * which broke the webhook's `where('stripeCustomerId', '==', …)` lookup. It is\n * denied to every client for one reason, stated in the rules: readable, it\n * maps a billing identity back to a workspace.\n *\n * The erasure had this exactly inverted. It deleted the customer AT STRIPE and\n * kept the local index — so the artefact that survived was the correlation the\n * whole issue existed to prevent, now pointing at an org that no longer exists\n * to be gated on. This deletes the local rows. The Stripe-side delete is\n * unchanged and still runs: PII lives at the processor too, and one is not a\n * substitute for the other.\n *\n * Bounded by the `orgId` field rather than by the org's CURRENT customer id,\n * because nothing cleans up the index when a customer id changes — an org that\n * was re-created in Stripe has more than one row pointing at it.\n */\nasync function eraseOrgStripeIndex(\n orgId: string,\n dryRun = false,\n): Promise<number> {\n return deleteDocsByOrgId('stripeCustomers', orgId, dryRun)\n}\n\n/**\n * Drop the org's REST API replay keys (AGL-1448).\n *\n * `apiIdempotency/{sha256(orgId:key)}` dedupes `POST`s by `Idempotency-Key`\n * (AGL-618). Low severity — a replay key names an org and a record id and\n * authorises nothing — but it does NOT age out on its own, which is the part\n * worth writing down because the assumption runs the other way: the only TTL\n * policy on the database is `rateLimits.expiresAt`\n * (`docs/FIRESTORE_MANUAL_CONFIG.md`), and these documents carry no expiry\n * field for a policy to key on. Left alone they accumulate against a dead org\n * indefinitely.\n *\n * Taken with the credentials rather than at the end: the record ids they carry\n * point into the org tree that step 4 destroys.\n */\nasync function eraseOrgIdempotencyKeys(\n orgId: string,\n dryRun = false,\n): Promise<number> {\n return deleteDocsByOrgId('apiIdempotency', orgId, dryRun)\n}\n\n/**\n * Release every slug the org has EVER held (AGL-1448).\n *\n * The erasure deleted `orgSlugs/{org.slug}` — the current name only. But\n * `changeOrgSlug` leaves a tombstone at the previous slug (`{ orgId, movedTo,\n * renamedAt }`) so old workspace URLs keep redirecting (AGL-585/AGL-236), and\n * nothing ever collected them. Every historical name an org held therefore\n * survived its erasure, in the one collection the rules make\n * `allow read: if true` — public because it doubles as the pre-auth health\n * probe. An erased workspace's naming history stayed world-readable, each\n * tombstone still naming the dead org id.\n *\n * Bounded by the `orgId` field, which is exactly the right boundary here and\n * not merely the safe one: claiming a tombstoned slug FULL-REPLACES the\n * document with the new owner's `{ orgId }`, so a name this org renamed away\n * from and somebody else has since taken does not match — and must not be\n * swept, because it is now a live tenant's reservation.\n *\n * The current slug is unioned in rather than assumed present: an org whose\n * reservation was lost to a legacy write would otherwise keep its workspace\n * subdomain attached to the project. ⛔ ONLY WHEN NOBODY HOLDS IT (AGL-2585).\n * A workspace address taken by an unverified signup is a reservation that\n * lapses, and a live workspace may since have claimed the name — at which\n * point `orgSlugs/{slug}` names THAT org, and the erasure of this one would\n * otherwise delete a working tenant's reservation and detach their subdomain.\n * The `orgId` query above already refuses that document; this is the one line\n * that used to add it back by assumption.\n *\n * Each name's `{slug}.aglyn.com` is detached too. `changeOrgSlug` deliberately\n * KEEPS the old subdomain attached so the tombstone's 308 has a hostname to\n * run on; once the tombstone goes, the attachment is a name resolving to a\n * console for an org that does not exist. Best-effort, like every other\n * external cleanup here: an erasure must not fail because a DNS API did.\n */\nasync function eraseOrgSlugs(\n orgId: string,\n currentSlug?: string,\n dryRun = false,\n): Promise<number> {\n const firestore = firebaseAdmin.app().firestore()\n const held = await firestore\n .collection('orgSlugs')\n .where('orgId', '==', orgId)\n .get()\n const slugs = new Set(held.docs.map((doc) => doc.id))\n if (currentSlug && !slugs.has(currentSlug)) {\n // Absent means the reservation was lost and only the DNS attachment is\n // left to clean up. Present but not matched by the query above means it\n // belongs to another org now, and is not this erasure's to touch.\n const reservation = await firestore\n .collection('orgSlugs')\n .doc(currentSlug)\n .get()\n .catch(() => null)\n if (reservation && !reservation.exists) slugs.add(currentSlug)\n }\n // `detachWorkspaceDomain` calls a live DNS API — never on a plan (AGL-1481).\n if (dryRun) return slugs.size\n for (const slug of slugs) {\n await firestore.collection('orgSlugs').doc(slug).delete().catch(() => undefined)\n await detachWorkspaceDomain(slug).catch(() => undefined)\n }\n return slugs.size\n}\n\n/**\n * Sweep up ORPHANED `hostIndex` rows the per-host loop cannot enumerate\n * (AGL-1448).\n *\n * `hostIndex/{hostId}` is `{ orgId, subdomain }` and `allow read: if\n * isSignedIn()`. It is deleted today inside `eraseHost`, which `eraseOrg`\n * calls once per document in `hosts where orgId == org` — so the row is\n * reached DERIVATIVELY, through a second collection, and only for as long as\n * the two agree.\n *\n * **The erasure is itself one of the ways they stop agreeing.** `eraseHost`'s\n * `hostIndex` delete is `.catch(() => undefined)` — fail-soft on purpose, so\n * a transient failure never blocks the Firestore delete — and the very next\n * statement `recursiveDelete`s `hosts/{hostId}`. The instant that lands, the\n * orphaned index row is unreachable from the org side forever: nothing points\n * at it, so neither this run nor any retry can ever enumerate it again. A\n * fail-soft that manufactures a PERMANENT orphan is not fail-soft, and the\n * residue is a signed-in-readable row naming a dead org and the subdomain its\n * customer chose.\n *\n * Bounded by the `orgId` field — this is the table the middleware resolves\n * every request on, so an unbounded sweep here does not lose an index, it\n * takes the estate offline.\n *\n * A BACKSTOP, not a replacement: the per-host loop still does the real work\n * (Storage, the org routing map, the member projections, the host tree). This\n * only collects what that loop structurally cannot see.\n */\nasync function eraseOrgHostIndex(\n orgId: string,\n dryRun = false,\n): Promise<number> {\n return deleteDocsByOrgId('hostIndex', orgId, dryRun)\n}\n\n/**\n * Sweep up `hostMemberships` projections belonging to FORMER members\n * (AGL-1448).\n *\n * `users/{uid}/hostMemberships/{hostId}` carries `{ orgId, subdomain,\n * displayName, nameLower, role }` — the site switcher's row. It lives under a\n * USER, so `recursiveDelete(orgs/{orgId})` cannot reach it, and `eraseOrg`'s\n * members loop only walks `orgs/{orgId}/members`.\n *\n * `deleteHostProjectionForAllMembers(orgId, hostId)` clears the row for\n * everyone in that member list at the moment the erasure runs. **A former\n * member is not in it.** Their row is written by the projection fan-out and\n * removed by `removeOrgMember`, which is fail-soft in the same way as\n * everything else on that path; one missed fan-out leaves a row no later\n * erasure can find, because the enumeration starts from a membership that no\n * longer exists. The erased workspace's site NAME and subdomain then live on\n * in the user document of somebody who left the company.\n *\n * ## This is a COLLECTION-GROUP query, and that owes an index\n *\n * `hostMemberships` has two composite indexes, both `queryScope: COLLECTION`.\n * A collection-group equality on `orgId` needs a `COLLECTION_GROUP`\n * single-field index, which Firestore does NOT create automatically — the\n * automatic single-field indexes are collection-scoped except for\n * array-contains. `cloud/firebase-firestore.indexes.json` therefore gains a\n * `fieldOverrides` entry alongside the existing `members.uid` and\n * `messages.authorId` ones, and **it must be deployed by hand.**\n *\n * Until it is, the query throws `FAILED_PRECONDITION`. AGL-1793 is this\n * codebase's record of exactly that failure mode — both commerce crons\n * silently dead in production for want of a `COLLECTION_GROUP_ASC` override —\n * so this refuses to be the thing that kills an erasure. It is caught and\n * reported as `null`, which is deliberately NOT `0`: a zero would say \"there\n * was nothing to sweep\" and a missing index would wear that as a clean bill\n * of health. `null` in the audit row says \"attempted, could not run\", and the\n * spec asserts a NUMBER so the wired-and-indexed path is what is pinned.\n */\nasync function eraseOrgHostMemberships(\n orgId: string,\n dryRun = false,\n): Promise<number | null> {\n if (!orgId) return 0\n try {\n const firestore = firebaseAdmin.app().firestore()\n const rows = await firestore\n .collectionGroup('hostMemberships')\n .where('orgId', '==', orgId)\n .get()\n if (dryRun) return rows.size\n for (let index = 0; index < rows.docs.length; index += 400) {\n const batch = firestore.batch()\n for (const doc of rows.docs.slice(index, index + 400)) {\n batch.delete(doc.ref)\n }\n await batch.commit()\n }\n return rows.size\n } catch (error) {\n console.error(\n `eraseOrg: hostMemberships projection sweep failed for ${orgId} — a missing COLLECTION_GROUP index on hostMemberships.orgId is the likeliest cause`,\n error,\n )\n return null\n }\n}\n\n/**\n * What an erasure did with `publisherProfiles/{orgId}` (AGL-1970).\n *\n * Three outcomes rather than a boolean, because the third one is a decision\n * the audit row has to be able to state: a listing the erased org published\n * can outlive it, and a listing whose publisher document is simply gone is\n * unattributable in a way nobody chose.\n */\nexport type PublisherProfileDisposition = 'absent' | 'deleted' | 'tombstoned'\n\n/**\n * Drop the org's PUBLIC marketplace identity — the profile and every handle\n * it reserved (AGL-1970).\n *\n * Both survived every erasure until now, and both are `allow read: if true`\n * (`cloud/firebase-firestore.rules`, `match /publisherProfiles/{orgId}`). This\n * is the AGL-1448 shape with one twist that is the whole reason it was missed:\n * `publisherProfiles` is keyed by the org id **as the document id**, so it is\n * invisible to `deleteDocsByOrgId` *and* to a reader scanning this file for\n * the field-keyed sweep list. Nothing prompts you to notice it.\n *\n * What survived is not cosmetic. `stripeAccountId` is a **payout\n * destination** — written server-side only after Connect onboarding/KYC and\n * trusted by every checkout path — so an erased org stayed world-readable with\n * a live payment-account identifier attached to a dead identity. That is the\n * `stripeCustomers` correlation AGL-1448 removed, pointing the other way.\n *\n * `publisherHandles` is the easy half and goes unconditionally: it carries\n * `orgId` as a FIELD, so `deleteDocsByOrgId` fits exactly, and it is a **live\n * reservation** — `claimPublisherHandle` refuses a handle another org's row\n * names, so a ghost holds a marketplace name against a real customer with no\n * way to appeal. That is the `ssoDomains` argument verbatim, and absence is\n * again the only state that both stops the read and releases the name. Rename\n * tombstones (`{ orgId, movedTo }`) carry the same `orgId` and go with it, and\n * the field bound is what makes that safe: re-claiming a tombstone FULL-\n * REPLACES it with the new owner's `{ orgId }`, so a handle this org renamed\n * away from and somebody else has since taken does not match the query.\n *\n * **The profile is the half with a genuine tension, and it is resolved here\n * rather than deferred.** `marketplaceListings` outlives an erasure — that is\n * AGL-1448's parked Tier 3 product decision, an erased org's listing being\n * something buyers paid for — so deleting the publisher document outright can\n * leave a listing attributed to nothing. So:\n *\n * - **No surviving listing** → `recursiveDelete` the profile. `deleted`.\n * `recursiveDelete` and not `delete`: `publish-plugin` keeps its daily\n * publish-rate window at `publisherProfiles/{orgId}/meta/publishWindow`,\n * and a document delete would orphan it under a path with no owner.\n * - **A listing survives** → the same `recursiveDelete`, then a minimal\n * `{ erased: true, erasedAt }` in its place. `tombstoned`. The tombstone\n * carries no `handle`, no `displayName`, no `bio`/`avatarUrl`/`website`,\n * no `publisherAgreement` and — the point — no `stripeAccountId`. It is\n * \"an internal record that the erasure happened\", which is exactly what\n * the Privacy Policy §5 sentence reserves; every byte the sentence calls\n * content is gone either way. Readers already handle it: with no `handle`,\n * `resolvePublisherProfile` returns `null`, which is the pre-existing\n * \"this org has no profile\" path.\n *\n * The count of surviving listings is returned either way, so an erasure that\n * left something standing SAYS SO in its audit row instead of reporting a\n * clean success — the Tier 3 decision is still open, and an erasure is the\n * one place its cost is measurable.\n */\nasync function eraseOrgPublisherIdentity(\n orgId: string,\n dryRun = false,\n): Promise<{\n handles: number\n profile: PublisherProfileDisposition\n listingsRetained: number\n}> {\n const firestore = firebaseAdmin.app().firestore()\n const handles = await deleteDocsByOrgId('publisherHandles', orgId, dryRun)\n const profileRef = firestore.collection('publisherProfiles').doc(orgId)\n const [profileSnapshot, listings] = await Promise.all([\n profileRef.get(),\n // `profileId` is the listing's publishing-org id (AGL-652) — the same\n // value as this profile's document id, under the older field name.\n firestore.collection('marketplaceListings').where('profileId', '==', orgId).get(),\n ])\n const listingsRetained = listings.size\n if (!profileSnapshot.exists) {\n return { handles, profile: 'absent', listingsRetained }\n }\n if (dryRun) {\n return {\n handles,\n profile: listingsRetained ? 'tombstoned' : 'deleted',\n listingsRetained,\n }\n }\n await firestore.recursiveDelete(profileRef)\n if (!listingsRetained) return { handles, profile: 'deleted', listingsRetained }\n await profileRef.set({ erased: true, erasedAt: FieldValue.serverTimestamp() })\n return { handles, profile: 'tombstoned', listingsRetained }\n}\n\n/**\n * Destroy every support ticket the org opened, and the messages under each\n * (AGL-1971).\n *\n * `supportTickets/{id}` is top-level with `orgId` as a field — the exact shape\n * `deleteDocsByOrgId` exists for — and it was in neither sweep list. It is\n * also the highest-PII-density collection outside the org tree: `messages`\n * rows carry `authorId`, `authorEmail` and up to 5000 characters of prose a\n * customer wrote to us, which is where somebody pastes the invoice, the member\n * list, or the very data they are asking us to delete.\n *\n * **`deleteDocsByOrgId` is deliberately NOT used here**, though the query is\n * identical. It deletes documents, not subtrees, and a batch delete of the\n * ticket documents would leave every `messages` row alive under a parent that\n * no longer exists — the prose and the email surviving the delete that was\n * supposed to remove them, now unreachable by any query that starts from\n * `orgId`. `recursiveDelete` per ticket is the only correct shape.\n *\n * **No retention exception, and the alternative was considered.** A closed\n * support thread is arguable dispute evidence, which is the class of argument\n * that keeps `platformRevenue` alive (AGL-1811). It does not reach here: that\n * exemption is GDPR Art. 17(3)(b), *records retained to comply with a legal\n * obligation*, and no law requires us to keep a support thread — a Texas tax\n * return is filed from `platformRevenue`; nothing is filed from a ticket.\n * Live DPA §11 commits the other way in terms (\"delete or return Customer\n * Personal Data, except to the extent retention is required by law\"). What\n * remains as evidence is the `adminAudit` row: the erasure happened, when, at\n * whose instruction, and how many tickets it destroyed — ids and counts,\n * never content (AGL-1443).\n *\n * Sequential rather than parallel: `recursiveDelete` is itself a bulk writer,\n * and fanning N of them out at once is how a 60-second cron loses an erasure.\n */\nasync function eraseOrgSupportTickets(\n orgId: string,\n dryRun = false,\n): Promise<number> {\n if (!orgId) return 0\n const firestore = firebaseAdmin.app().firestore()\n const tickets = await firestore\n .collection('supportTickets')\n .where('orgId', '==', orgId)\n .get()\n if (dryRun) return tickets.size\n for (const ticket of tickets.docs) {\n await firestore.recursiveDelete(ticket.ref)\n }\n return tickets.size\n}\n\n/**\n * The steps of `eraseOrg` after the hold check, named so a failed attempt can\n * say WHERE it stopped (AGL-1455).\n *\n * `export` used to lead this list, and its removal is the AGL-1443 change: an\n * erasure no longer writes anything before it starts deleting, so there is no\n * longer a step whose meaning is \"nothing is gone yet\". `credentials` is now\n * the first thing that can fail, and it destroys as it goes.\n */\ntype EraseStep =\n | 'plugins'\n | 'credentials'\n | 'support'\n | 'hosts'\n | 'org-storage'\n | 'stripe'\n | 'members'\n | 'org-tree'\n | 'slugs'\n\n/**\n * The two things an OPERATOR needs that a served caller does not (AGL-1481).\n *\n * `tools/scripts/erase-tenant.mjs` is the manual path staff reach for when the\n * cron is stuck. It used to be a second implementation of this function, and it\n * drifted: by the time AGL-1481 was filed it was missing four sweeps added that\n * week and still wrote a complete dump of the workspace to the operator's\n * working directory. The fix is that it CALLS this function — which means the\n * capabilities it had and this function lacked have to live here instead.\n *\n * Neither is a hold bypass, deliberately. The script refused to run before the\n * 7-day hold elapsed and this function refuses too; an `ignoreHold` flag would\n * have been the easy way to keep the two paths identical and is the one\n * difference worth preserving as a refusal on both.\n */\nexport interface EraseOrgOptions {\n /**\n * Count what an erasure WOULD destroy and destroy nothing. Every sweep runs\n * its query and skips its write, so the plan is produced by the same list of\n * sweeps that the erasure is — a separate counting pass would be exactly the\n * second enumeration this issue removed. Nothing external is touched (no\n * Storage, no Stripe, no Vercel, no DNS) and no audit row is written.\n *\n * Returns `ok: false` with `skippedReason: 'dry-run'`: `ok` means the org was\n * erased, and a plan did not erase it. A caller that treats a plan as an\n * erasure is the one mistake this shape has to make impossible.\n */\n dryRun?: boolean\n /**\n * Who is answerable for this erasure, for the audit row. Defaults to the\n * cron. A staff member running the script by hand is not the scheduler, and\n * an erasure trail that says otherwise names the wrong actor for the single\n * most irreversible action the platform performs.\n */\n actorUid?: string\n /**\n * The console-side sending-domain teardown, passed through to every host\n * this erasure destroys. See {@link EraseHostOptions.tearDownSendingDomain}.\n *\n * Never consulted on a dry run: a plan touches no vendor.\n */\n tearDownSendingDomain?: TeardownSendingDomainDriver | null\n /**\n * Erase a workspace that carries NO erasure request at all (AGL-2585).\n *\n * ⛔ EXACTLY ONE CALLER, and the option exists so that caller has to say so\n * out loud. `/api/admin/reap-unverified-orgs` collects workspaces created by\n * a signup whose address was never confirmed — there is nobody to ask for an\n * erasure, because nobody has been shown to exist. Every other path reaches\n * this function through `erasureRequestedAt`, which is a person's decision\n * with a seven-day hold on it, and none of them passes this.\n *\n * What it turns off is narrow and named: the `no-request` refusal and the\n * hold, and nothing else. Every sweep, every audit row and `dryRun` behave\n * identically — the caller supplies the proof that this workspace is\n * nobody's, and this records WHAT that proof was on the audit row.\n *\n * It does not skip the request when one exists. An org somebody has already\n * asked to erase is still that person's decision, and the reaper refuses\n * such an org before it ever gets here.\n */\n withoutRequest?: { reason: string } | null\n}\n\nexport interface EraseOrgResult {\n ok: boolean\n /**\n * Set when the org was NOT erased (flag missing, hold not elapsed, gone) —\n * and `'dry-run'` when nothing was erased because nothing was meant to be.\n */\n skippedReason?: string\n hosts?: number\n /** Member back-references cleaned up — reported so a plan can show them. */\n members?: number\n /** API credentials destroyed (AGL-1444) — outside the org path. */\n apiKeys?: number\n /**\n * What each plugin's workspace eraser did (AGL-2978), keyed by plugin id:\n * counts and flags, such as grants revoked at a provider. The erasers run\n * before anything below is deleted. **`null` means that plugin's eraser\n * failed** and its share may not be done; it is not zero. A plan carries\n * each eraser's count of what it would do.\n */\n plugins?: Record<string, PluginOrgErasureReport | null>\n /** Outreach mailbox grants destroyed (AGL-2974) — outside the org path. */\n outreachMailboxCredentials?: number\n /** Public SSO routing docs destroyed (AGL-1448) — outside the org path. */\n ssoDomains?: number\n /** Custom console domains released (AGL-1448) — outside the org path. */\n consoleDomains?: number\n /** REST API replay keys destroyed (AGL-1448) — no TTL reaps these. */\n apiIdempotency?: number\n /** Local Stripe reverse-index rows destroyed (AGL-1448/AGL-1028). */\n stripeIndex?: number\n /** Slugs released — the current one AND every tombstone (AGL-1448). */\n slugs?: number\n /**\n * ORPHANED `hostIndex` rows collected (AGL-1448) — ones with no surviving\n * `hosts` document for the per-host loop to reach them through.\n */\n hostIndex?: number\n /**\n * `hostMemberships` projections collected (AGL-1448), including FORMER\n * members' rows that no membership list names.\n *\n * `null` means the sweep could not RUN — almost always the missing\n * `COLLECTION_GROUP` index on `hostMemberships.orgId`. Deliberately not\n * `0`: an erasure that could not reach a collection must not report the\n * same number as one that reached it and found nothing.\n */\n hostMemberships?: number | null\n /** Marketplace handle reservations released (AGL-1970) — id-keyed, public. */\n publisherHandles?: number\n /** What became of the org's public publisher profile (AGL-1970). */\n publisherProfile?: PublisherProfileDisposition\n /**\n * Marketplace listings that OUTLIVE this erasure and still name the org\n * (AGL-1970/AGL-1448 Tier 3). Reported rather than deleted: an erasure that\n * leaves something standing has to say so, and this is the number that makes\n * the parked listing-survival decision cost something measurable.\n */\n listingsRetained?: number\n /**\n * Support threads destroyed, each with its `messages` subtree (AGL-1971) —\n * the highest-PII-density collection outside the org path.\n */\n supportTickets?: number\n /**\n * What became of the erased sites' dedicated sending domains.\n *\n * `deferred` is the number an operator reads: those provider slots and zone\n * records are still held, the debt is on each label claim, and\n * `/api/admin/reap-sending-domains` is what settles them. The erasure itself\n * is complete either way — a vendor outage may delay a slot's release and\n * may not delay a deletion somebody has a legal right to.\n */\n sendingDomains?: {\n released: number\n deferred: number\n /** Shared pool members refused. Always 0; a non-zero is a real alarm. */\n protected: number\n }\n}\n\n/**\n * What an in-flight erasure has destroyed so far — counts only.\n *\n * `members` is excluded: the inventory already reports it as `before.members`\n * on both audit rows, and a second copy under `after` would say the same\n * number twice.\n */\ntype EraseProgress = Omit<EraseOrgResult, 'ok' | 'skippedReason' | 'members'>\n\n/**\n * A stable label for an error, carrying no free text (AGL-1455).\n *\n * The class name and the SDK's error code are enough to tell a Firestore\n * permission denial from a deadline exceeded from an out-of-memory kill. The\n * MESSAGE is deliberately not stored: a Firestore or Storage error quotes the\n * path — and sometimes the payload — it choked on, and this row lands in a\n * collection that must not become a second copy of what the erasure failed to\n * delete (AGL-1443). The full error still goes to `console.error`, which is\n * the log, not the record.\n */\nfunction errorLabel(error: unknown): string {\n const source = (error ?? {}) as { name?: unknown; code?: unknown }\n const parts = [source.name, source.code].filter(\n (part) => typeof part === 'string' || typeof part === 'number',\n )\n return parts.length ? parts.join(':') : 'unknown'\n}\n\n/**\n * Record a failed erasure attempt where somebody will actually find it\n * (AGL-1455).\n *\n * Aborting when the export cannot be written is correct and stays — the bug\n * was that the abort was invisible. `skippedReason` reached exactly one\n * place, the cron endpoint's HTTP response body, and nobody reads a\n * scheduler's 200. Meanwhile `erasureRequestedAt` stays set, so the same org\n * is retried on the next run and fails the same way indefinitely, and the\n * customer who requested the erasure is told nothing.\n *\n * An `adminAudit` row is the cheapest durable form and the one the success\n * path already uses, so the two read as a pair: same `actorUid`, same\n * `target`, same `before` inventory, and `after` carrying what actually\n * happened. Staff can find every stuck erasure by filtering the audit log on\n * the action, which is exactly the search that was impossible before.\n *\n * `after.exportWritten`/`after.exportPath` used to be the fields that\n * mattered here: they named the complete dump of a still-existing workspace\n * that a post-export failure left in the bucket. AGL-1443 removed the write,\n * so that state is no longer reachable and the fields no longer exist. What\n * replaces them is `after.requestedAt` — WHICH erasure request is stuck,\n * which the object's name used to be the only record of.\n *\n * Ids, counts, a step name and a timestamp. Never customer data.\n *\n * Best-effort and never throws: this runs while an erasure is already\n * failing, and losing the original error to a bookkeeping write would be\n * worse than losing the row.\n */\nasync function recordErasureFailure(entry: {\n orgId: string\n step: EraseStep\n error: unknown\n /** Whoever asked for the erasure — the cron, or a named operator. */\n actorUid: string\n /** Millis of the `erasureRequestedAt` this attempt was fulfilling. */\n requestedAt: number\n before: { hosts: number; members: number }\n after: Record<string, unknown>\n}): Promise<void> {\n await firebaseAdmin\n .app()\n .firestore()\n .collection('adminAudit')\n .add({\n actorUid: entry.actorUid,\n action: 'org.erase-failed',\n target: `orgs/${entry.orgId}`,\n before: entry.before,\n after: {\n failedStep: entry.step,\n error: errorLabel(entry.error),\n requestedAt: entry.requestedAt,\n ...entry.after,\n },\n at: FieldValue.serverTimestamp(),\n })\n .catch((auditError) => {\n console.error(\n `eraseOrg: could not record the failed attempt for ${entry.orgId}`,\n auditError,\n )\n })\n}\n\n/**\n * Permanently erase an organization once its 7-day hold has elapsed\n * (AGL-485/487). Runs from the automated cron and the manual staff path.\n *\n * **Both of those are literally this function (AGL-1481).** The manual path,\n * `tools/scripts/erase-tenant.mjs`, used to be a second implementation, and a\n * second implementation of a cascade delete is a divergence with a schedule:\n * within a week of `eraseOrgApiKeys`, the SSO/console-domain release and the\n * org-keyed index sweeps landing here, the script was missing all four and\n * reporting success without them — leaving a live API credential, live domain\n * reservations, `orgSlugs` tombstones and the `stripeCustomers` reverse index\n * behind an erasure a human had been told was complete. The script now calls\n * this, and `EraseOrgOptions` is where the two capabilities it had and this\n * did not (a plan, and a named actor) live instead.\n *\n * Order matters and every step is defensive:\n * 1. Re-read the org and re-verify erasureRequestedAt + hold — never\n * delete on a stale/cancelled request.\n * 2. Nothing is written. This step used to persist a final JSON export of\n * the org and host trees to `erasures/{orgId}/…` and abort if it could\n * not — see the note below for why it is gone (AGL-1443).\n * 3. Revoke the org's API credentials (AGL-1444), its public routing — SSO\n * domains and custom console domains (AGL-1448) — and its public\n * marketplace identity: the publisher profile and every handle it\n * reserved (AGL-1970); and destroy the OAuth grants behind its reps'\n * Outreach mailboxes (AGL-2974). All of them are top-level collections\n * keyed by something other than a path under the org, so the org-tree\n * delete cannot reach them; doing them before the content delete also\n * closes the mid-erasure window, in which a credential, a domain or a\n * public publisher page still resolves to a half-deleted workspace.\n * 4. Delete each host (eraseHost), org-level Storage, the Stripe customer\n * AND its local reverse index, member back-references, then the org tree\n * and every slug the org ever held — tombstones included (AGL-1448).\n * 5. Audit.\n *\n * Every ending is audited, not just the successful one (AGL-1455). A throw\n * from any step writes an `org.erase-failed` row before re-throwing, because\n * `erasureRequestedAt` stays set on a failure: without a record the org is\n * retried and fails identically on every subsequent run, forever, and nothing\n * anywhere says so.\n *\n * **The erasure writes no copy of the workspace (AGL-1443).** It used to\n * persist `erasures/{orgId}/{requestedMs}.json` — and \"export\" understated\n * it: `exportDocTree` recursed `listCollections()` with no bound and copied\n * `snapshot.data()` wholesale, so the object was a complete verbatim copy of\n * the org tree and every host tree, carrying `webhooks.secret` (a plaintext\n * HMAC key), `orders.paymentLinkUrl` (a live bearer URL that lets its holder\n * pay), `screens.protection.passwordHash` and `ssoDomains.token`. It landed\n * on a prefix this function's own storage sweep does not cover, in a bucket\n * with no lifecycle rule, so the most sensitive object the platform can\n * produce outlived the request that deleted everything else — indefinitely,\n * unread, and belonging to a customer who had been told their workspace was\n * gone. Four facts decided it, and none of them is about tidiness:\n *\n * - **Nothing read it.** One producer, zero consumers in the repo; the\n * owner's confirmation email never mentioned it.\n * - **A governed full copy already exists.** Firestore backups run weekly\n * with 14-week retention (AGL-871), so the dump added no recoverability —\n * only a second, ungoverned place the data lived.\n * - **The minimum that proves an erasure was already being written.** The\n * `adminAudit` row below is ids and counts, in a collection with a\n * retention policy. The one thing the object's NAME carried that the row\n * did not — which request this run fulfilled — is now `after.requestedAt`.\n * - **The DPA commits to \"a limited period, after which deleted or\n * de-identified\"**, which indefinite retention of a full dump sits\n * outside of.\n *\n * Two consequences are deliberate, not side effects. The export-write abort\n * is gone with the write: there is nothing left to fail before the first\n * delete, so an erasure can no longer be blocked by Storage. And the export\n * was the only unbounded work in this function — it built the whole workspace\n * in memory inside a 60-second cron, so the larger the workspace the likelier\n * its erasure silently never happened (AGL-1455 half 1). That defect is\n * removed by deletion rather than by a streaming exporter.\n */\nexport async function eraseOrg(\n orgId: string,\n options: EraseOrgOptions = {},\n): Promise<EraseOrgResult> {\n const {\n dryRun = false,\n actorUid = 'cron:run-erasures',\n tearDownSendingDomain = null,\n withoutRequest = null,\n } = options\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const orgSnapshot = await orgRef.get()\n if (!orgSnapshot.exists) return { ok: false, skippedReason: 'not-found' }\n\n const requestedMs = orgSnapshot.get('erasureRequestedAt')?.toMillis?.() ?? null\n // The request and its hold, unless the caller carries its own proof\n // (AGL-2585). Both refusals are skipped together: a workspace with no\n // request has no stamp for a hold to run from, so keeping the second while\n // dropping the first would be a hold measured against `null`.\n if (!withoutRequest) {\n if (!requestedMs) return { ok: false, skippedReason: 'no-request' }\n if (Date.now() - requestedMs < ERASURE_HOLD_MS) {\n return { ok: false, skippedReason: 'hold-active' }\n }\n }\n\n const hosts = await firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .get()\n const members = await orgRef.collection('members').get()\n const slug = orgSnapshot.get('slug') as string | undefined\n // AGL-1028: moved to `orgs/{orgId}/billing/stripe`; the helper falls back\n // to the org doc for orgs the backfill has not reached.\n const stripeCustomerId = (await readOrgBilling(orgId)).stripeCustomerId as\n | string\n | undefined\n\n // The inventory both audit rows report, taken before anything is touched.\n const before = { hosts: hosts.size, members: members.size }\n\n // No export. The write that used to stand here is gone (AGL-1443) — see\n // the note above this function. Nothing is persisted anywhere before the\n // deleting starts, and `requestedMs` now travels in the audit row rather\n // than in an object's name.\n\n // None of what follows was guarded (AGL-1455) — `eraseOrgApiKeys`,\n // `eraseOrgSsoDomains`, `eraseOrgIdempotencyKeys` and `recursiveDelete` all\n // throw. A throw here leaves a workspace half destroyed and still standing,\n // recorded nowhere. Each step now names itself, and the error is re-thrown\n // unchanged — this adds a record, it does not swallow the failure. What it\n // can no longer leave behind is the state that used to be the worst one: a\n // surviving workspace with its own complete dump already in the bucket.\n const progress: EraseProgress = {}\n let step: EraseStep = 'plugins'\n try {\n // The plugins' share first (AGL-2978), while the org, its roster and\n // every record swept below still exist: an eraser that revokes a grant at\n // its provider opens the stored grant to do it. Each eraser is isolated,\n // so a failing one is recorded as `null` and stops nothing; on a plan\n // each one counts and touches no provider.\n progress.plugins = await runPluginOrgErasers({ orgId, dryRun })\n\n step = 'credentials'\n // Credentials and routing BEFORE content (AGL-1444/AGL-1448). The org doc\n // survives until the recursiveDelete at the end, so a key presented — or a\n // sign-in routed, or a console domain resolved — mid-erasure would still\n // pass the org gate and reach a half-deleted workspace. Revoking first\n // closes that window as well as the permanent one.\n progress.apiKeys = await eraseOrgApiKeys(orgId, dryRun)\n progress.outreachMailboxCredentials = await eraseOrgOutreachMailboxCredentials(\n orgId,\n dryRun,\n )\n progress.ssoDomains = await eraseOrgSsoDomains(orgId, dryRun)\n progress.consoleDomains = await releaseOrgConsoleDomains(orgId, dryRun)\n progress.apiIdempotency = await eraseOrgIdempotencyKeys(orgId, dryRun)\n\n // The org's PUBLIC marketplace identity (AGL-1970). Here rather than with\n // the Stripe step, though it carries a payout id, because `publisherHandles`\n // is a live name reservation and belongs with the other reservations this\n // step releases — and because the profile is world-readable, so the sooner\n // it stops resolving the smaller the mid-erasure window.\n const publisher = await eraseOrgPublisherIdentity(orgId, dryRun)\n progress.publisherHandles = publisher.handles\n progress.publisherProfile = publisher.profile\n progress.listingsRetained = publisher.listingsRetained\n\n // Support threads (AGL-1971). Its own step because it is the only sweep\n // here that walks a subtree per row, so it is the one most likely to be\n // where a large workspace's erasure runs out of time — and a failure that\n // cannot say WHICH step stopped it is the AGL-1455 defect again.\n step = 'support'\n progress.supportTickets = await eraseOrgSupportTickets(orgId, dryRun)\n\n /*\n * Hosts (Storage + routing + Firestore trees + the sending domain).\n *\n * Each site's dedicated domain is released here rather than by a sweep\n * that never existed: a workspace erasure used to leave one plan-capped\n * provider slot and three zone records per site standing forever, with\n * the DKIM key still live under a label a future site could claim.\n *\n * The driver is passed in and may be absent, and either way the erasure\n * finishes. What the vendors refuse is recorded as a debt on the label\n * claim, which survives the `recursiveDelete` of both the host and the\n * org, and the reaper settles it.\n */\n step = 'hosts'\n progress.hosts = 0\n progress.sendingDomains = { released: 0, deferred: 0, protected: 0 }\n for (const host of hosts.docs) {\n if (!dryRun) {\n const erased = await eraseHost(host.id, { tearDownSendingDomain })\n if (erased.sendingDomain === 'released') {\n progress.sendingDomains.released += 1\n }\n if (erased.sendingDomain === 'deferred') {\n progress.sendingDomains.deferred += 1\n }\n if (erased.sendingDomain === 'protected') {\n progress.sendingDomains.protected += 1\n }\n }\n progress.hosts += 1\n }\n\n // The backstop for what the loop above cannot enumerate (AGL-1448): a\n // `hostIndex` row whose `hosts` document is already gone. AFTER the loop\n // deliberately — the loop's own fail-soft delete is one of the things\n // that creates them, so sweeping first would leave this run's orphans\n // behind for a retry that can no longer see them.\n progress.hostIndex = await eraseOrgHostIndex(orgId, dryRun)\n\n // Org-level Storage (media/dataset assets outside the host prefix).\n step = 'org-storage'\n if (!dryRun) {\n try {\n await storageBucket().deleteFiles({ prefix: `orgs/${orgId}/` })\n } catch (error) {\n console.error(`eraseOrg: org storage cleanup failed for ${orgId}`, error)\n }\n // The org library's copies at the delivery provider (AGL-2824); each\n // site's went with its `eraseHost` above.\n await eraseMediaDeliveryScope({ collection: 'orgs', scopeId: orgId })\n }\n\n // Stripe: the customer at the processor, AND the local reverse index that\n // correlates that identity back to this workspace (AGL-1448). Deleting\n // only the first left exactly the record AGL-1028 denied to every client.\n step = 'stripe'\n // `readOrgBilling` hands back the customer for THIS deployment's Stripe\n // mode (AGL-2486), which is the only one `deleteStripeCustomer` could act\n // on anyway — it spends `STRIPE_SECRET_KEY`, and a live key cannot delete a\n // test customer. Known residue, deliberately not chased here: an org that\n // was also exercised in TEST mode leaves its test customer standing at\n // Stripe after a live erasure. That is synthetic data created by us, not\n // subject data supplied by the workspace, and reaching it would mean\n // erasure spending a second API key in a mode this deployment is not in.\n if (!dryRun) await deleteStripeCustomer(stripeCustomerId)\n progress.stripeIndex = await eraseOrgStripeIndex(orgId, dryRun)\n\n // Members' reverse index into this org.\n step = 'members'\n if (!dryRun) {\n for (const member of members.docs) {\n await firestore\n .collection('users')\n .doc(member.id)\n .collection('orgs')\n .doc(orgId)\n .delete()\n .catch(() => undefined)\n }\n }\n\n // The backstop for the members loop (AGL-1448): a `hostMemberships`\n // projection belonging to somebody who has LEFT the org, whom\n // `deleteHostProjectionForAllMembers` cannot enumerate because the\n // membership it would start from is already gone. Collection-group —\n // see the note on the function, and the index it owes.\n progress.hostMemberships = await eraseOrgHostMemberships(orgId, dryRun)\n\n // The org subtree, then every slug reservation it has ever held — the\n // current one and each rename tombstone (AGL-1448), with the matching\n // `{slug}.aglyn.com` detached (AGL-1136).\n step = 'org-tree'\n if (!dryRun) await firestore.recursiveDelete(orgRef)\n step = 'slugs'\n progress.slugs = await eraseOrgSlugs(orgId, slug, dryRun)\n } catch (error) {\n console.error(`eraseOrg: ${step} failed for ${orgId}`, error)\n await recordErasureFailure({\n orgId,\n step,\n error,\n actorUid,\n requestedAt: requestedMs,\n before,\n // How far it got, so the next reader knows what is already destroyed.\n after: { ...progress },\n })\n throw error\n }\n\n // A plan (AGL-1481). Every sweep above ran its query and skipped its write,\n // so these are the counts the real run would report — reached through the\n // same code, which is the whole point of the operator script calling this\n // function instead of listing the sweeps a second time. No audit row: an\n // erasure that did not happen is not a record of one.\n if (dryRun) {\n return {\n ok: false,\n skippedReason: 'dry-run',\n ...progress,\n hosts: hosts.size,\n members: members.size,\n }\n }\n\n // The proof of erasure, and now the only record of it: actor, action,\n // target, the request it fulfilled, the inventory found and what each sweep\n // destroyed. Ids and counts — never the content (AGL-1443).\n await firestore\n .collection('adminAudit')\n .add({\n actorUid,\n action: 'org.erased',\n target: `orgs/${orgId}`,\n before,\n after: {\n requestedAt: requestedMs,\n // What stood in for a request, when nothing did (AGL-2585). Absent on\n // every ordinary erasure, so its presence is the whole flag.\n ...(withoutRequest ? { withoutRequest: withoutRequest.reason } : {}),\n ...progress,\n },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n\n return { ok: true, ...progress, hosts: hosts.size, members: members.size }\n}\n\n/**\n * Strip an erased person from the support threads they wrote in, WITHOUT\n * destroying the threads (AGL-1971).\n *\n * The asymmetry with `eraseOrgSupportTickets` is the whole point and is worth\n * stating rather than assuming. A ticket belongs to an ORG, not to the person\n * who happened to open it: it is the workspace's support history, other people\n * on the roster can read it, and it is evidence in a dispute the workspace may\n * still be having with us. Erasing one member must not delete it — and cannot,\n * honestly: for that thread Aglyn is the processor and the ORG is the\n * controller, so deleting it on a member's instruction is acting without the\n * controller's instruction, which `docs/PRIVACY_REQUESTS.md` §6 refuses in the\n * mirror-image case. `eraseUser` also already refuses to cascade into\n * `eraseOrg` for exactly this reason.\n *\n * So the narrow correct action is redaction: `authorId` and `authorEmail` go\n * to `null` and `authorErased: true` marks why, on that person's messages\n * only. What remains is the message BODY, deliberately — it is the workspace's\n * record of a conversation with us, and it is the workspace's to delete. An\n * erasure of the ORG destroys it, which is the path that reaches it.\n *\n * **A collection-group query, not a walk over their current memberships.** The\n * membership walk is the tempting version and it is wrong in a specific way:\n * `eraseUser` only knows the orgs the person belongs to NOW, so every ticket\n * they wrote in a workspace they have since left would keep their email — the\n * residual is invisible and grows with tenure. `messages` is used as a\n * subcollection name by `supportTickets` and by nothing else in the repo, so\n * the group is exactly the right set. It needs the COLLECTION_GROUP index\n * override on `messages.authorId` in `cloud/firebase-firestore.indexes.json`,\n * added with this change.\n *\n * Returns the number of messages redacted, or **`null` when the query could\n * not run** — a missing index in an environment where it was never deployed.\n * `null` is not zero and the caller must not flatten it: it means this erasure\n * did not reach the support messages at all, and an erasure that quietly\n * reports success for a step it skipped is the failure this whole file is\n * organised against. It is reported in the result and in the audit row rather\n * than thrown, because the account deletion itself must still complete.\n */\nasync function redactUserSupportMessages(uid: string): Promise<number | null> {\n if (!uid) return 0\n const firestore = firebaseAdmin.app().firestore()\n let rows: FirebaseFirestore.QuerySnapshot\n try {\n rows = await firestore\n .collectionGroup('messages')\n .where('authorId', '==', uid)\n .get()\n } catch (error) {\n console.error(\n `eraseUser: support message redaction could not run for ${uid}`,\n error,\n )\n return null\n }\n for (let index = 0; index < rows.docs.length; index += 400) {\n const batch = firestore.batch()\n for (const doc of rows.docs.slice(index, index + 400)) {\n batch.set(\n doc.ref,\n { authorId: null, authorEmail: null, authorErased: true },\n { merge: true },\n )\n }\n await batch.commit()\n }\n return rows.size\n}\n\n/** An org that stops a person's account from being erased. */\nexport interface UserErasureBlocker {\n orgId: string\n orgName: string\n /** Whether the org still bills — the reason a human must intervene. */\n hasLiveSubscription: boolean\n /** Other people who would be stranded if this org lost its owner. */\n otherMembers: number\n}\n\nexport interface UserErasureCandidateOrg {\n orgId: string\n orgName: string\n ownerUid: string | null\n hasLiveSubscription: boolean\n memberCount: number\n}\n\n/**\n * Which orgs block erasing `uid` (AGL-1140).\n *\n * Pure so the policy can be tested without Firestore, because the policy is\n * the part worth arguing about — the deletion itself is mechanical.\n *\n * **Owning an org blocks erasure, always.** Not only when it bills, and not\n * only when other members would be stranded. The alternative — cascading\n * into `eraseOrg` — deletes a workspace, its sites and its data as a side\n * effect of someone closing a personal account, and no consent given to the\n * second act was given to the first. Blocking is recoverable; a cascade is\n * not.\n *\n * A live subscription and stranded members are reported rather than gating,\n * so the message can say WHY this org needs attention instead of a bare\n * refusal — \"transfer ownership\" is useless advice if you do not know which\n * of eleven workspaces is the problem.\n */\nexport function userErasureBlockers(\n uid: string,\n orgs: readonly UserErasureCandidateOrg[],\n): UserErasureBlocker[] {\n return orgs\n .filter((org) => org.ownerUid === uid)\n .map((org) => ({\n orgId: org.orgId,\n orgName: org.orgName,\n hasLiveSubscription: org.hasLiveSubscription,\n // The owner themselves is a member; anyone beyond that is stranded.\n otherMembers: Math.max(0, org.memberCount - 1),\n }))\n}\n\n/**\n * An address this account holds that a SECOND account also holds, which stops\n * the erasure until a human decides what it is.\n *\n * ⛔ Carries no address and no other uid. The whole question is about a second\n * customer, and a refusal that names them — or hands back an address in the\n * clear, which `emailDeliveries` is hashed precisely to avoid keeping —\n * discloses the person the refusal exists to protect. The staff account page\n * already lists this account's own addresses and marks the shared ones, so\n * the operator has the specifics without this record carrying them.\n */\nexport interface SharedAddressBlocker {\n /** `sha256(address)` — matches the delivery log's own key. Not readable. */\n key: string\n /** Where this account got it: `primary`, `provider`, `stored`. */\n sources: AccountAddressSource[]\n}\n\n/**\n * Which of an account's addresses a second account is also known to hold.\n *\n * Pure, and separate from the sweep, for the same reason `userErasureBlockers`\n * is: the policy is the arguable part. The mechanical part is deleting rows.\n *\n * ⚠️ `shared` is evidence in ONE direction. True proves a second holder;\n * false only means none was found, because no lookup exists for an account\n * holding an address through a federated provider. So this finds every case\n * we can SEE and cannot promise there is no other — which is exactly why the\n * sweep still tombstones every address it erases.\n */\nexport function sharedAddressBlockers(\n set: Pick<AccountAddressSet, 'addresses'>,\n): SharedAddressBlocker[] {\n return set.addresses\n .filter((entry) => entry.shared === true)\n .map((entry) => ({ key: entry.key, sources: [...entry.sources] }))\n}\n\nexport interface EraseUserResult {\n ok: boolean\n /** Set when the account was NOT erased. */\n skippedReason?: 'not-found' | 'owns-orgs' | 'shared-address'\n /** Orgs that must be handed over or deleted first. */\n blockers?: UserErasureBlocker[]\n /**\n * Addresses a second account also holds. Set with\n * `skippedReason: 'shared-address'`, and NOTHING was erased.\n */\n sharedAddresses?: SharedAddressBlocker[]\n /** What was actually removed, for the audit row and the caller's message. */\n deleted?: {\n subcollections: string[]\n authRecord: boolean\n photo: boolean\n /** `profiles/{uid}` — the world-readable public identity (AGL-1970). */\n profile: boolean\n /**\n * Support messages stripped of `authorId`/`authorEmail` (AGL-1971). The\n * threads themselves belong to the org and survive. **`null` means the\n * redaction could not run** — not that there was nothing to redact.\n */\n supportMessagesRedacted: number | null\n /**\n * What each plugin's eraser removed (AGL-2939), keyed by plugin id — the\n * data a plugin keeps about the person where neither the recursive\n * delete of the user document nor an org's cascade reaches, such as\n * documents under each org keyed by the uid. **`null` means that\n * plugin's eraser failed** and its data may remain; it is not zero.\n */\n plugins: Record<string, PluginUserErasureReport | null>\n /**\n * Messages removed from `emailDeliveries/{sha256(address)}/messages` —\n * the per-recipient delivery log. Zero for an account with no address on\n * file, or one we never mailed.\n *\n * Across EVERY address the account held, not just the current primary.\n */\n emailDeliveries: number\n /**\n * `emailIdentityIndex/{address}` rows released — the address-keyed\n * uniqueness claims, which no `recursiveDelete` of the user can reach.\n */\n emailIdentityIndex: number\n /**\n * How wide the address sweep actually was.\n *\n * ⚠️ `incomplete` is the field a compliance answer turns on: true means a\n * source could not be read and an address the account held may have been\n * missed, so this was not a complete erasure and must not be reported as\n * one. Counts only — never the addresses, which is the data being erased.\n */\n addressSweep: {\n /** Addresses found across the Auth record, its providers and the store. */\n resolved: number\n /** Addresses the delivery sweep actually visited. */\n erased: number\n /**\n * Addresses left INTACT because a second account also holds them.\n *\n * Always 0 on a successful erasure: a contested address refuses the\n * whole run before anything is destroyed. It is reported so the audit\n * row states that rather than leaving a reader to infer it.\n */\n contested: number\n incomplete: boolean\n }\n }\n}\n\n/**\n * Permanently erase a person's account (AGL-1140).\n *\n * Nothing did this before: `eraseHost`, `eraseSubtree` and `eraseOrg` existed\n * and no path anywhere deleted a `users/{uid}`. That was survivable while the\n * doc held a name and a phone; AGL-1133 added a postal address, and \"we have\n * no mechanism to delete it\" is a bad answer to an erasure request.\n *\n * `eraseOrg` is deliberately NOT the model here. An org is a workspace with\n * one owner; a person belongs to many orgs, so erasing them must not take\n * anything with it that outlives them.\n *\n * Order matters:\n * 1. Refuse if they own an org — see `userErasureBlockers`.\n * 1b. Refuse if a second account holds one of their addresses — see\n * `sharedAddressBlockers`. Both refusals come before any delete, so a\n * blocked erasure leaves the account exactly as it was.\n * 2. Remove their membership from every org they belong to, so no roster\n * keeps their email and no host projection keeps granting them access.\n * This runs BEFORE the profile delete: a half-erased account that still\n * appears on a roster is worse than one not yet started.\n * 3. Delete the `users/{uid}` subtree, the avatar, the world-readable\n * `profiles/{uid}` public identity (AGL-1970), redact their name and\n * email off every support message they wrote (AGL-1971), then the auth\n * record last — once that is gone there is no uid to retry with.\n */\nexport async function eraseUser(uid: string): Promise<EraseUserResult> {\n const firestore = firebaseAdmin.app().firestore()\n const userRef = firestore.collection('users').doc(uid)\n const snapshot = await userRef.get()\n const membership = await userRef.collection('orgs').get()\n // An absent profile doc is not an absent account — the doc is only born on\n // first save (AGL-1127), so the auth record and the org memberships can\n // outlive it. Treat \"nothing anywhere\" as not-found, not \"no doc\".\n if (!snapshot.exists && membership.empty) {\n const record = await findUserByUidAcrossPools(uid).catch(() => null)\n if (!record) return { ok: false, skippedReason: 'not-found' }\n }\n\n const candidates: UserErasureCandidateOrg[] = []\n for (const row of membership.docs) {\n const orgRef = firestore.collection('orgs').doc(row.id)\n const [org, members] = await Promise.all([\n orgRef.get(),\n orgRef.collection('members').count().get(),\n ])\n if (!org.exists) continue\n candidates.push({\n orgId: row.id,\n orgName: String(org.get('name') ?? row.id),\n ownerUid: (org.get('ownerUid') as string | undefined) ?? null,\n hasLiveSubscription: isBillingSubscription(org.data() as never),\n memberCount: Number(members.data().count ?? 0),\n })\n }\n const blockers = userErasureBlockers(uid, candidates)\n if (blockers.length) {\n return { ok: false, skippedReason: 'owns-orgs', blockers }\n }\n\n // Inventory BEFORE anything is deleted. Taken after the membership sweep\n // below, this under-reported: `removeOrgMember` empties `users/{uid}/orgs`,\n // and Firestore does not list a collection with no documents in it — so a\n // live run erased three subcollections and the audit record named two.\n // An erasure audit trail that understates what it destroyed is the one\n // record that has to be right. Measured 2026-08-01 (AGL-1140).\n const subcollections = (await userRef.listCollections()).map((c) => c.id)\n\n /*\n * EVERY ADDRESS, RESOLVED BEFORE ANY OF THEM IS DESTROYED.\n *\n * Two of the three sources are erased by this function itself:\n * `users/{uid}/emails` goes with the `recursiveDelete` below, and the Auth\n * record — the only place the primary lives — goes at the very end. Reading\n * them afterwards is reading nothing, and an erasure that resolves an empty\n * address set sweeps nothing while reporting success. That is the exact\n * shape of a spec passing because its fixture never reached the code, and\n * here it would be a live erasure reporting a completeness it does not have.\n *\n * `detectShared` because the SHARED case changes what the caller is told,\n * not what is destroyed — see `eraseEmailDeliveriesForAddresses`.\n */\n const pooledForAddresses = await findUserByUidAcrossPools(uid).catch(() => null)\n const addressSet: AccountAddressSet = await resolveAccountAddresses({\n uid,\n record: pooledForAddresses?.record ?? null,\n detectShared: true,\n firestore,\n }).catch(() => ({\n uid,\n primary: null,\n addresses: [],\n incomplete: true,\n }))\n\n /*\n * A SECOND ACCOUNT HOLDS ONE OF THESE ADDRESSES — REFUSE, HERE.\n *\n * The delivery log is filed under the address, so an address two accounts\n * hold has one set of rows answering \"what did you send me\" for two account\n * records. Whether that is one human with two accounts (the ordinary live\n * shape — a federated provider address that is another account's primary)\n * or a genuinely shared role mailbox like `billing@` decides whether\n * erasing those rows honours a request or destroys a stranger's history,\n * and NOTHING readable here distinguishes them. The data records that two\n * accounts name one address; it records nothing about the humans.\n *\n * Both automatic answers are unacceptable, and they fail differently:\n * erasing the second party's mail cannot be undone, while leaving it and\n * reporting the erasure complete is the gap this whole area was opened to\n * close. So neither is chosen — the erasure stops and says why.\n *\n * BEFORE the membership sweep, deliberately. A refusal raised after the\n * account is half-deleted is not a refusal, and the uid is the only handle\n * a human has to act on this: erase the auth record and there is no longer\n * anything to look at, no way to see which address was contested, and no\n * way to run the erasure once the question is settled. Refusing while the\n * account is whole is the only version of this that stays reversible.\n *\n * The remedy is ordinary staff work, not a special path: decide whether the\n * accounts are one person, detach the address from this one or merge them,\n * then run the erasure again. `/admin/users/[uid]` already lists this\n * account's addresses and marks the shared ones.\n */\n const contested = sharedAddressBlockers(addressSet)\n if (contested.length) {\n return {\n ok: false,\n skippedReason: 'shared-address',\n sharedAddresses: contested,\n }\n }\n\n // Memberships first — a roster row carries their email and a host\n // projection carries their access, and both outlive the profile doc.\n for (const candidate of candidates) {\n await removeOrgMember(candidate.orgId, uid).catch((error) => {\n console.error(`eraseUser: membership cleanup failed for ${candidate.orgId}`, error)\n })\n }\n\n // What the plugins keep about the person (AGL-2939). A document under an\n // org keyed by the uid is the shape no recursive delete on this path can\n // see — the same shape as the delivery log below. Each plugin's eraser\n // gets every workspace the person was in, because the memberships above\n // are already gone; one that fails is recorded as `null` and the erasure\n // goes on.\n const plugins = await runPluginUserErasers({\n uid,\n orgIds: candidates.map((candidate) => candidate.orgId),\n })\n\n // The avatar outlives the doc otherwise.\n let photo = false\n try {\n await storageBucket().deleteFiles({ prefix: `users/${uid}/` })\n photo = true\n } catch (error) {\n console.error(`eraseUser: storage cleanup failed for ${uid}`, error)\n }\n\n await firestore.recursiveDelete(userRef)\n\n // `profiles/{uid}` — the person's PUBLIC identity (AGL-1970).\n //\n // A separate top-level collection keyed by the uid AS THE DOCUMENT ID, so\n // `recursiveDelete(users/{uid})` above never reaches it and no reader\n // scanning this function for a sweep list is prompted to notice it. It is\n // `allow read: if true` in the rules and carries `handle`, `displayName`,\n // and `stripeAccountId`/`stripeChargesEnabled` — a Stripe Connect payout\n // destination written server-side after KYC. An erased person therefore\n // stayed publicly listed with a live payment-account identifier attached,\n // which Privacy Policy §5 (\"a genuine recursive delete… we keep no copy of\n // the erased content\") says in terms does not happen.\n //\n // No tombstone, unlike the org's publisher profile: the only reader of the\n // display name is the support forum's author rendering, and a forum post\n // losing its author name is the intended outcome of erasing its author.\n //\n // `recursiveDelete` rather than `delete` — nothing writes a subcollection\n // under a profile today, and a document delete would silently orphan the\n // first thing that does.\n let profile = false\n try {\n const profileRef = firestore.collection('profiles').doc(uid)\n profile = (await profileRef.get()).exists\n await firestore.recursiveDelete(profileRef)\n } catch (error) {\n console.error(`eraseUser: public profile delete failed for ${uid}`, error)\n }\n\n // Their name and email off every support message they wrote, in any\n // workspace, past or present (AGL-1971). The threads are the orgs' and\n // survive — see `redactUserSupportMessages`.\n const supportMessagesRedacted = await redactUserSupportMessages(uid)\n\n // Auth record LAST: once it is gone there is no uid to retry with, and\n // `authForPool` is required because a project-level delete cannot see an\n // SSO account at all (AGL-1122).\n //\n // The lookup is hoisted above the delete because the DELIVERY LOG is filed\n // under the ADDRESS, not the uid, and the auth record is the last place the\n // address exists. Erase it while there is still something to erase it by.\n let authRecord = false\n let emailDeliveries = 0\n /** Addresses actually swept — not the same as the ones we hold, on failure. */\n let erasedAddressCount = 0\n /** Addresses left intact because a second account also holds them. */\n let contestedAddressCount = 0\n /** Identity-index rows released, one per address that had one. */\n let emailIdentityIndex = 0\n try {\n const record = await findUserByUidAcrossPools(uid)\n if (record) {\n /*\n * `emailDeliveries/{sha256(address)}/messages` — every subject we ever\n * sent this person, with the times they opened and clicked. Personal\n * data by any reading of it, and reachable from neither\n * `recursiveDelete(users/{uid})` nor `profiles/{uid}` because it is\n * keyed by address in a third top-level collection. The same shape as\n * the `profiles/{uid}` omission this function already documents above:\n * a store nothing in the sweep list would prompt a reader to notice.\n *\n * EVERY ADDRESS, not the current primary. Passing one address left a\n * prior or provider-supplied address holding its full history —\n * recipient, subjects, open and click times — after the request had\n * been honoured and reported complete.\n *\n * Best-effort, like every other step here — a log that survives must\n * not stop the auth record from going.\n */\n const sweep = await eraseEmailDeliveriesForAddresses(\n addressSet.addresses,\n firestore,\n ).catch(() => null)\n emailDeliveries = sweep?.removed ?? 0\n erasedAddressCount = sweep?.addresses.length ?? 0\n contestedAddressCount = sweep?.contestedAddresses.length ?? 0\n\n /*\n * `emailIdentityIndex/{address}` — the SAME omission one collection\n * over.\n *\n * A top-level collection keyed by the address IN THE CLEAR, holding\n * `{uid, address, claimedAt}`. `recursiveDelete(users/{uid})` cannot\n * see it for exactly the reason it cannot see the delivery log, so an\n * erased account left a readable address still pointing at its uid.\n *\n * Released rather than tombstoned, unlike the delivery log: this is a\n * uniqueness claim, and `removeAccountEmail` already argues that\n * holding one after the address is gone burns the address for whoever\n * might legitimately want it next.\n *\n * ⛔ Only rows this uid actually owns. The index is the guard against\n * two accounts holding one address, and deleting another account's\n * claim would hand their sign-in identifier to the next taker.\n */\n for (const entry of addressSet.addresses) {\n try {\n const ref = firestore\n .collection(EMAIL_IDENTITY_INDEX_COLLECTION)\n .doc(entry.address)\n const indexed = await ref.get()\n if (indexed.exists && indexed.get('uid') === uid) {\n await ref.delete()\n emailIdentityIndex += 1\n }\n } catch (error) {\n console.error(`eraseUser: identity index release failed for ${uid}`, error)\n }\n }\n await authForPool(record.tenantId).deleteUser(uid)\n authRecord = true\n }\n } catch (error) {\n console.error(`eraseUser: auth record delete failed for ${uid}`, error)\n }\n\n /*\n * WHAT THE ADDRESS SWEEP ACTUALLY COVERED.\n *\n * Counts, never the addresses themselves: this collection is readable by\n * any staff role and `maskEmailAddress` exists because an audit row holding\n * a readable address made `adminAudit` the leakier of the two stores for\n * the same data. An erasure record listing the addresses it destroyed would\n * be the sharpest version of that — it would preserve, in the clear, the\n * exact set the request existed to remove.\n *\n * `addressesIncomplete` is the one that matters to a reader. It says a\n * source could not be read, so the sweep may have missed an address the\n * account held — the difference between \"erased everywhere\" and \"erased\n * everywhere we could see\", which is the difference a regulator asks about.\n */\n const addressSweep = {\n resolved: addressSet.addresses.length,\n erased: erasedAddressCount,\n contested: contestedAddressCount,\n incomplete: addressSet.incomplete,\n }\n\n await firestore\n .collection('adminAudit')\n .add({\n actorUid: 'system:erase-user',\n action: 'user.erased',\n target: `users/${uid}`,\n before: { orgs: candidates.length },\n after: {\n subcollections,\n authRecord,\n photo,\n profile,\n supportMessagesRedacted,\n plugins,\n emailDeliveries,\n emailIdentityIndex,\n addressSweep,\n },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n\n return {\n ok: true,\n deleted: {\n subcollections,\n authRecord,\n photo,\n profile,\n supportMessagesRedacted,\n plugins,\n emailDeliveries,\n emailIdentityIndex,\n addressSweep,\n },\n }\n}\n"],"names":["FieldValue","firebaseAdmin","eraseMediaDeliveryScope","deleteHostProjectionForAllMembers","detachWorkspaceDomain","CONSOLE_DOMAINS_COLLECTION","releaseConsoleDomain","authForPool","findUserByUidAcrossPools","eraseEmailDeliveriesForAddresses","resolveAccountAddresses","EMAIL_IDENTITY_INDEX_COLLECTION","removeOrgMember","runPluginUserErasers","runPluginOrgErasers","isBillingSubscription","listPluginOrgCollections","readOrgBilling","disposeHostSendingDomain","readSendingDomainTeardownByLabel","ERASURE_HOLD_MS","storageBucket","name","process","env","NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET","app","storage","bucket","undefined","SUPPLIER_DELIVERY_COLLECTION","OUTREACH_MAILBOX_CREDENTIALS_COLLECTION","eraseHostSupplierDeliveries","hostId","firestore","rows","collection","where","get","index","docs","length","batch","doc","slice","delete","ref","commit","size","error","console","eraseHostOwnedOrgDocuments","orgId","erased","declared","siteField","recursiveDelete","eraseHost","options","hostSnapshot","hostRef","sendingLabel","String","trim","sendingTeardown","catch","deleteFiles","prefix","scopeId","set","hosts","registerAllocations","collaboratorAllocations","merge","sendingDomain","teardown","tearDown","tearDownSendingDomain","eraseSubtree","path","scope","kind","id","deleteStripeCustomer","customerId","key","STRIPE_SECRET_KEY","fetch","method","headers","Authorization","eraseOrgApiKeys","dryRun","deleteDocsByOrgId","eraseOrgOutreachMailboxCredentials","eraseOrgSsoDomains","releaseOrgConsoleDomains","claims","primaries","Set","map","released","domain","result","eraseOrgStripeIndex","eraseOrgIdempotencyKeys","eraseOrgSlugs","currentSlug","held","slugs","has","reservation","exists","add","slug","eraseOrgHostIndex","eraseOrgHostMemberships","collectionGroup","eraseOrgPublisherIdentity","handles","profileRef","profileSnapshot","listings","Promise","all","listingsRetained","profile","erasedAt","serverTimestamp","eraseOrgSupportTickets","tickets","ticket","errorLabel","source","parts","code","filter","part","join","recordErasureFailure","entry","actorUid","action","target","before","after","failedStep","step","requestedAt","at","auditError","eraseOrg","orgSnapshot","withoutRequest","orgRef","ok","skippedReason","requestedMs","toMillis","Date","now","members","stripeCustomerId","progress","plugins","apiKeys","outreachMailboxCredentials","ssoDomains","consoleDomains","apiIdempotency","publisher","publisherHandles","publisherProfile","supportTickets","sendingDomains","deferred","protected","host","hostIndex","stripeIndex","member","hostMemberships","reason","redactUserSupportMessages","uid","authorId","authorEmail","authorErased","userErasureBlockers","orgs","org","ownerUid","orgName","hasLiveSubscription","otherMembers","Math","max","memberCount","sharedAddressBlockers","addresses","shared","sources","eraseUser","userRef","snapshot","membership","empty","record","candidates","row","count","push","data","Number","blockers","subcollections","listCollections","c","pooledForAddresses","addressSet","detectShared","primary","incomplete","contested","sharedAddresses","candidate","orgIds","photo","supportMessagesRedacted","authRecord","emailDeliveries","erasedAddressCount","contestedAddressCount","emailIdentityIndex","sweep","removed","contestedAddresses","address","indexed","tenantId","deleteUser","addressSweep","resolved","deleted"],"mappings":"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"}