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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/package.json +8 -8
  2. package/src/lib/server/api-http.js +5 -1
  3. package/src/lib/server/api-http.js.map +1 -1
  4. package/src/lib/server/api-v1-description.d.ts +112 -0
  5. package/src/lib/server/api-v1-description.js +126 -0
  6. package/src/lib/server/api-v1-description.js.map +1 -0
  7. package/src/lib/server/api-v1-kit.d.ts +163 -0
  8. package/src/lib/server/api-v1-kit.js +191 -0
  9. package/src/lib/server/api-v1-kit.js.map +1 -0
  10. package/src/lib/server/api-v1-resources.d.ts +143 -0
  11. package/src/lib/server/api-v1-resources.js +125 -0
  12. package/src/lib/server/api-v1-resources.js.map +1 -0
  13. package/src/lib/server/campaign-conversion-attribution.d.ts +1 -1
  14. package/src/lib/server/campaign-conversion-attribution.js +3 -3
  15. package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
  16. package/src/lib/server/card-payment-velocity.d.ts +9 -0
  17. package/src/lib/server/card-payment-velocity.js +28 -2
  18. package/src/lib/server/card-payment-velocity.js.map +1 -1
  19. package/src/lib/server/consent-group-carry.js +1 -1
  20. package/src/lib/server/consent-group-carry.js.map +1 -1
  21. package/src/lib/server/consent-group-change.d.ts +2 -1
  22. package/src/lib/server/consent-group-change.js +43 -18
  23. package/src/lib/server/consent-group-change.js.map +1 -1
  24. package/src/lib/server/contact-email-index.d.ts +22 -0
  25. package/src/lib/server/contact-email-index.js +108 -0
  26. package/src/lib/server/contact-email-index.js.map +1 -1
  27. package/src/lib/server/crm-email-activity.d.ts +1 -1
  28. package/src/lib/server/crm-email-activity.js +1 -1
  29. package/src/lib/server/crm-email-activity.js.map +1 -1
  30. package/src/lib/server/crm-records.d.ts +9 -1
  31. package/src/lib/server/crm-records.js +19 -2
  32. package/src/lib/server/crm-records.js.map +1 -1
  33. package/src/lib/server/cron-auth.d.ts +44 -0
  34. package/src/lib/server/cron-auth.js +76 -0
  35. package/src/lib/server/cron-auth.js.map +1 -0
  36. package/src/lib/server/dataset-live-pages.js +6 -6
  37. package/src/lib/server/dataset-live-pages.js.map +1 -1
  38. package/src/lib/server/duplicate-resource.d.ts +26 -0
  39. package/src/lib/server/duplicate-resource.js +47 -25
  40. package/src/lib/server/duplicate-resource.js.map +1 -1
  41. package/src/lib/server/dynamic-list-materialize.js +6 -3
  42. package/src/lib/server/dynamic-list-materialize.js.map +1 -1
  43. package/src/lib/server/email-flow-gate.js +2 -2
  44. package/src/lib/server/email-flow-gate.js.map +1 -1
  45. package/src/lib/server/email-suppression.d.ts +11 -0
  46. package/src/lib/server/email-suppression.js +37 -2
  47. package/src/lib/server/email-suppression.js.map +1 -1
  48. package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
  49. package/src/lib/server/email-unsubscribe-link.js +7 -7
  50. package/src/lib/server/email-unsubscribe-link.js.map +1 -1
  51. package/src/lib/server/erase.d.ts +19 -33
  52. package/src/lib/server/erase.js +37 -162
  53. package/src/lib/server/erase.js.map +1 -1
  54. package/src/lib/server/host-email-tokens.js +1 -1
  55. package/src/lib/server/host-email-tokens.js.map +1 -1
  56. package/src/lib/server/host-memberships.js +3 -3
  57. package/src/lib/server/host-memberships.js.map +1 -1
  58. package/src/lib/server/host-visitor-records.d.ts +32 -4
  59. package/src/lib/server/host-visitor-records.js +73 -14
  60. package/src/lib/server/host-visitor-records.js.map +1 -1
  61. package/src/lib/server/hosted-page-review.d.ts +27 -0
  62. package/src/lib/server/hosted-page-review.js +149 -12
  63. package/src/lib/server/hosted-page-review.js.map +1 -1
  64. package/src/lib/server/media-cdn-rate-limit.d.ts +6 -3
  65. package/src/lib/server/media-cdn-rate-limit.js +6 -3
  66. package/src/lib/server/media-cdn-rate-limit.js.map +1 -1
  67. package/src/lib/server/member-email-aliases.d.ts +10 -2
  68. package/src/lib/server/member-email-aliases.js +26 -2
  69. package/src/lib/server/member-email-aliases.js.map +1 -1
  70. package/src/lib/server/new-account-announcement.d.ts +62 -0
  71. package/src/lib/server/new-account-announcement.js +77 -0
  72. package/src/lib/server/new-account-announcement.js.map +1 -0
  73. package/src/lib/server/notifications.d.ts +22 -2
  74. package/src/lib/server/notifications.js +40 -3
  75. package/src/lib/server/notifications.js.map +1 -1
  76. package/src/lib/server/operator-alerts.d.ts +7 -1
  77. package/src/lib/server/operator-alerts.js +5 -3
  78. package/src/lib/server/operator-alerts.js.map +1 -1
  79. package/src/lib/server/org-containers.d.ts +66 -0
  80. package/src/lib/server/org-containers.js +95 -0
  81. package/src/lib/server/org-containers.js.map +1 -0
  82. package/src/lib/server/org-member-notice.js +3 -2
  83. package/src/lib/server/org-member-notice.js.map +1 -1
  84. package/src/lib/server/payment-fraud-signal.js +5 -1
  85. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  86. package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
  87. package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +36 -2
  88. package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
  89. package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
  90. package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
  91. package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
  92. package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
  93. package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
  94. package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
  95. package/src/lib/server/payment-provider.d.ts +173 -0
  96. package/src/lib/server/payment-provider.js +70 -0
  97. package/src/lib/server/payment-provider.js.map +1 -0
  98. package/src/lib/server/payment-risk-record.d.ts +1 -1
  99. package/src/lib/server/payment-risk-record.js +3 -1
  100. package/src/lib/server/payment-risk-record.js.map +1 -1
  101. package/src/lib/server/personal-data-export.js +1 -1
  102. package/src/lib/server/personal-data-export.js.map +1 -1
  103. package/src/lib/server/platform-marketing-consent.d.ts +1 -1
  104. package/src/lib/server/platform-marketing-consent.js +1 -1
  105. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  106. package/src/lib/server/rate-limit-store.js +13 -3
  107. package/src/lib/server/rate-limit-store.js.map +1 -1
  108. package/src/lib/server/realm-plugins.js +8 -3
  109. package/src/lib/server/realm-plugins.js.map +1 -1
  110. package/src/lib/server/retained-refusals.d.ts +142 -0
  111. package/src/lib/server/retained-refusals.js +325 -0
  112. package/src/lib/server/retained-refusals.js.map +1 -0
  113. package/src/lib/server/risk-notice.d.ts +32 -1
  114. package/src/lib/server/risk-notice.js +90 -28
  115. package/src/lib/server/risk-notice.js.map +1 -1
  116. package/src/lib/server/sending-domains.d.ts +17 -0
  117. package/src/lib/server/sending-domains.js +28 -4
  118. package/src/lib/server/sending-domains.js.map +1 -1
  119. package/src/lib/server/sso-enforcement.js +7 -2
  120. package/src/lib/server/sso-enforcement.js.map +1 -1
  121. package/src/lib/server/staff-alert-email.js +4 -2
  122. package/src/lib/server/staff-alert-email.js.map +1 -1
  123. package/src/lib/server/test-firestore-queries.d.ts +7 -0
  124. package/src/lib/server/test-firestore-queries.js +70 -27
  125. package/src/lib/server/test-firestore-queries.js.map +1 -1
  126. package/src/lib/server/{email-topic-confirmation.js → topic-subscriptions.js} +15 -11
  127. package/src/lib/server/topic-subscriptions.js.map +1 -0
  128. package/src/lib/server/upsert-contact.d.ts +46 -1
  129. package/src/lib/server/upsert-contact.js +87 -20
  130. package/src/lib/server/upsert-contact.js.map +1 -1
  131. package/src/tenancy.d.ts +5 -8
  132. package/src/tenancy.js +12 -13
  133. package/src/tenancy.js.map +1 -1
  134. package/src/lib/server/connect-account-status.js.map +0 -1
  135. package/src/lib/server/connect-payout-failure.js.map +0 -1
  136. package/src/lib/server/contact-email-engagement.d.ts +0 -32
  137. package/src/lib/server/contact-email-engagement.js +0 -145
  138. package/src/lib/server/contact-email-engagement.js.map +0 -1
  139. package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
  140. package/src/lib/server/contact-lifecycle-floor.js +0 -91
  141. package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
  142. package/src/lib/server/contact-merge.d.ts +0 -55
  143. package/src/lib/server/contact-merge.js +0 -288
  144. package/src/lib/server/contact-merge.js.map +0 -1
  145. package/src/lib/server/crm-booking-activity.d.ts +0 -86
  146. package/src/lib/server/crm-booking-activity.js +0 -257
  147. package/src/lib/server/crm-booking-activity.js.map +0 -1
  148. package/src/lib/server/crm-inbound-email.d.ts +0 -145
  149. package/src/lib/server/crm-inbound-email.js +0 -297
  150. package/src/lib/server/crm-inbound-email.js.map +0 -1
  151. package/src/lib/server/email-topic-confirmation.js.map +0 -1
  152. package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
  153. package/src/lib/server/form-dataset-binding-token.js +0 -107
  154. package/src/lib/server/form-dataset-binding-token.js.map +0 -1
  155. package/src/lib/server/stripe-account-mode.js.map +0 -1
  156. /package/src/lib/server/{email-topic-confirmation.d.ts → topic-subscriptions.d.ts} +0 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/personal-data-export.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Personal-data export — access and portability, in a machine-readable form\n * (AGL-1974).\n *\n * Privacy Policy §7 grants portability (GDPR Art. 20) and nothing in the\n * product exported a person's or a workspace's personal data. Six surfaces\n * told customers to \"export first\" before deleting — the org settings Delete\n * tab, the Close account card, `erase-org-cli.mjs`, the staff org actions,\n * live DPA §11 and Terms §13.3 — all pointing at something that did not\n * exist. An access request was assembled by a staff member reading the console\n * by hand.\n *\n * ## THE PROPERTY THIS FILE EXISTS FOR\n *\n * **An export whose coverage differs from erasure's is a bug in one of them.**\n * Both answer the same question — *what do we hold about this subject* — and\n * the honest failure modes are symmetric: an export that misses a collection\n * under-discloses on an access request, and an erasure that misses the same\n * one leaves data behind after we said it was gone. Historically the second\n * kept happening (AGL-1444, AGL-1448, AGL-1970, AGL-1971 each found a\n * collection outside the cascade), which is exactly the evidence that a list\n * kept in one place and mirrored by hand in another will drift.\n *\n * The ideal is one enumeration both derive from. That is not available here:\n * `erase.ts` is under concurrent change and its sweep list is expressed as\n * code, not data. So the coverage is enumerated ONCE below, as data, and\n * `personal-data-export-coverage.spec.ts` DISCOVERS every collection\n * `erase.ts` touches out of its source and fails when the two disagree. A new\n * sweep added to the erasure turns this file red until somebody decides\n * whether the export discloses it — which is the decision that was being\n * skipped.\n *\n * That guard is worth more than the feature.\n *\n * ## What must NOT come out wholesale\n *\n * Three categories, and each is a way an access request becomes a breach:\n *\n * 1. **Another person's data.** A workspace is shared. A member asking what\n * we hold about THEM gets their own roster row, not the roster; their own\n * support messages, not the thread's other authors. Handled by exporting\n * the person and the workspace as different subjects with different\n * scopes, never by filtering a single blob after the fact.\n * 2. **Secrets.** An `apiKeys` row confirms that a credential exists — its\n * label, scopes, creator and dates, which are all facts about the subject\n * — and never re-discloses the credential. Note the document ID *is* the\n * SHA-256 of the token, so the id itself is withheld too; a hash handed\n * back is a verifier handed back.\n * 3. **Staff-only fields.** Internal notes, override reasons and moderation\n * state are about our handling, not about the person, and several name a\n * staff member.\n *\n * ## What it is NOT\n *\n * Not `GET /api/hosts/export` — that is a Pro+ site-design backup whose own\n * header says it never includes bookings, leads, submissions or secrets, and\n * offering it as a portability answer would be misdescribing it. Not the\n * `firestore-export` cron, which is an unscoped DR snapshot. Not the audit CSV.\n */\n\nimport { firebaseAdmin } from './firebase-admin'\nimport { CONSOLE_DOMAINS_COLLECTION } from './console-domains'\n\nconst firestoreApp = () => firebaseAdmin.app().firestore()\n\n/** Which subject an export source belongs to. */\nexport type ExportSubject = 'user' | 'org'\n\n/** How a source is located — the axis a path cascade is blind to. */\nexport type ExportKeying =\n /** Top-level collection carrying the subject id in a FIELD. */\n | 'field'\n /** Top-level collection whose DOCUMENT ID is the subject id. */\n | 'doc-id'\n /** Under the subject's own document path. */\n | 'path'\n /** Reached through another collection's rows (a collection group). */\n | 'collection-group'\n /** Outside Firestore entirely. */\n | 'external'\n\nexport interface ExportSourceSpec {\n /** Firestore collection name, or an `external:` pseudo-name. */\n collection: string\n keyedBy: ExportKeying\n subjects: readonly ExportSubject[]\n /**\n * `true` when the export discloses it. `false` requires `reason` — and the\n * reason is the whole value of the field: it is a decision recorded at the\n * moment it was made, not an omission discovered later.\n */\n exported: boolean\n /** Why it is withheld, or how it is disclosed. Always present. */\n note: string\n}\n\n/**\n * Every collection `erase.ts` touches, and what the export does with it.\n *\n * Kept in the SAME order as the erasure's steps so a reader comparing the two\n * files reads them in step. `personal-data-export-coverage.spec.ts` proves the\n * set matches; this list carries the decision for each one.\n */\nexport const PERSONAL_DATA_SOURCES: readonly ExportSourceSpec[] = [\n // ── The person ────────────────────────────────────────────────────────\n {\n collection: 'users',\n keyedBy: 'doc-id',\n subjects: ['user'],\n exported: true,\n note: 'Profile: name, phone, postal address, legalAcceptances, and every subcollection (orgs, hostMemberships, notifications, passkeys).',\n },\n {\n collection: 'profiles',\n keyedBy: 'doc-id',\n subjects: ['user'],\n exported: true,\n note: 'The world-readable handle, display name and Stripe Connect account. Not reachable from the user tree (AGL-1970) — the same blind spot on both sides.',\n },\n // ── The workspace ─────────────────────────────────────────────────────\n {\n collection: 'orgs',\n keyedBy: 'doc-id',\n subjects: ['org'],\n exported: true,\n note: 'The org document and every subcollection: members, invites, roles, usage, apiUsage, analytics, datasets/records, contacts, lists, media, installs, activity, assistExchanges, retention.',\n },\n {\n collection: 'hosts',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each site tree the org owns — screens, layouts, orders, form submissions. Secrets within are redacted, not omitted, so the export still says a webhook exists.',\n },\n {\n collection: 'hostIndex',\n keyedBy: 'doc-id',\n subjects: ['org'],\n exported: true,\n note: 'Subdomain/CNAME routing for each of the org’s sites. Configuration the customer set, so it is theirs to take.',\n },\n {\n collection: 'supplierDeliveries',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'The dropship outbox for each of the org’s sites, keyed by hostId (AGL-1448). Disclosed to the ORG and to no one else, and the reason is the unusual one: the personal data in the row is a SHOPPER’s — name, email and shipping address in the body we sign and POST to the supplier. That is the merchant’s own order record, which they already read in their console and for which they are the controller and we the processor, so an org access request is entitled to it. It is NOT a `user`-subject source: a console member asking what we hold about THEM is not the shopper, and returning these rows would answer their request with a third party’s address. Withholding it was not an option worth taking — `eraseHost` destroys these rows, so an export that omitted them would delete what it never disclosed.',\n },\n // ── Top-level, field-keyed: what a path cascade cannot see ────────────\n {\n collection: 'apiKeys',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY — label, scopes, creating uid, dates. Never the credential, and never the document id, which is the SHA-256 of the token.',\n },\n {\n collection: 'outreachMailboxCredentials',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY — which connected mailbox a credential authorizes, its provider, granted scopes and dates (AGL-2974), and since AGL-2978 the connected account’s address, its Google account id and the member who connected it: the organization’s own record of whose mailbox it holds. Every token is redacted by name and by shape (see redactSecrets) and never disclosed; the sealed refresh token and the id of the key that sealed it both carry `token` in their names. The document id is the mailbox id, not derived from any secret, so it is disclosed.',\n },\n {\n collection: 'outreachLinks',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Where each short tracking link in a sent Outreach sequence email points (AGL-3297): the enrollment, the step, the link’s index and the destination the step wrote. The organization’s own record of what it sent; the id is random and derived from no secret.',\n },\n {\n collection: 'ssoDomains',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'The public SSO routing document: domain, GCIP tenant and provider ids, IdP display name. `token` is redacted.',\n },\n {\n collection: CONSOLE_DOMAINS_COLLECTION,\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Custom console domain claims the org holds.',\n },\n {\n collection: 'apiIdempotency',\n keyedBy: 'field',\n subjects: ['org'],\n exported: false,\n note: 'Replay keys. The document id is a SHA-256 of the org id and the caller’s Idempotency-Key, and the row names a record id and nothing about a person. Disclosed as a COUNT so the export does not silently omit a collection the erasure destroys.',\n },\n {\n collection: 'stripeCustomers',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'The local reverse index: which Stripe customer id belongs to this workspace. An identifier the customer already holds on their own invoices.',\n },\n {\n collection: 'orgSlugs',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Every workspace name the org has ever held, rename tombstones included — naming history is a fact about them.',\n },\n {\n collection: 'publisherHandles',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Marketplace handle reservations and rename tombstones (AGL-1970).',\n },\n {\n collection: 'publisherProfiles',\n keyedBy: 'doc-id',\n subjects: ['org'],\n exported: true,\n note: 'The public publisher identity — handle, display name, bio, website, avatar, publisher agreement. `stripeAccountId` is the org’s own payout destination and is disclosed; nothing here is a credential.',\n },\n {\n collection: 'marketplaceListings',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Listings the org published. These deliberately OUTLIVE an erasure (AGL-1448 Tier 3) — which makes disclosing them on an access request more important, not less: they are the part that does not go away.',\n },\n {\n collection: 'supportTickets',\n keyedBy: 'field',\n subjects: ['org', 'user'],\n exported: true,\n note: 'For an ORG: every thread and its messages. For a PERSON: only the messages they themselves authored — a thread has other authors, and handing a member the whole conversation would disclose colleagues’ words to them.',\n },\n // ── Retained past erasure on statutory grounds ────────────────────────\n {\n collection: 'platformRevenue',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Per-transaction tax filing records. GDPR Art. 17(3)(b) exempts them from ERASURE (AGL-1811); it does not exempt them from ACCESS. If a transaction of theirs is in there it is disclosable — and the export is the only place a customer can see what survived their erasure.',\n },\n {\n collection: 'storefrontTaxCollected',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Sales tax charged to shoppers on the org’s storefront (AGL-1904). Same statutory retention, same access obligation.',\n },\n // ── Records of our own handling ───────────────────────────────────────\n {\n collection: 'adminAudit',\n keyedBy: 'field',\n subjects: ['org', 'user'],\n exported: false,\n note: 'STAFF-ONLY. Rows name the staff actor, carry override reasons and internal notes, and are the record of our handling rather than data about the subject. `/admin/audit` → Export CSV serves a request that specifically asks for them, after a staff read.',\n },\n]\n\n/** Sources this export deliberately withholds, with the reason. */\nexport const EXPORT_WITHHELD = PERSONAL_DATA_SOURCES.filter((s) => !s.exported)\n\n/** Collection names the export claims to cover, for the coverage guard. */\nexport const EXPORT_COVERED_COLLECTIONS: readonly string[] =\n PERSONAL_DATA_SOURCES.map((source) => source.collection)\n\n/**\n * ## WHY THIS IS SHAPE-DRIVEN AND NOT A LIST OF NAMES\n *\n * It used to be a list of names, anchored on separators:\n *\n * /(^|[._-])(secret|token|password|apikey|…)($|[._-])/i\n *\n * That pattern requires a `.`, `_`, `-`, or a string boundary on both sides\n * of the word, so it reads `password_hash` and `webhooks.secret` and misses\n * `supplierToken` and `passwordScrypt` — the exact two fields the AGL-1881\n * review found leaving the platform in a customer's own export file. One is\n * a live bearer credential a supplier authenticates with; the other is a\n * shopper's scrypt password hash. **A denylist of names is always one naming\n * convention behind, and camelCase is the proof.**\n *\n * So the name half is now tokenized rather than separator-anchored, and it is\n * no longer the only gate. Three run, in order, and any of them redacts:\n *\n * * **(A) The name, tokenized.** `supplierToken` splits to\n * `['supplier','token']` the same way `supplier_token` and\n * `SUPPLIER-TOKEN` do, so the convention stops mattering. A name matches\n * on a single secret word or on an adjacent pair (`client secret`).\n * * **(B) The VALUE's shape, whatever the key is called.** This is the half\n * that does not need to know the schema: a PEM block, a JWT, a vendor\n * prefix (`sk_live_`, `whsec_`, `ghp_`, `AKIA`…), a `$2b$`/`$argon2` hash,\n * this repo's own `salt:hash` scrypt form, or an opaque high-entropy\n * string is a credential whether the field is called `supplierToken`,\n * `t`, or `notes`. **Gate B is the \"redact unless known safe\" default**:\n * a credential-shaped string is withheld unless its key names it as a\n * content identifier (see {@link SAFE_VALUE_WORDS}).\n * * **(C) Credentials carried inside a URL.** `?token=`, `?sig=`,\n * `?key=`, and `https://user:pass@host` are stripped from the URL rather\n * than the URL being blanked, because a media object's `url` field really\n * does carry a live Firebase download token in a query parameter, and the\n * location of the customer's own file is exactly what an access request\n * asks for. Precision beats blanking here.\n *\n * ### Why the KEY half is still deny-based, when the value half is not\n *\n * A true allowlist of field names is not available on this data. The export\n * walks documents whose field names our customers author — canvas nodes,\n * dataset records, custom metadata, form submissions — so an allowlist of\n * names would blank most of the file and turn an access request into a\n * non-answer, which is the under-disclosure this feature exists to stop. The\n * allowlist therefore lives where the vocabulary IS ours: value shapes. An\n * unrecognized VALUE shape is passed through (it is prose, a number, a date,\n * a customer's own content); an unrecognized value shape that looks like a\n * credential is withheld. That is the inversion the review asked for, put\n * where it can hold.\n *\n * ### The one deliberate carve-out\n *\n * `token`/`tokens` is also a metering word — `tokensUsed: 14203` on an\n * assist exchange is a usage count, and blanking it would blank a real\n * disclosure. A credential is a STRING; a count is a number. So a numeric or\n * boolean value under a name that matched only on a metering word passes\n * through. Nothing else in the vocabulary gets that treatment.\n *\n * A false positive costs a redacted field in an export. A false negative\n * costs a credential. Everything below is tuned in that direction.\n */\n\n/**\n * Split a field name into lowercase word tokens, convention-independently.\n *\n * `supplierToken`, `supplier_token`, `SUPPLIER-TOKEN` and `Supplier.Token`\n * all become `['supplier','token']`. Acronym runs split at the last capital\n * (`SSOTokenId` → `['sso','token','id']`) and digits break a word\n * (`apiKey2` → `['api','key','2']`), so a numbered field cannot hide.\n */\nexport function fieldNameTokens(key: unknown): string[] {\n return String(key ?? '')\n .replace(/[^A-Za-z0-9]+/g, ' ')\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2')\n .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')\n .replace(/([A-Za-z])([0-9])/g, '$1 $2')\n .toLowerCase()\n .split(' ')\n .filter(Boolean)\n}\n\n/**\n * Words that make a field a credential on their own.\n *\n * Deliberately short. Every entry here is a word that has no innocent\n * meaning on a stored VALUE — `hmac`, `scrypt` and `bcrypt` name the\n * algorithm that produced a verifier, `passphrase` and `credential` name the\n * thing itself. `key` is NOT here and must never be: `publicKey` on a\n * passkey and `sortKey` on a dataset are not secrets, which is what\n * {@link SECRET_WORD_PAIRS} is for.\n */\nconst SECRET_WORDS = new Set([\n 'secret',\n 'secrets',\n 'password',\n 'passwords',\n 'passwd',\n 'pwd',\n 'passphrase',\n 'token',\n 'tokens',\n 'credential',\n 'credentials',\n 'apikey',\n 'privatekey',\n 'scrypt',\n 'bcrypt',\n 'argon',\n 'argon2',\n 'pbkdf2',\n 'hmac',\n 'otp',\n 'totp',\n 'authorization',\n])\n\n/**\n * Words that are a credential only next to another word.\n *\n * `key` is meaningless alone and decisive after `api`, `private`, `signing`,\n * `secret`, `session` or `encryption`; `token` after `access`/`refresh` is\n * already caught by {@link SECRET_WORDS} and listed here anyway so the pair\n * table reads as the complete statement of the rule.\n */\nconst SECRET_WORD_PAIRS = new Set([\n 'api key',\n 'private key',\n 'signing key',\n 'secret key',\n 'session key',\n 'encryption key',\n 'client secret',\n 'access token',\n 'refresh token',\n 'webhook secret',\n 'shared secret',\n // A Stripe payment link is a live bearer URL: whoever holds it can pay.\n // Named in the AGL-1443 inventory of what the old dump actually carried.\n 'payment link',\n 'auth code',\n 'recovery code',\n 'backup code',\n])\n\n/**\n * The metering carve-out — see the header. A name that matched ONLY on one\n * of these, over a number or a boolean, is a count and is disclosed.\n */\nconst METERING_WORDS = new Set(['token', 'tokens'])\n\n/**\n * Key words that mark a high-entropy value as a content identifier rather\n * than a credential, defusing gate (B) only.\n *\n * A SHA-256 has exactly the shape of a 64-character secret and is not one.\n * Gate (A) is never defused by this list: `apiKeyId` is redacted, because\n * the name says credential and an id of a credential is not worth the\n * argument.\n */\nconst SAFE_VALUE_WORDS = new Set([\n // A passkey's `publicKey` is public by definition and has exactly the shape\n // of a secret. Safe to list because gate (A) is never defused here — a\n // `publicToken` is still redacted, on the strength of `token`.\n 'public',\n 'id',\n 'ids',\n 'uid',\n 'uuid',\n 'guid',\n 'hash',\n 'hashes',\n 'sha',\n 'sha1',\n 'sha256',\n 'sha512',\n 'md5',\n 'crc',\n 'crc32c',\n 'etag',\n 'checksum',\n 'digest',\n 'fingerprint',\n 'generation',\n 'version',\n 'revision',\n 'slug',\n 'path',\n 'url',\n 'uri',\n 'href',\n 'src',\n 'color',\n 'colour',\n 'hex',\n])\n\n/** Vendor credential prefixes — unambiguous, and worth naming explicitly. */\nconst CREDENTIAL_PREFIXES = [\n 'sk_live_',\n 'sk_test_',\n 'rk_live_',\n 'rk_test_',\n 'whsec_',\n 'ghp_',\n 'gho_',\n 'ghu_',\n 'ghs_',\n 'ghr_',\n 'github_pat_',\n 'xoxb-',\n 'xoxp-',\n 'xapp-',\n 'xoxa-',\n 'AKIA',\n 'ASIA',\n 'AIza',\n 'ya29.',\n 'SG.',\n 're_',\n 'shpat_',\n 'shpss_',\n 'npm_',\n 'glpat-',\n 'sq0csp-',\n 'sq0atp-',\n]\n\n/** A PEM block that carries a private key or a certificate request. */\nconst PEM_PATTERN = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/\n\n/** Modular crypt format: bcrypt, argon2, scrypt, PBKDF2, sha512-crypt. */\nconst CRYPT_HASH_PATTERN = /^\\$(2[abxy]?|argon2[a-z]*|scrypt|pbkdf2[^$]*|[56])\\$/\n\n/** This repo's own scrypt storage form: `{16+ hex salt}:{32+ hex hash}`. */\nconst SALTED_HASH_PATTERN = /^[0-9a-f]{16,}:[0-9a-f]{32,}$/i\n\n/** A JSON Web Token — three base64url segments. */\nconst JWT_PATTERN = /^[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}$/\n\n/** A UUID, which is an identifier far more often than it is a credential. */\nconst UUID_PATTERN =\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n/** Charset of an opaque credential: base64, base64url or hex, nothing else. */\nconst OPAQUE_CHARSET = /^[A-Za-z0-9_+/=-]+$/\n\n/**\n * The window an opaque secret lives in.\n *\n * Below 32 characters the false-positive rate against ordinary identifiers is\n * not worth it and the value is too short to be a modern credential anyway.\n * Above 512 it is customer CONTENT — a base64 image, a compressed canvas\n * blob, an encoded document — and blanking those is the \"gutting the export\"\n * failure this file is explicitly trying not to commit.\n */\nconst OPAQUE_MIN_LENGTH = 32\nconst OPAQUE_MAX_LENGTH = 512\n\n/** Shannon entropy per character. Prose and slugs sit well below this. */\nconst OPAQUE_MIN_ENTROPY = 3.2\n\nfunction shannonEntropyPerChar(text: string): number {\n const counts = new Map<string, number>()\n for (const character of text) {\n counts.set(character, (counts.get(character) ?? 0) + 1)\n }\n let entropy = 0\n for (const count of counts.values()) {\n const p = count / text.length\n entropy -= p * Math.log2(p)\n }\n return entropy\n}\n\n/**\n * Does this STRING look like a credential, ignoring what it is called?\n *\n * Exported because the guard spec asserts each limb independently — a shape\n * detector tested only through the redactor is a detector whose gaps are\n * invisible.\n */\nexport function looksLikeCredential(value: string): boolean {\n const text = value.trim()\n if (!text) return false\n if (PEM_PATTERN.test(text)) return true\n if (CRYPT_HASH_PATTERN.test(text)) return true\n if (SALTED_HASH_PATTERN.test(text)) return true\n if (CREDENTIAL_PREFIXES.some((prefix) => text.startsWith(prefix))) return true\n if (JWT_PATTERN.test(text)) return true\n // Anything with whitespace is prose, and `data:` is an inline asset. Both\n // are customer content, and neither is a bearer credential.\n if (/\\s/.test(text)) return false\n if (/^data:/i.test(text)) return false\n if (UUID_PATTERN.test(text)) return false\n if (text.length < OPAQUE_MIN_LENGTH || text.length > OPAQUE_MAX_LENGTH) {\n return false\n }\n if (!OPAQUE_CHARSET.test(text)) return false\n return shannonEntropyPerChar(text) >= OPAQUE_MIN_ENTROPY\n}\n\n/** What a stripped URL parameter reads as, so the removal is visible. */\nexport const REDACTED_URL_PARAM = 'REDACTED'\n\n/** Query parameters that carry a bearer credential inside a URL. */\nconst URL_CREDENTIAL_PARAMS = new Set([\n 'token',\n 'access_token',\n 'accesstoken',\n 'id_token',\n 'refresh_token',\n 'auth',\n 'authorization',\n 'apikey',\n 'api_key',\n 'key',\n 'sig',\n 'signature',\n 'secret',\n 'password',\n 'downloadtoken',\n])\n\n/**\n * Strip the credential out of a URL, keeping the URL.\n *\n * A media object's `url` is `…/o/{path}?alt=media&token={uuid}` — a live,\n * unauthenticated download credential, in a field an access request has a\n * real interest in. Blanking the whole value would tell the customer nothing\n * about where their own file is; keeping it whole hands out the credential.\n * So the parameter is replaced and the rest survives. `user:pass@` userinfo\n * goes the same way.\n *\n * Returns the input unchanged when it is not a URL — every non-URL string\n * has already been decided by the shape gate.\n */\nexport function scrubUrlCredentials(value: string): string {\n if (!/^https?:\\/\\//i.test(value)) return value\n let url: URL\n try {\n url = new URL(value)\n } catch {\n return value\n }\n let changed = false\n if (url.username || url.password) {\n url.username = ''\n url.password = ''\n changed = true\n }\n for (const name of Array.from(url.searchParams.keys())) {\n if (!URL_CREDENTIAL_PARAMS.has(name.toLowerCase())) continue\n if (!url.searchParams.get(name)) continue\n url.searchParams.set(name, REDACTED_URL_PARAM)\n changed = true\n }\n return changed ? url.toString() : value\n}\n\n/**\n * Gate (A): does the NAME say credential?\n *\n * `value` is consulted only for the metering carve-out described in the\n * header — a `tokensUsed: 14203` is a count, not a credential.\n */\nexport function secretByName(key: unknown, value?: unknown): boolean {\n const tokens = fieldNameTokens(key)\n if (!tokens.length) return false\n const hits = tokens.filter((token) => SECRET_WORDS.has(token))\n for (let index = 0; index + 1 < tokens.length; index += 1) {\n if (SECRET_WORD_PAIRS.has(`${tokens[index]} ${tokens[index + 1]}`)) {\n return true\n }\n }\n if (!hits.length) return false\n // Matched only on a metering word, over a number or a boolean: a count.\n const metering = hits.every((token) => METERING_WORDS.has(token))\n if (metering && (typeof value === 'number' || typeof value === 'boolean')) {\n return false\n }\n return true\n}\n\n/** Gate (B)'s defuser: does the NAME say this high-entropy value is an id? */\nfunction nameMarksContentIdentifier(key: unknown): boolean {\n return fieldNameTokens(key).some((token) => SAFE_VALUE_WORDS.has(token))\n}\n\n/** What a redacted value is replaced with — presence, never content. */\nexport interface RedactedValue {\n redacted: true\n /** Whether a value was actually there, so absence is distinguishable. */\n present: boolean\n reason: 'secret'\n}\n\nconst redactedMarker = (present: boolean): RedactedValue => ({\n redacted: true,\n present,\n reason: 'secret',\n})\n\n/**\n * Deep-copy a document, replacing every credential-shaped value with a\n * presence marker.\n *\n * Presence rather than deletion, because \"we hold a webhook secret for you\"\n * is itself a true and disclosable fact, and a silently-absent key would make\n * the export look like it holds less than it does — the under-disclosure this\n * feature exists to stop, reintroduced by the safety measure.\n *\n * Firestore values that are not plain JSON (Timestamp, GeoPoint, DocumentRef,\n * Buffer) are stringified rather than walked: they carry no nested keys to\n * redact, and `JSON.stringify` on a Timestamp yields `{_seconds,_nanoseconds}`,\n * which is not a date to a human reading the file.\n *\n * Three gates, any of which redacts — the name (A), the value's shape (B),\n * and a credential embedded in a URL (C). See the header above\n * {@link fieldNameTokens} for why the value half is the one that carries the\n * \"withhold unless known safe\" default.\n */\nexport function redactSecrets(value: unknown, key = ''): unknown {\n // (A) The name, tokenized — convention-independent.\n if (key && secretByName(key, value)) {\n return redactedMarker(value !== undefined && value !== null && value !== '')\n }\n if (value === null || value === undefined) return value ?? null\n if (typeof value === 'string') {\n // (C) A URL that carries its credential in a parameter keeps the URL and\n // loses the credential — see {@link scrubUrlCredentials}.\n const scrubbed = scrubUrlCredentials(value)\n if (scrubbed !== value) return scrubbed\n // (B) The shape, whatever the field is called. Withheld unless the name\n // marks it as a content identifier.\n if (looksLikeCredential(value) && !nameMarksContentIdentifier(key)) {\n return redactedMarker(true)\n }\n return value\n }\n if (Array.isArray(value)) return value.map((entry) => redactSecrets(entry, key))\n if (typeof value === 'object') {\n const source = value as Record<string, unknown>\n // Firestore Timestamp — the only non-plain value common enough to be\n // worth rendering rather than dumping.\n if (typeof (source as any).toDate === 'function') {\n try {\n return (source as any).toDate().toISOString()\n } catch {\n return String(value)\n }\n }\n if (typeof (source as any).toBase64 === 'function') return '[binary]'\n const out: Record<string, unknown> = {}\n for (const [childKey, childValue] of Object.entries(source)) {\n out[childKey] = redactSecrets(childValue, childKey)\n }\n return out\n }\n return value\n}\n\nexport interface ExportedDocument {\n /** Document id — omitted where the id is itself a secret (see `apiKeys`). */\n id?: string\n path?: string\n data: unknown\n}\n\nexport interface PersonalDataExport {\n subject: { type: ExportSubject; id: string }\n generatedAt: string\n /**\n * The disclosure decision for every source, copied into the file the\n * customer receives. An export that silently omits a collection is\n * indistinguishable from one that has nothing to report; this makes the two\n * different, and it is what a supervisory authority would ask to see.\n */\n coverage: readonly ExportSourceSpec[]\n /** Collection name → the documents disclosed for it. */\n data: Record<string, ExportedDocument[]>\n /** Collection name → row count, for sources disclosed as a count only. */\n counts: Record<string, number>\n /** Places we hold data that is not in Firestore. */\n elsewhere: readonly string[]\n}\n\n/** Data we hold outside Firestore — named, because a file that omits them reads as complete. */\nconst ELSEWHERE = [\n 'Stripe — customer object, payment methods, invoices. Request from Stripe or ask us.',\n 'Resend — transactional email delivery logs.',\n 'Google Analytics — 14-month retention, not keyed to an account.',\n 'Cloud Storage — uploaded media and avatars, under users/{uid}/, orgs/{orgId}/ and hosts/{hostId}/. Listed by path in this file; the bytes are served by their own URLs.',\n]\n\nfunction sourcesFor(subject: ExportSubject): ExportSourceSpec[] {\n return PERSONAL_DATA_SOURCES.filter((source) => source.subjects.includes(subject))\n}\n\n/** Read a top-level collection bounded by a field equal to the subject id. */\nasync function readByField(\n db: any,\n collection: string,\n field: string,\n id: string,\n options: { includeIds?: boolean } = {},\n): Promise<ExportedDocument[]> {\n const rows = await db.collection(collection).where(field, '==', id).get()\n return rows.docs.map((doc: any) => ({\n // `apiKeys` document ids are the SHA-256 of the token: an id handed back\n // is a verifier handed back, so the caller opts in rather than out.\n ...(options.includeIds === false ? {} : { id: doc.id }),\n data: redactSecrets(doc.data()),\n }))\n}\n\n/** Read one document by id, or nothing when it does not exist. */\nasync function readDoc(\n db: any,\n collection: string,\n id: string,\n): Promise<ExportedDocument[]> {\n const snapshot = await db.collection(collection).doc(id).get()\n if (!snapshot.exists) return []\n return [{ id, data: redactSecrets(snapshot.data()) }]\n}\n\n/**\n * Walk a document and everything beneath it.\n *\n * `listCollections()` rather than a hard-coded subcollection list, for the\n * same reason the erasure uses `recursiveDelete`: a subcollection added next\n * quarter is reached without anybody remembering this file exists. Bounded by\n * `maxDepth` because the tree is customer-shaped and an unbounded recursion\n * inside a request is how AGL-1455 lost erasures to a 60-second timeout.\n */\nasync function readSubtree(\n db: any,\n ref: any,\n depth = 0,\n maxDepth = 4,\n): Promise<ExportedDocument[]> {\n const snapshot = await ref.get()\n const found: ExportedDocument[] = []\n if (snapshot.exists) {\n found.push({ path: ref.path, data: redactSecrets(snapshot.data()) })\n }\n if (depth >= maxDepth) return found\n const children = (await ref.listCollections?.()) ?? []\n for (const child of children) {\n const rows = await child.get()\n for (const doc of rows.docs) {\n found.push(...(await readSubtree(db, doc.ref, depth + 1, maxDepth)))\n }\n }\n return found\n}\n\nexport interface ExportOptions {\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n /** Stops a pathological workspace from building an unbounded file. */\n maxDepth?: number\n}\n\n/**\n * Everything we hold about a PERSON, as the person themselves may ask for it\n * (GDPR Art. 15 and Art. 20).\n *\n * Scoped to them, never to the workspaces they belong to. Their membership row\n * in each org is theirs; the roster around it is their colleagues'. An org\n * export is a separate call with a separate authorization, which is the whole\n * reason the two are different functions rather than one with a flag.\n */\nexport async function exportUserData(\n uid: string,\n options: ExportOptions = {},\n): Promise<PersonalDataExport> {\n const db = options.firestore ?? firestoreApp()\n const data: Record<string, ExportedDocument[]> = {}\n const counts: Record<string, number> = {}\n\n // The profile tree — `users/{uid}` and every subcollection under it.\n data['users'] = await readSubtree(\n db,\n db.collection('users').doc(uid),\n 0,\n options.maxDepth ?? 4,\n )\n\n // The public identity, which the user tree does not reach (AGL-1970).\n data['profiles'] = await readDoc(db, 'profiles', uid)\n\n // ONLY the messages this person wrote. The threads belong to a workspace\n // and carry other people's words; handing a member the whole conversation\n // to answer their own access request is a disclosure nobody consented to.\n const messages = await db\n .collectionGroup('messages')\n .where('authorId', '==', uid)\n .get()\n .catch(() => ({ docs: [] as any[] }))\n data['supportTickets'] = messages.docs.map((doc: any) => ({\n path: doc.ref.path,\n data: redactSecrets(doc.data()),\n }))\n\n return {\n subject: { type: 'user', id: uid },\n generatedAt: new Date().toISOString(),\n coverage: sourcesFor('user'),\n data,\n counts,\n elsewhere: ELSEWHERE,\n }\n}\n\n/**\n * Everything we hold about a WORKSPACE, for its owner (Art. 15 for the\n * personal data in it, and the \"export before you delete\" the Delete tab,\n * DPA §11 and Terms §13.3 have been promising).\n *\n * Every top-level collection the erasure sweeps by `orgId` is read here by the\n * same bound, so the two see the same rows. Where a source is withheld, its\n * COUNT is still reported: a collection that appears in neither the data nor\n * the counts is indistinguishable from one that does not exist, and that\n * ambiguity is what makes an incomplete export undetectable.\n */\nexport async function exportOrgData(\n orgId: string,\n options: ExportOptions = {},\n): Promise<PersonalDataExport> {\n const db = options.firestore ?? firestoreApp()\n const data: Record<string, ExportedDocument[]> = {}\n const counts: Record<string, number> = {}\n\n data['orgs'] = await readSubtree(\n db,\n db.collection('orgs').doc(orgId),\n 0,\n options.maxDepth ?? 4,\n )\n\n // Sites: the routing entry and the whole tree for each.\n const hosts = await db.collection('hosts').where('orgId', '==', orgId).get()\n const hostDocs: ExportedDocument[] = []\n const routing: ExportedDocument[] = []\n const outbox: ExportedDocument[] = []\n for (const host of hosts.docs) {\n hostDocs.push(...(await readSubtree(db, host.ref, 0, options.maxDepth ?? 4)))\n routing.push(...(await readDoc(db, 'hostIndex', host.id)))\n // Keyed by `hostId`, not `orgId`, so it is read here rather than in the\n // field-keyed loop below — the same bound `eraseHostSupplierDeliveries`\n // sweeps by, so the export and the erasure see the same rows.\n outbox.push(...(await readByField(db, 'supplierDeliveries', 'hostId', host.id)))\n }\n data['hosts'] = hostDocs\n data['hostIndex'] = routing\n data['supplierDeliveries'] = outbox\n\n // The field-keyed collections a path cascade is blind to — the ones the\n // erasure had to grow a sweep for, one issue at a time.\n for (const collection of [\n 'ssoDomains',\n CONSOLE_DOMAINS_COLLECTION,\n 'stripeCustomers',\n 'orgSlugs',\n 'publisherHandles',\n 'platformRevenue',\n 'storefrontTaxCollected',\n ]) {\n data[collection] = await readByField(db, collection, 'orgId', orgId)\n }\n\n // Existence, never the credential — and not the id either, which is the\n // token's SHA-256.\n data['apiKeys'] = await readByField(db, 'apiKeys', 'orgId', orgId, {\n includeIds: false,\n })\n\n // Which mailboxes the reps connected, never the grant behind one: the\n // tokens are withheld by `redactSecrets` like every other read here. The id\n // is the mailbox's own, so unlike `apiKeys` it stays.\n data['outreachMailboxCredentials'] = await readByField(\n db,\n 'outreachMailboxCredentials',\n 'orgId',\n orgId,\n )\n\n // Where each short tracking link the org's sequences sent points (AGL-3297).\n data['outreachLinks'] = await readByField(db, 'outreachLinks', 'orgId', orgId)\n\n data['publisherProfiles'] = await readDoc(db, 'publisherProfiles', orgId)\n data['marketplaceListings'] = await readByField(\n db,\n 'marketplaceListings',\n 'profileId',\n orgId,\n )\n\n // Support threads WITH their messages — for an org the whole thread is the\n // org's own record, unlike the person-scoped export above.\n const tickets = await db\n .collection('supportTickets')\n .where('orgId', '==', orgId)\n .get()\n const ticketDocs: ExportedDocument[] = []\n for (const ticket of tickets.docs) {\n ticketDocs.push(...(await readSubtree(db, ticket.ref, 0, 2)))\n }\n data['supportTickets'] = ticketDocs\n\n // Withheld, but counted — see the note on this function.\n const replayKeys = await db\n .collection('apiIdempotency')\n .where('orgId', '==', orgId)\n .get()\n counts['apiIdempotency'] = replayKeys.docs.length\n\n return {\n subject: { type: 'org', id: orgId },\n generatedAt: new Date().toISOString(),\n coverage: sourcesFor('org'),\n data,\n counts,\n elsewhere: ELSEWHERE,\n }\n}\n\n/** A filename a human can file: subject, id and the date it was produced. */\nexport function exportFilename(exported: PersonalDataExport): string {\n const day = exported.generatedAt.slice(0, 10)\n return `aglyn-${exported.subject.type}-data-${exported.subject.id}-${day}.json`\n}\n\nexport default exportUserData\n"],"names":["firebaseAdmin","CONSOLE_DOMAINS_COLLECTION","firestoreApp","app","firestore","PERSONAL_DATA_SOURCES","collection","keyedBy","subjects","exported","note","EXPORT_WITHHELD","filter","s","EXPORT_COVERED_COLLECTIONS","map","source","fieldNameTokens","key","String","replace","toLowerCase","split","Boolean","SECRET_WORDS","Set","SECRET_WORD_PAIRS","METERING_WORDS","SAFE_VALUE_WORDS","CREDENTIAL_PREFIXES","PEM_PATTERN","CRYPT_HASH_PATTERN","SALTED_HASH_PATTERN","JWT_PATTERN","UUID_PATTERN","OPAQUE_CHARSET","OPAQUE_MIN_LENGTH","OPAQUE_MAX_LENGTH","OPAQUE_MIN_ENTROPY","shannonEntropyPerChar","text","counts","Map","character","set","get","entropy","count","values","p","length","Math","log2","looksLikeCredential","value","trim","test","some","prefix","startsWith","REDACTED_URL_PARAM","URL_CREDENTIAL_PARAMS","scrubUrlCredentials","url","URL","changed","username","password","name","Array","from","searchParams","keys","has","toString","secretByName","tokens","hits","token","index","metering","every","nameMarksContentIdentifier","redactedMarker","present","redacted","reason","redactSecrets","undefined","scrubbed","isArray","entry","toDate","toISOString","toBase64","out","childKey","childValue","Object","entries","ELSEWHERE","sourcesFor","subject","includes","readByField","db","field","id","options","rows","where","docs","doc","includeIds","data","readDoc","snapshot","exists","readSubtree","ref","depth","maxDepth","found","push","path","children","listCollections","child","exportUserData","uid","messages","collectionGroup","catch","type","generatedAt","Date","coverage","elsewhere","exportOrgData","orgId","hosts","hostDocs","routing","outbox","host","tickets","ticketDocs","ticket","replayKeys","exportFilename","day","slice"],"mappings":"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GACD,MAAMC,eAAe,IAAIC,IAAI;IAC3B;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAED;;;;;;;CAOC,GACD,MAAMC,oBAAoB,IAAID,IAAI;IAChC;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA,wEAAwE;IACxE,yEAAyE;IACzE;IACA;IACA;IACA;CACD;AAED;;;CAGC,GACD,MAAME,iBAAiB,IAAIF,IAAI;IAAC;IAAS;CAAS;AAElD;;;;;;;;CAQC,GACD,MAAMG,mBAAmB,IAAIH,IAAI;IAC/B,4EAA4E;IAC5E,uEAAuE;IACvE,+DAA+D;IAC/D;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAED,2EAA2E,GAC3E,MAAMI,sBAAsB;IAC1B;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAED,qEAAqE,GACrE,MAAMC,cAAc;AAEpB,wEAAwE,GACxE,MAAMC,qBAAqB;AAE3B,0EAA0E,GAC1E,MAAMC,sBAAsB;AAE5B,iDAAiD,GACjD,MAAMC,cAAc;AAEpB,2EAA2E,GAC3E,MAAMC,eACJ;AAEF,6EAA6E,GAC7E,MAAMC,iBAAiB;AAEvB;;;;;;;;CAQC,GACD,MAAMC,oBAAoB;AAC1B,MAAMC,oBAAoB;AAE1B,wEAAwE,GACxE,MAAMC,qBAAqB;AAE3B,SAASC,sBAAsBC,IAAY;IACzC,MAAMC,SAAS,IAAIC;IACnB,KAAK,MAAMC,aAAaH,KAAM;YACLC;QAAvBA,OAAOG,GAAG,CAACD,WAAW,EAACF,cAAAA,OAAOI,GAAG,CAACF,sBAAXF,cAAyB,KAAK;I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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/personal-data-export.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Personal-data export — access and portability, in a machine-readable form\n * (AGL-1974).\n *\n * Privacy Policy §7 grants portability (GDPR Art. 20) and nothing in the\n * product exported a person's or a workspace's personal data. Six surfaces\n * told customers to \"export first\" before deleting — the org settings Delete\n * tab, the Close account card, `erase-org-cli.mjs`, the staff org actions,\n * live DPA §11 and Terms §13.3 — all pointing at something that did not\n * exist. An access request was assembled by a staff member reading the console\n * by hand.\n *\n * ## THE PROPERTY THIS FILE EXISTS FOR\n *\n * **An export whose coverage differs from erasure's is a bug in one of them.**\n * Both answer the same question — *what do we hold about this subject* — and\n * the honest failure modes are symmetric: an export that misses a collection\n * under-discloses on an access request, and an erasure that misses the same\n * one leaves data behind after we said it was gone. Historically the second\n * kept happening (AGL-1444, AGL-1448, AGL-1970, AGL-1971 each found a\n * collection outside the cascade), which is exactly the evidence that a list\n * kept in one place and mirrored by hand in another will drift.\n *\n * The ideal is one enumeration both derive from. That is not available here:\n * `erase.ts` is under concurrent change and its sweep list is expressed as\n * code, not data. So the coverage is enumerated ONCE below, as data, and\n * `personal-data-export-coverage.spec.ts` DISCOVERS every collection\n * `erase.ts` touches out of its source and fails when the two disagree. A new\n * sweep added to the erasure turns this file red until somebody decides\n * whether the export discloses it — which is the decision that was being\n * skipped.\n *\n * That guard is worth more than the feature.\n *\n * ## What must NOT come out wholesale\n *\n * Three categories, and each is a way an access request becomes a breach:\n *\n * 1. **Another person's data.** A workspace is shared. A member asking what\n * we hold about THEM gets their own roster row, not the roster; their own\n * support messages, not the thread's other authors. Handled by exporting\n * the person and the workspace as different subjects with different\n * scopes, never by filtering a single blob after the fact.\n * 2. **Secrets.** An `apiKeys` row confirms that a credential exists — its\n * label, scopes, creator and dates, which are all facts about the subject\n * — and never re-discloses the credential. Note the document ID *is* the\n * SHA-256 of the token, so the id itself is withheld too; a hash handed\n * back is a verifier handed back.\n * 3. **Staff-only fields.** Internal notes, override reasons and moderation\n * state are about our handling, not about the person, and several name a\n * staff member.\n *\n * ## What it is NOT\n *\n * Not `GET /api/hosts/export` — that is a Pro+ site-design backup whose own\n * header says it never includes bookings, leads, submissions or secrets, and\n * offering it as a portability answer would be misdescribing it. Not the\n * `firestore-export` cron, which is an unscoped DR snapshot. Not the audit CSV.\n */\n\nimport { firebaseAdmin } from './firebase-admin'\nimport { CONSOLE_DOMAINS_COLLECTION } from './console-domains'\n\nconst firestoreApp = () => firebaseAdmin.app().firestore()\n\n/** Which subject an export source belongs to. */\nexport type ExportSubject = 'user' | 'org'\n\n/** How a source is located — the axis a path cascade is blind to. */\nexport type ExportKeying =\n /** Top-level collection carrying the subject id in a FIELD. */\n | 'field'\n /** Top-level collection whose DOCUMENT ID is the subject id. */\n | 'doc-id'\n /** Under the subject's own document path. */\n | 'path'\n /** Reached through another collection's rows (a collection group). */\n | 'collection-group'\n /** Outside Firestore entirely. */\n | 'external'\n\nexport interface ExportSourceSpec {\n /** Firestore collection name, or an `external:` pseudo-name. */\n collection: string\n keyedBy: ExportKeying\n subjects: readonly ExportSubject[]\n /**\n * `true` when the export discloses it. `false` requires `reason` — and the\n * reason is the whole value of the field: it is a decision recorded at the\n * moment it was made, not an omission discovered later.\n */\n exported: boolean\n /** Why it is withheld, or how it is disclosed. Always present. */\n note: string\n}\n\n/**\n * Every collection `erase.ts` touches, and what the export does with it.\n *\n * Kept in the SAME order as the erasure's steps so a reader comparing the two\n * files reads them in step. `personal-data-export-coverage.spec.ts` proves the\n * set matches; this list carries the decision for each one.\n */\nexport const PERSONAL_DATA_SOURCES: readonly ExportSourceSpec[] = [\n // ── The person ────────────────────────────────────────────────────────\n {\n collection: 'users',\n keyedBy: 'doc-id',\n subjects: ['user'],\n exported: true,\n note: 'Profile: name, phone, postal address, legalAcceptances, and every subcollection (orgs, hostMemberships, notifications, passkeys).',\n },\n {\n collection: 'profiles',\n keyedBy: 'doc-id',\n subjects: ['user'],\n exported: true,\n note: 'The world-readable handle, display name and Stripe Connect account. Not reachable from the user tree (AGL-1970) — the same blind spot on both sides.',\n },\n // ── The workspace ─────────────────────────────────────────────────────\n {\n collection: 'orgs',\n keyedBy: 'doc-id',\n subjects: ['org'],\n exported: true,\n note: 'The org document and every subcollection: members, invites, roles, usage, apiUsage, analytics, datasets/records, contacts, lists, media, installs, activity, assistExchanges, retention.',\n },\n {\n collection: 'hosts',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Each site tree the org owns — pages, layouts, orders, form submissions. Secrets within are redacted, not omitted, so the export still says a webhook exists.',\n },\n {\n collection: 'hostIndex',\n keyedBy: 'doc-id',\n subjects: ['org'],\n exported: true,\n note: 'Subdomain/CNAME routing for each of the org’s sites. Configuration the customer set, so it is theirs to take.',\n },\n {\n collection: 'supplierDeliveries',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'The dropship outbox for each of the org’s sites, keyed by hostId (AGL-1448). Disclosed to the ORG and to no one else, and the reason is the unusual one: the personal data in the row is a SHOPPER’s — name, email and shipping address in the body we sign and POST to the supplier. That is the merchant’s own order record, which they already read in their console and for which they are the controller and we the processor, so an org access request is entitled to it. It is NOT a `user`-subject source: a console member asking what we hold about THEM is not the shopper, and returning these rows would answer their request with a third party’s address. Withholding it was not an option worth taking — `eraseHost` destroys these rows, so an export that omitted them would delete what it never disclosed.',\n },\n // ── Top-level, field-keyed: what a path cascade cannot see ────────────\n {\n collection: 'apiKeys',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY — label, scopes, creating uid, dates. Never the credential, and never the document id, which is the SHA-256 of the token.',\n },\n {\n collection: 'outreachMailboxCredentials',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'EXISTENCE ONLY — which connected mailbox a credential authorizes, its provider, granted scopes and dates (AGL-2974), and since AGL-2978 the connected account’s address, its Google account id and the member who connected it: the organization’s own record of whose mailbox it holds. Every token is redacted by name and by shape (see redactSecrets) and never disclosed; the sealed refresh token and the id of the key that sealed it both carry `token` in their names. The document id is the mailbox id, not derived from any secret, so it is disclosed.',\n },\n {\n collection: 'outreachLinks',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Where each short tracking link in a sent Outreach sequence email points (AGL-3297): the enrollment, the step, the link’s index and the destination the step wrote. The organization’s own record of what it sent; the id is random and derived from no secret.',\n },\n {\n collection: 'ssoDomains',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'The public SSO routing document: domain, GCIP tenant and provider ids, IdP display name. `token` is redacted.',\n },\n {\n collection: CONSOLE_DOMAINS_COLLECTION,\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Custom console domain claims the org holds.',\n },\n {\n collection: 'apiIdempotency',\n keyedBy: 'field',\n subjects: ['org'],\n exported: false,\n note: 'Replay keys. The document id is a SHA-256 of the org id and the caller’s Idempotency-Key, and the row names a record id and nothing about a person. Disclosed as a COUNT so the export does not silently omit a collection the erasure destroys.',\n },\n {\n collection: 'stripeCustomers',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'The local reverse index: which Stripe customer id belongs to this workspace. An identifier the customer already holds on their own invoices.',\n },\n {\n collection: 'orgSlugs',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Every workspace name the org has ever held, rename tombstones included — naming history is a fact about them.',\n },\n {\n collection: 'publisherHandles',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Marketplace handle reservations and rename tombstones (AGL-1970).',\n },\n {\n collection: 'publisherProfiles',\n keyedBy: 'doc-id',\n subjects: ['org'],\n exported: true,\n note: 'The public publisher identity — handle, display name, bio, website, avatar, publisher agreement. `stripeAccountId` is the org’s own payout destination and is disclosed; nothing here is a credential.',\n },\n {\n collection: 'marketplaceListings',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Listings the org published. These deliberately OUTLIVE an erasure (AGL-1448 Tier 3) — which makes disclosing them on an access request more important, not less: they are the part that does not go away.',\n },\n {\n collection: 'supportTickets',\n keyedBy: 'field',\n subjects: ['org', 'user'],\n exported: true,\n note: 'For an ORG: every thread and its messages. For a PERSON: only the messages they themselves authored — a thread has other authors, and handing a member the whole conversation would disclose colleagues’ words to them.',\n },\n // ── Retained past erasure on statutory grounds ────────────────────────\n {\n collection: 'platformRevenue',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Per-transaction tax filing records. GDPR Art. 17(3)(b) exempts them from ERASURE (AGL-1811); it does not exempt them from ACCESS. If a transaction of theirs is in there it is disclosable — and the export is the only place a customer can see what survived their erasure.',\n },\n {\n collection: 'storefrontTaxCollected',\n keyedBy: 'field',\n subjects: ['org'],\n exported: true,\n note: 'Sales tax charged to shoppers on the org’s storefront (AGL-1904). Same statutory retention, same access obligation.',\n },\n // ── Records of our own handling ───────────────────────────────────────\n {\n collection: 'adminAudit',\n keyedBy: 'field',\n subjects: ['org', 'user'],\n exported: false,\n note: 'STAFF-ONLY. Rows name the staff actor, carry override reasons and internal notes, and are the record of our handling rather than data about the subject. `/admin/audit` → Export CSV serves a request that specifically asks for them, after a staff read.',\n },\n]\n\n/** Sources this export deliberately withholds, with the reason. */\nexport const EXPORT_WITHHELD = PERSONAL_DATA_SOURCES.filter((s) => !s.exported)\n\n/** Collection names the export claims to cover, for the coverage guard. */\nexport const EXPORT_COVERED_COLLECTIONS: readonly string[] =\n PERSONAL_DATA_SOURCES.map((source) => source.collection)\n\n/**\n * ## WHY THIS IS SHAPE-DRIVEN AND NOT A LIST OF NAMES\n *\n * It used to be a list of names, anchored on separators:\n *\n * /(^|[._-])(secret|token|password|apikey|…)($|[._-])/i\n *\n * That pattern requires a `.`, `_`, `-`, or a string boundary on both sides\n * of the word, so it reads `password_hash` and `webhooks.secret` and misses\n * `supplierToken` and `passwordScrypt` — the exact two fields the AGL-1881\n * review found leaving the platform in a customer's own export file. One is\n * a live bearer credential a supplier authenticates with; the other is a\n * shopper's scrypt password hash. **A denylist of names is always one naming\n * convention behind, and camelCase is the proof.**\n *\n * So the name half is now tokenized rather than separator-anchored, and it is\n * no longer the only gate. Three run, in order, and any of them redacts:\n *\n * * **(A) The name, tokenized.** `supplierToken` splits to\n * `['supplier','token']` the same way `supplier_token` and\n * `SUPPLIER-TOKEN` do, so the convention stops mattering. A name matches\n * on a single secret word or on an adjacent pair (`client secret`).\n * * **(B) The VALUE's shape, whatever the key is called.** This is the half\n * that does not need to know the schema: a PEM block, a JWT, a vendor\n * prefix (`sk_live_`, `whsec_`, `ghp_`, `AKIA`…), a `$2b$`/`$argon2` hash,\n * this repo's own `salt:hash` scrypt form, or an opaque high-entropy\n * string is a credential whether the field is called `supplierToken`,\n * `t`, or `notes`. **Gate B is the \"redact unless known safe\" default**:\n * a credential-shaped string is withheld unless its key names it as a\n * content identifier (see {@link SAFE_VALUE_WORDS}).\n * * **(C) Credentials carried inside a URL.** `?token=`, `?sig=`,\n * `?key=`, and `https://user:pass@host` are stripped from the URL rather\n * than the URL being blanked, because a media object's `url` field really\n * does carry a live Firebase download token in a query parameter, and the\n * location of the customer's own file is exactly what an access request\n * asks for. Precision beats blanking here.\n *\n * ### Why the KEY half is still deny-based, when the value half is not\n *\n * A true allowlist of field names is not available on this data. The export\n * walks documents whose field names our customers author — canvas nodes,\n * dataset records, custom metadata, form submissions — so an allowlist of\n * names would blank most of the file and turn an access request into a\n * non-answer, which is the under-disclosure this feature exists to stop. The\n * allowlist therefore lives where the vocabulary IS ours: value shapes. An\n * unrecognized VALUE shape is passed through (it is prose, a number, a date,\n * a customer's own content); an unrecognized value shape that looks like a\n * credential is withheld. That is the inversion the review asked for, put\n * where it can hold.\n *\n * ### The one deliberate carve-out\n *\n * `token`/`tokens` is also a metering word — `tokensUsed: 14203` on an\n * assist exchange is a usage count, and blanking it would blank a real\n * disclosure. A credential is a STRING; a count is a number. So a numeric or\n * boolean value under a name that matched only on a metering word passes\n * through. Nothing else in the vocabulary gets that treatment.\n *\n * A false positive costs a redacted field in an export. A false negative\n * costs a credential. Everything below is tuned in that direction.\n */\n\n/**\n * Split a field name into lowercase word tokens, convention-independently.\n *\n * `supplierToken`, `supplier_token`, `SUPPLIER-TOKEN` and `Supplier.Token`\n * all become `['supplier','token']`. Acronym runs split at the last capital\n * (`SSOTokenId` → `['sso','token','id']`) and digits break a word\n * (`apiKey2` → `['api','key','2']`), so a numbered field cannot hide.\n */\nexport function fieldNameTokens(key: unknown): string[] {\n return String(key ?? '')\n .replace(/[^A-Za-z0-9]+/g, ' ')\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2')\n .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')\n .replace(/([A-Za-z])([0-9])/g, '$1 $2')\n .toLowerCase()\n .split(' ')\n .filter(Boolean)\n}\n\n/**\n * Words that make a field a credential on their own.\n *\n * Deliberately short. Every entry here is a word that has no innocent\n * meaning on a stored VALUE — `hmac`, `scrypt` and `bcrypt` name the\n * algorithm that produced a verifier, `passphrase` and `credential` name the\n * thing itself. `key` is NOT here and must never be: `publicKey` on a\n * passkey and `sortKey` on a dataset are not secrets, which is what\n * {@link SECRET_WORD_PAIRS} is for.\n */\nconst SECRET_WORDS = new Set([\n 'secret',\n 'secrets',\n 'password',\n 'passwords',\n 'passwd',\n 'pwd',\n 'passphrase',\n 'token',\n 'tokens',\n 'credential',\n 'credentials',\n 'apikey',\n 'privatekey',\n 'scrypt',\n 'bcrypt',\n 'argon',\n 'argon2',\n 'pbkdf2',\n 'hmac',\n 'otp',\n 'totp',\n 'authorization',\n])\n\n/**\n * Words that are a credential only next to another word.\n *\n * `key` is meaningless alone and decisive after `api`, `private`, `signing`,\n * `secret`, `session` or `encryption`; `token` after `access`/`refresh` is\n * already caught by {@link SECRET_WORDS} and listed here anyway so the pair\n * table reads as the complete statement of the rule.\n */\nconst SECRET_WORD_PAIRS = new Set([\n 'api key',\n 'private key',\n 'signing key',\n 'secret key',\n 'session key',\n 'encryption key',\n 'client secret',\n 'access token',\n 'refresh token',\n 'webhook secret',\n 'shared secret',\n // A Stripe payment link is a live bearer URL: whoever holds it can pay.\n // Named in the AGL-1443 inventory of what the old dump actually carried.\n 'payment link',\n 'auth code',\n 'recovery code',\n 'backup code',\n])\n\n/**\n * The metering carve-out — see the header. A name that matched ONLY on one\n * of these, over a number or a boolean, is a count and is disclosed.\n */\nconst METERING_WORDS = new Set(['token', 'tokens'])\n\n/**\n * Key words that mark a high-entropy value as a content identifier rather\n * than a credential, defusing gate (B) only.\n *\n * A SHA-256 has exactly the shape of a 64-character secret and is not one.\n * Gate (A) is never defused by this list: `apiKeyId` is redacted, because\n * the name says credential and an id of a credential is not worth the\n * argument.\n */\nconst SAFE_VALUE_WORDS = new Set([\n // A passkey's `publicKey` is public by definition and has exactly the shape\n // of a secret. Safe to list because gate (A) is never defused here — a\n // `publicToken` is still redacted, on the strength of `token`.\n 'public',\n 'id',\n 'ids',\n 'uid',\n 'uuid',\n 'guid',\n 'hash',\n 'hashes',\n 'sha',\n 'sha1',\n 'sha256',\n 'sha512',\n 'md5',\n 'crc',\n 'crc32c',\n 'etag',\n 'checksum',\n 'digest',\n 'fingerprint',\n 'generation',\n 'version',\n 'revision',\n 'slug',\n 'path',\n 'url',\n 'uri',\n 'href',\n 'src',\n 'color',\n 'colour',\n 'hex',\n])\n\n/** Vendor credential prefixes — unambiguous, and worth naming explicitly. */\nconst CREDENTIAL_PREFIXES = [\n 'sk_live_',\n 'sk_test_',\n 'rk_live_',\n 'rk_test_',\n 'whsec_',\n 'ghp_',\n 'gho_',\n 'ghu_',\n 'ghs_',\n 'ghr_',\n 'github_pat_',\n 'xoxb-',\n 'xoxp-',\n 'xapp-',\n 'xoxa-',\n 'AKIA',\n 'ASIA',\n 'AIza',\n 'ya29.',\n 'SG.',\n 're_',\n 'shpat_',\n 'shpss_',\n 'npm_',\n 'glpat-',\n 'sq0csp-',\n 'sq0atp-',\n]\n\n/** A PEM block that carries a private key or a certificate request. */\nconst PEM_PATTERN = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/\n\n/** Modular crypt format: bcrypt, argon2, scrypt, PBKDF2, sha512-crypt. */\nconst CRYPT_HASH_PATTERN = /^\\$(2[abxy]?|argon2[a-z]*|scrypt|pbkdf2[^$]*|[56])\\$/\n\n/** This repo's own scrypt storage form: `{16+ hex salt}:{32+ hex hash}`. */\nconst SALTED_HASH_PATTERN = /^[0-9a-f]{16,}:[0-9a-f]{32,}$/i\n\n/** A JSON Web Token — three base64url segments. */\nconst JWT_PATTERN = /^[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}$/\n\n/** A UUID, which is an identifier far more often than it is a credential. */\nconst UUID_PATTERN =\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n/** Charset of an opaque credential: base64, base64url or hex, nothing else. */\nconst OPAQUE_CHARSET = /^[A-Za-z0-9_+/=-]+$/\n\n/**\n * The window an opaque secret lives in.\n *\n * Below 32 characters the false-positive rate against ordinary identifiers is\n * not worth it and the value is too short to be a modern credential anyway.\n * Above 512 it is customer CONTENT — a base64 image, a compressed canvas\n * blob, an encoded document — and blanking those is the \"gutting the export\"\n * failure this file is explicitly trying not to commit.\n */\nconst OPAQUE_MIN_LENGTH = 32\nconst OPAQUE_MAX_LENGTH = 512\n\n/** Shannon entropy per character. Prose and slugs sit well below this. */\nconst OPAQUE_MIN_ENTROPY = 3.2\n\nfunction shannonEntropyPerChar(text: string): number {\n const counts = new Map<string, number>()\n for (const character of text) {\n counts.set(character, (counts.get(character) ?? 0) + 1)\n }\n let entropy = 0\n for (const count of counts.values()) {\n const p = count / text.length\n entropy -= p * Math.log2(p)\n }\n return entropy\n}\n\n/**\n * Does this STRING look like a credential, ignoring what it is called?\n *\n * Exported because the guard spec asserts each limb independently — a shape\n * detector tested only through the redactor is a detector whose gaps are\n * invisible.\n */\nexport function looksLikeCredential(value: string): boolean {\n const text = value.trim()\n if (!text) return false\n if (PEM_PATTERN.test(text)) return true\n if (CRYPT_HASH_PATTERN.test(text)) return true\n if (SALTED_HASH_PATTERN.test(text)) return true\n if (CREDENTIAL_PREFIXES.some((prefix) => text.startsWith(prefix))) return true\n if (JWT_PATTERN.test(text)) return true\n // Anything with whitespace is prose, and `data:` is an inline asset. Both\n // are customer content, and neither is a bearer credential.\n if (/\\s/.test(text)) return false\n if (/^data:/i.test(text)) return false\n if (UUID_PATTERN.test(text)) return false\n if (text.length < OPAQUE_MIN_LENGTH || text.length > OPAQUE_MAX_LENGTH) {\n return false\n }\n if (!OPAQUE_CHARSET.test(text)) return false\n return shannonEntropyPerChar(text) >= OPAQUE_MIN_ENTROPY\n}\n\n/** What a stripped URL parameter reads as, so the removal is visible. */\nexport const REDACTED_URL_PARAM = 'REDACTED'\n\n/** Query parameters that carry a bearer credential inside a URL. */\nconst URL_CREDENTIAL_PARAMS = new Set([\n 'token',\n 'access_token',\n 'accesstoken',\n 'id_token',\n 'refresh_token',\n 'auth',\n 'authorization',\n 'apikey',\n 'api_key',\n 'key',\n 'sig',\n 'signature',\n 'secret',\n 'password',\n 'downloadtoken',\n])\n\n/**\n * Strip the credential out of a URL, keeping the URL.\n *\n * A media object's `url` is `…/o/{path}?alt=media&token={uuid}` — a live,\n * unauthenticated download credential, in a field an access request has a\n * real interest in. Blanking the whole value would tell the customer nothing\n * about where their own file is; keeping it whole hands out the credential.\n * So the parameter is replaced and the rest survives. `user:pass@` userinfo\n * goes the same way.\n *\n * Returns the input unchanged when it is not a URL — every non-URL string\n * has already been decided by the shape gate.\n */\nexport function scrubUrlCredentials(value: string): string {\n if (!/^https?:\\/\\//i.test(value)) return value\n let url: URL\n try {\n url = new URL(value)\n } catch {\n return value\n }\n let changed = false\n if (url.username || url.password) {\n url.username = ''\n url.password = ''\n changed = true\n }\n for (const name of Array.from(url.searchParams.keys())) {\n if (!URL_CREDENTIAL_PARAMS.has(name.toLowerCase())) continue\n if (!url.searchParams.get(name)) continue\n url.searchParams.set(name, REDACTED_URL_PARAM)\n changed = true\n }\n return changed ? url.toString() : value\n}\n\n/**\n * Gate (A): does the NAME say credential?\n *\n * `value` is consulted only for the metering carve-out described in the\n * header — a `tokensUsed: 14203` is a count, not a credential.\n */\nexport function secretByName(key: unknown, value?: unknown): boolean {\n const tokens = fieldNameTokens(key)\n if (!tokens.length) return false\n const hits = tokens.filter((token) => SECRET_WORDS.has(token))\n for (let index = 0; index + 1 < tokens.length; index += 1) {\n if (SECRET_WORD_PAIRS.has(`${tokens[index]} ${tokens[index + 1]}`)) {\n return true\n }\n }\n if (!hits.length) return false\n // Matched only on a metering word, over a number or a boolean: a count.\n const metering = hits.every((token) => METERING_WORDS.has(token))\n if (metering && (typeof value === 'number' || typeof value === 'boolean')) {\n return false\n }\n return true\n}\n\n/** Gate (B)'s defuser: does the NAME say this high-entropy value is an id? */\nfunction nameMarksContentIdentifier(key: unknown): boolean {\n return fieldNameTokens(key).some((token) => SAFE_VALUE_WORDS.has(token))\n}\n\n/** What a redacted value is replaced with — presence, never content. */\nexport interface RedactedValue {\n redacted: true\n /** Whether a value was actually there, so absence is distinguishable. */\n present: boolean\n reason: 'secret'\n}\n\nconst redactedMarker = (present: boolean): RedactedValue => ({\n redacted: true,\n present,\n reason: 'secret',\n})\n\n/**\n * Deep-copy a document, replacing every credential-shaped value with a\n * presence marker.\n *\n * Presence rather than deletion, because \"we hold a webhook secret for you\"\n * is itself a true and disclosable fact, and a silently-absent key would make\n * the export look like it holds less than it does — the under-disclosure this\n * feature exists to stop, reintroduced by the safety measure.\n *\n * Firestore values that are not plain JSON (Timestamp, GeoPoint, DocumentRef,\n * Buffer) are stringified rather than walked: they carry no nested keys to\n * redact, and `JSON.stringify` on a Timestamp yields `{_seconds,_nanoseconds}`,\n * which is not a date to a human reading the file.\n *\n * Three gates, any of which redacts — the name (A), the value's shape (B),\n * and a credential embedded in a URL (C). See the header above\n * {@link fieldNameTokens} for why the value half is the one that carries the\n * \"withhold unless known safe\" default.\n */\nexport function redactSecrets(value: unknown, key = ''): unknown {\n // (A) The name, tokenized — convention-independent.\n if (key && secretByName(key, value)) {\n return redactedMarker(value !== undefined && value !== null && value !== '')\n }\n if (value === null || value === undefined) return value ?? null\n if (typeof value === 'string') {\n // (C) A URL that carries its credential in a parameter keeps the URL and\n // loses the credential — see {@link scrubUrlCredentials}.\n const scrubbed = scrubUrlCredentials(value)\n if (scrubbed !== value) return scrubbed\n // (B) The shape, whatever the field is called. Withheld unless the name\n // marks it as a content identifier.\n if (looksLikeCredential(value) && !nameMarksContentIdentifier(key)) {\n return redactedMarker(true)\n }\n return value\n }\n if (Array.isArray(value)) return value.map((entry) => redactSecrets(entry, key))\n if (typeof value === 'object') {\n const source = value as Record<string, unknown>\n // Firestore Timestamp — the only non-plain value common enough to be\n // worth rendering rather than dumping.\n if (typeof (source as any).toDate === 'function') {\n try {\n return (source as any).toDate().toISOString()\n } catch {\n return String(value)\n }\n }\n if (typeof (source as any).toBase64 === 'function') return '[binary]'\n const out: Record<string, unknown> = {}\n for (const [childKey, childValue] of Object.entries(source)) {\n out[childKey] = redactSecrets(childValue, childKey)\n }\n return out\n }\n return value\n}\n\nexport interface ExportedDocument {\n /** Document id — omitted where the id is itself a secret (see `apiKeys`). */\n id?: string\n path?: string\n data: unknown\n}\n\nexport interface PersonalDataExport {\n subject: { type: ExportSubject; id: string }\n generatedAt: string\n /**\n * The disclosure decision for every source, copied into the file the\n * customer receives. An export that silently omits a collection is\n * indistinguishable from one that has nothing to report; this makes the two\n * different, and it is what a supervisory authority would ask to see.\n */\n coverage: readonly ExportSourceSpec[]\n /** Collection name → the documents disclosed for it. */\n data: Record<string, ExportedDocument[]>\n /** Collection name → row count, for sources disclosed as a count only. */\n counts: Record<string, number>\n /** Places we hold data that is not in Firestore. */\n elsewhere: readonly string[]\n}\n\n/** Data we hold outside Firestore — named, because a file that omits them reads as complete. */\nconst ELSEWHERE = [\n 'Stripe — customer object, payment methods, invoices. Request from Stripe or ask us.',\n 'Resend — transactional email delivery logs.',\n 'Google Analytics — 14-month retention, not keyed to an account.',\n 'Cloud Storage — uploaded media and avatars, under users/{uid}/, orgs/{orgId}/ and hosts/{hostId}/. Listed by path in this file; the bytes are served by their own URLs.',\n]\n\nfunction sourcesFor(subject: ExportSubject): ExportSourceSpec[] {\n return PERSONAL_DATA_SOURCES.filter((source) => source.subjects.includes(subject))\n}\n\n/** Read a top-level collection bounded by a field equal to the subject id. */\nasync function readByField(\n db: any,\n collection: string,\n field: string,\n id: string,\n options: { includeIds?: boolean } = {},\n): Promise<ExportedDocument[]> {\n const rows = await db.collection(collection).where(field, '==', id).get()\n return rows.docs.map((doc: any) => ({\n // `apiKeys` document ids are the SHA-256 of the token: an id handed back\n // is a verifier handed back, so the caller opts in rather than out.\n ...(options.includeIds === false ? {} : { id: doc.id }),\n data: redactSecrets(doc.data()),\n }))\n}\n\n/** Read one document by id, or nothing when it does not exist. */\nasync function readDoc(\n db: any,\n collection: string,\n id: string,\n): Promise<ExportedDocument[]> {\n const snapshot = await db.collection(collection).doc(id).get()\n if (!snapshot.exists) return []\n return [{ id, data: redactSecrets(snapshot.data()) }]\n}\n\n/**\n * Walk a document and everything beneath it.\n *\n * `listCollections()` rather than a hard-coded subcollection list, for the\n * same reason the erasure uses `recursiveDelete`: a subcollection added next\n * quarter is reached without anybody remembering this file exists. Bounded by\n * `maxDepth` because the tree is customer-shaped and an unbounded recursion\n * inside a request is how AGL-1455 lost erasures to a 60-second timeout.\n */\nasync function readSubtree(\n db: any,\n ref: any,\n depth = 0,\n maxDepth = 4,\n): Promise<ExportedDocument[]> {\n const snapshot = await ref.get()\n const found: ExportedDocument[] = []\n if (snapshot.exists) {\n found.push({ path: ref.path, data: redactSecrets(snapshot.data()) })\n }\n if (depth >= maxDepth) return found\n const children = (await ref.listCollections?.()) ?? []\n for (const child of children) {\n const rows = await child.get()\n for (const doc of rows.docs) {\n found.push(...(await readSubtree(db, doc.ref, depth + 1, maxDepth)))\n }\n }\n return found\n}\n\nexport interface ExportOptions {\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n /** Stops a pathological workspace from building an unbounded file. */\n maxDepth?: number\n}\n\n/**\n * Everything we hold about a PERSON, as the person themselves may ask for it\n * (GDPR Art. 15 and Art. 20).\n *\n * Scoped to them, never to the workspaces they belong to. Their membership row\n * in each org is theirs; the roster around it is their colleagues'. An org\n * export is a separate call with a separate authorization, which is the whole\n * reason the two are different functions rather than one with a flag.\n */\nexport async function exportUserData(\n uid: string,\n options: ExportOptions = {},\n): Promise<PersonalDataExport> {\n const db = options.firestore ?? firestoreApp()\n const data: Record<string, ExportedDocument[]> = {}\n const counts: Record<string, number> = {}\n\n // The profile tree — `users/{uid}` and every subcollection under it.\n data['users'] = await readSubtree(\n db,\n db.collection('users').doc(uid),\n 0,\n options.maxDepth ?? 4,\n )\n\n // The public identity, which the user tree does not reach (AGL-1970).\n data['profiles'] = await readDoc(db, 'profiles', uid)\n\n // ONLY the messages this person wrote. The threads belong to a workspace\n // and carry other people's words; handing a member the whole conversation\n // to answer their own access request is a disclosure nobody consented to.\n const messages = await db\n .collectionGroup('messages')\n .where('authorId', '==', uid)\n .get()\n .catch(() => ({ docs: [] as any[] }))\n data['supportTickets'] = messages.docs.map((doc: any) => ({\n path: doc.ref.path,\n data: redactSecrets(doc.data()),\n }))\n\n return {\n subject: { type: 'user', id: uid },\n generatedAt: new Date().toISOString(),\n coverage: sourcesFor('user'),\n data,\n counts,\n elsewhere: ELSEWHERE,\n }\n}\n\n/**\n * Everything we hold about a WORKSPACE, for its owner (Art. 15 for the\n * personal data in it, and the \"export before you delete\" the Delete tab,\n * DPA §11 and Terms §13.3 have been promising).\n *\n * Every top-level collection the erasure sweeps by `orgId` is read here by the\n * same bound, so the two see the same rows. Where a source is withheld, its\n * COUNT is still reported: a collection that appears in neither the data nor\n * the counts is indistinguishable from one that does not exist, and that\n * ambiguity is what makes an incomplete export undetectable.\n */\nexport async function exportOrgData(\n orgId: string,\n options: ExportOptions = {},\n): Promise<PersonalDataExport> {\n const db = options.firestore ?? firestoreApp()\n const data: Record<string, ExportedDocument[]> = {}\n const counts: Record<string, number> = {}\n\n data['orgs'] = await readSubtree(\n db,\n db.collection('orgs').doc(orgId),\n 0,\n options.maxDepth ?? 4,\n )\n\n // Sites: the routing entry and the whole tree for each.\n const hosts = await db.collection('hosts').where('orgId', '==', orgId).get()\n const hostDocs: ExportedDocument[] = []\n const routing: ExportedDocument[] = []\n const outbox: ExportedDocument[] = []\n for (const host of hosts.docs) {\n hostDocs.push(...(await readSubtree(db, host.ref, 0, options.maxDepth ?? 4)))\n routing.push(...(await readDoc(db, 'hostIndex', host.id)))\n // Keyed by `hostId`, not `orgId`, so it is read here rather than in the\n // field-keyed loop below — the same bound `eraseHostSupplierDeliveries`\n // sweeps by, so the export and the erasure see the same rows.\n outbox.push(...(await readByField(db, 'supplierDeliveries', 'hostId', host.id)))\n }\n data['hosts'] = hostDocs\n data['hostIndex'] = routing\n data['supplierDeliveries'] = outbox\n\n // The field-keyed collections a path cascade is blind to — the ones the\n // erasure had to grow a sweep for, one issue at a time.\n for (const collection of [\n 'ssoDomains',\n CONSOLE_DOMAINS_COLLECTION,\n 'stripeCustomers',\n 'orgSlugs',\n 'publisherHandles',\n 'platformRevenue',\n 'storefrontTaxCollected',\n ]) {\n data[collection] = await readByField(db, collection, 'orgId', orgId)\n }\n\n // Existence, never the credential — and not the id either, which is the\n // token's SHA-256.\n data['apiKeys'] = await readByField(db, 'apiKeys', 'orgId', orgId, {\n includeIds: false,\n })\n\n // Which mailboxes the reps connected, never the grant behind one: the\n // tokens are withheld by `redactSecrets` like every other read here. The id\n // is the mailbox's own, so unlike `apiKeys` it stays.\n data['outreachMailboxCredentials'] = await readByField(\n db,\n 'outreachMailboxCredentials',\n 'orgId',\n orgId,\n )\n\n // Where each short tracking link the org's sequences sent points (AGL-3297).\n data['outreachLinks'] = await readByField(db, 'outreachLinks', 'orgId', orgId)\n\n data['publisherProfiles'] = await readDoc(db, 'publisherProfiles', orgId)\n data['marketplaceListings'] = await readByField(\n db,\n 'marketplaceListings',\n 'profileId',\n orgId,\n )\n\n // Support threads WITH their messages — for an org the whole thread is the\n // org's own record, unlike the person-scoped export above.\n const tickets = await db\n .collection('supportTickets')\n .where('orgId', '==', orgId)\n .get()\n const ticketDocs: ExportedDocument[] = []\n for (const ticket of tickets.docs) {\n ticketDocs.push(...(await readSubtree(db, ticket.ref, 0, 2)))\n }\n data['supportTickets'] = ticketDocs\n\n // Withheld, but counted — see the note on this function.\n const replayKeys = await db\n .collection('apiIdempotency')\n .where('orgId', '==', orgId)\n .get()\n counts['apiIdempotency'] = replayKeys.docs.length\n\n return {\n subject: { type: 'org', id: orgId },\n generatedAt: new Date().toISOString(),\n coverage: sourcesFor('org'),\n data,\n counts,\n elsewhere: ELSEWHERE,\n }\n}\n\n/** A filename a human can file: subject, id and the date it was produced. */\nexport function exportFilename(exported: PersonalDataExport): string {\n const day = exported.generatedAt.slice(0, 10)\n return `aglyn-${exported.subject.type}-data-${exported.subject.id}-${day}.json`\n}\n\nexport default exportUserData\n"],"names":["firebaseAdmin","CONSOLE_DOMAINS_COLLECTION","firestoreApp","app","firestore","PERSONAL_DATA_SOURCES","collection","keyedBy","subjects","exported","note","EXPORT_WITHHELD","filter","s","EXPORT_COVERED_COLLECTIONS","map","source","fieldNameTokens","key","String","replace","toLowerCase","split","Boolean","SECRET_WORDS","Set","SECRET_WORD_PAIRS","METERING_WORDS","SAFE_VALUE_WORDS","CREDENTIAL_PREFIXES","PEM_PATTERN","CRYPT_HASH_PATTERN","SALTED_HASH_PATTERN","JWT_PATTERN","UUID_PATTERN","OPAQUE_CHARSET","OPAQUE_MIN_LENGTH","OPAQUE_MAX_LENGTH","OPAQUE_MIN_ENTROPY","shannonEntropyPerChar","text","counts","Map","character","set","get","entropy","count","values","p","length","Math","log2","looksLikeCredential","value","trim","test","some","prefix","startsWith","REDACTED_URL_PARAM","URL_CREDENTIAL_PARAMS","scrubUrlCredentials","url","URL","changed","username","password","name","Array","from","searchParams","keys","has","toString","secretByName","tokens","hits","token","index","metering","every","nameMarksContentIdentifier","redactedMarker","present","redacted","reason","redactSecrets","undefined","scrubbed","isArray","entry","toDate","toISOString","toBase64","out","childKey","childValue","Object","entries","ELSEWHERE","sourcesFor","subject","includes","readByField","db","field","id","options","rows","where","docs","doc","includeIds","data","readDoc","snapshot","exists","readSubtree","ref","depth","maxDepth","found","push","path","children","listCollections","child","exportUserData","uid","messages","collectionGroup","catch","type","generatedAt","Date","coverage","elsewhere","exportOrgData","orgId","hosts","hostDocs","routing","outbox","host","tickets","ticketDocs","ticket","replayKeys","exportFilename","day","slice"],"mappings":"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GACD,MAAMC,eAAe,IAAIC,IAAI;IAC3B;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAED;;;;;;;CAOC,GACD,MAAMC,oBAAoB,IAAID,IAAI;IAChC;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA,wEAAwE;IACxE,yEAAyE;IACzE;IACA;IACA;IACA;CACD;AAED;;;CAGC,GACD,MAAME,iBAAiB,IAAIF,IAAI;IAAC;IAAS;CAAS;AAElD;;;;;;;;CAQC,GACD,MAAMG,mBAAmB,IAAIH,IAAI;IAC/B,4EAA4E;IAC5E,uEAAuE;IACvE,+DAA+D;IAC/D;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAED,2EAA2E,GAC3E,MAAMI,sBAAsB;IAC1B;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAED,qEAAqE,GACrE,MAAMC,cAAc;AAEpB,wEAAwE,GACxE,MAAMC,qBAAqB;AAE3B,0EAA0E,GAC1E,MAAMC,sBAAsB;AAE5B,iDAAiD,GACjD,MAAMC,cAAc;AAEpB,2EAA2E,GAC3E,MAAMC,eACJ;AAEF,6EAA6E,GAC7E,MAAMC,iBAAiB;AAEvB;;;;;;;;CAQC,GACD,MAAMC,oBAAoB;AAC1B,MAAMC,oBAAoB;AAE1B,wEAAwE,GACxE,MAAMC,qBAAqB;AAE3B,SAASC,sBAAsBC,IAAY;IACzC,MAAMC,SAAS,IAAIC;IACnB,KAAK,MAAMC,aAAaH,KAAM;YACLC;QAAvBA,OAAOG,GAAG,CAACD,WAAW,EAACF,cAAAA,OAAOI,GAAG,CAACF,sBAAXF,cAAyB,KAAK;I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"}
@@ -63,7 +63,7 @@
63
63
  * verified account asks the plugin that keeps the site's preferences to
64
64
  * reopen that list and nothing else (`plugin-email-streams`).
65
65
  */
66
- import { type TopicSubscriptionState } from '@aglyn/aglyn/app-utils/email-topics';
66
+ import { type TopicSubscriptionState } from '@aglyn/aglyn/app-utils/subscription-topics';
67
67
  import { type PlatformMarketingConsentDecision, type PlatformMarketingConsoleSourceKind, type PlatformMarketingConsentState } from '@aglyn/aglyn/app-utils/platform-marketing-consent';
68
68
  import { type MarketingBasis, type MarketingConsentAssertedBy, type MarketingConsentReason, type MarketingConsentVerdict } from '@aglyn/aglyn/app-utils/marketing-consent';
69
69
  import { type EmailStreamRejoinOutcome, rejoinEmailStream } from '@aglyn/aglyn/plugin-manager/plugin-email-streams';
@@ -62,7 +62,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
62
62
  * The console's switch undoes it the same way, one list wide: a Yes from a
63
63
  * verified account asks the plugin that keeps the site's preferences to
64
64
  * reopen that list and nothing else (`plugin-email-streams`).
65
- */ import { readTopicSubscriptionState, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/email-topics";
65
+ */ import { readTopicSubscriptionState, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/subscription-topics";
66
66
  import { isPlatformMarketingConsentDecision, isPlatformMarketingConsoleSourceKind, isPlatformMarketingEmailSourceKind, platformMarketingConsentSource, platformMarketingEmailSource, PLATFORM_MARKETING_TOPIC_ID, platformMarketingUserFields, readPlatformMarketingConsent, USER_MARKETING_PROMPT_DISMISSED_AT_FIELD } from "@aglyn/aglyn/app-utils/platform-marketing-consent";
67
67
  import { marketingConsentDecision, readMarketingBasis, resolveMarketingConsentPolicy } from "@aglyn/aglyn/app-utils/marketing-consent";
68
68
  import { rejoinEmailStream } from "@aglyn/aglyn/plugin-manager/plugin-email-streams";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/platform-marketing-consent.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The record a console door writes when a person decides about the\n * platform's own product email (AGL-3185).\n *\n * ## Two documents, one decision\n *\n * The decision goes onto the person's own `users/{uid}` first — the console\n * shows the preference from there, and it must not depend on the operator's\n * CRM accepting a row — and then onto the operator's marketing contact for\n * the same address through `upsertHostContact`, which is every other capture\n * door's path and the only one the send-time consent join reads. The contact\n * half carries the decision AND its provenance, so a campaign's audience\n * count reports this basis as the person's own click and never as something\n * asserted on their behalf.\n *\n * ## Server-side, because the client cannot and should not\n *\n * The sign-up page is a client of the person's own project; the operator's\n * marketing org is somebody else's data. Only the Admin SDK writes there, and\n * only after the person's ID token has been verified by the route in front\n * of this module. The user-document half could be a client write — the rules\n * make it owner-writable — but a mirror the client writes and the server\n * reads back is two writers of one fact, so both halves are written here.\n *\n * ## Which host, and why it is a setting\n *\n * The marketing host is the deployment's, not the code's: this repository is\n * what a self-hosted install runs, and an install with no product audience of\n * its own has nowhere for a contact to go. `PLATFORM_MARKETING_HOST_ID` names\n * it, and when it is unset the decision is recorded on the person's document\n * alone. No hostname or document id is hardcoded here for the same reason\n * `platform-marketing-host.ts` gives: nothing in the code knows which site\n * is the operator's.\n *\n * ## The site's own pages answer the same question, for one list (AGL-3305)\n *\n * The marketing site's emails carry their own way out, and it writes the\n * site's lists, not either consent record. An unsubscribe that was about the\n * list the console's question stands for — `PLATFORM_MARKETING_TOPIC_ID`, or\n * everything the site sends — is mirrored onto the ACCOUNT here, through an\n * email door; an unsubscribe from any other list is not, because the person\n * said nothing about product updates. The contact is left alone on purpose:\n * its basis covers every list the site sends, and declining it would stop the\n * newsletter for somebody who only left product updates.\n *\n * The console's switch undoes it the same way, one list wide: a Yes from a\n * verified account asks the plugin that keeps the site's preferences to\n * reopen that list and nothing else (`plugin-email-streams`).\n */\n\nimport {\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionEntry,\n type TopicSubscriptionState,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport {\n isPlatformMarketingConsentDecision,\n isPlatformMarketingConsoleSourceKind,\n isPlatformMarketingEmailSourceKind,\n type PlatformMarketingConsentDecision,\n type PlatformMarketingConsoleSourceKind,\n type PlatformMarketingConsentState,\n platformMarketingConsentSource,\n platformMarketingEmailSource,\n PLATFORM_MARKETING_TOPIC_ID,\n platformMarketingUserFields,\n readPlatformMarketingConsent,\n USER_MARKETING_PROMPT_DISMISSED_AT_FIELD,\n} from '@aglyn/aglyn/app-utils/platform-marketing-consent'\nimport {\n marketingConsentDecision,\n type MarketingBasis,\n type MarketingConsentAssertedBy,\n type MarketingConsentReason,\n type MarketingConsentVerdict,\n readMarketingBasis,\n resolveMarketingConsentPolicy,\n} from '@aglyn/aglyn/app-utils/marketing-consent'\nimport {\n type EmailStreamRejoinOutcome,\n rejoinEmailStream,\n} from '@aglyn/aglyn/plugin-manager/plugin-email-streams'\nimport { attributableAccountForAddress } from './account-addresses'\nimport { findContactByEmail } from './contact-email-index'\nimport {\n EMAIL_SUPPRESSIONS_COLLECTION,\n emailSuppressionKey,\n HOST_SUPPRESSIONS_SUBCOLLECTION,\n} from './email-suppression'\nimport firebaseAdmin from './firebase-admin'\nimport {\n consentGroupForSite,\n getOrgForHost,\n orgDataCollectionForHost,\n} from './organizations'\nimport { upsertHostContact } from './upsert-contact'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/**\n * The variable naming the host whose contacts receive product-marketing\n * consent — the operator's own marketing site. Unset on a self-hosted\n * install, and honored as \"record the preference, capture no contact\".\n */\nexport const PLATFORM_MARKETING_HOST_ID_ENV = 'PLATFORM_MARKETING_HOST_ID'\n\n/**\n * The configured marketing host, or `null` when the deployment names none.\n *\n * Read at call time rather than at module load, so a test can set it and a\n * process that started without it picks up a later value the same way every\n * other runtime setting here does.\n */\nexport function platformMarketingHostId(): string | null {\n const value = String(process.env.PLATFORM_MARKETING_HOST_ID ?? '').trim()\n return value || null\n}\n\n/** What became of the contact half of the record. */\nexport type PlatformMarketingContactOutcome =\n | { status: 'recorded'; contactId: string; created: boolean }\n /** No marketing host is configured — a self-hosted install. */\n | { status: 'unconfigured' }\n /** The account carries no address a contact could be keyed on. */\n | { status: 'no-email' }\n /** The capture door refused the row — its band, an erasure, or an error. */\n | { status: 'refused'; reason: string }\n\nexport interface RecordPlatformMarketingConsentInput {\n uid: string\n /** The account's address, from the verified token. */\n email: string | null | undefined\n /** The name the token carries, for an account whose document holds none. */\n name?: string | null\n decision: PlatformMarketingConsentDecision\n /** A console door. An email door is recorded by the site's own pages. */\n source: PlatformMarketingConsoleSourceKind\n /** The wording version the door showed, stamped by the route. */\n textVersion: string\n /**\n * Whether the token proves the caller holds the mailbox — its email is\n * verified (AGL-3305). Only then does a Yes reopen the list on the\n * marketing site, because reopening it reverses what somebody did from\n * that mailbox; an unverified account could name a stranger's address.\n */\n mailboxVerified?: boolean\n /** Injectable for tests; defaults to the clock. */\n now?: number\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n /** Injectable for tests; defaults to {@link platformMarketingHostId}. */\n hostId?: string | null\n /** Injectable for tests; defaults to the real capture door. */\n upsert?: typeof upsertHostContact\n /** Injectable for tests; defaults to the `plugin-email-streams` slot. */\n rejoin?: typeof rejoinEmailStream\n}\n\n/**\n * What a Yes did to the marketing site's own lists (AGL-3305). Absent for a\n * No, and whenever there is no site or no address to reopen a list for.\n */\nexport type PlatformMarketingStreamOutcome =\n | EmailStreamRejoinOutcome\n /** The token does not prove the mailbox, so nothing was reopened. */\n | { status: 'unverified' }\n\nexport interface RecordPlatformMarketingConsentResult {\n /** The timestamp both documents carry. */\n atMs: number\n contact: PlatformMarketingContactOutcome\n stream?: PlatformMarketingStreamOutcome\n}\n\n/**\n * The name the person's own document holds, else the one the caller has.\n *\n * The sign-up form writes `firstName`/`lastName` onto the document; a Google\n * account carries its name on the token instead. Either is the name the\n * contact row should show, and a blank is left blank rather than invented.\n */\nfunction accountName(\n snapshot: { get?: (field: string) => unknown } | null | undefined,\n fallback: string | null | undefined,\n): string | undefined {\n const first = String(snapshot?.get?.('firstName') ?? '').trim()\n const last = String(snapshot?.get?.('lastName') ?? '').trim()\n const stored = [first, last].filter(Boolean).join(' ')\n return stored || String(fallback ?? '').trim() || undefined\n}\n\n/**\n * Records a decision on both documents.\n *\n * Loud on bad input, like `recordLegalAcceptance`: a decision that cannot be\n * attributed to an account, a door and a wording is not a record of anything.\n * Quiet on the contact half — a refused row is returned, not thrown — because\n * the person's own preference has already been recorded by then, and the\n * operator's CRM band is not a reason to tell them their choice failed.\n */\nexport async function recordPlatformMarketingConsent(\n input: RecordPlatformMarketingConsentInput,\n): Promise<RecordPlatformMarketingConsentResult> {\n const uid = String(input.uid ?? '').trim()\n if (!uid) throw new Error('recordPlatformMarketingConsent: uid is required')\n if (!isPlatformMarketingConsentDecision(input.decision)) {\n throw new Error('recordPlatformMarketingConsent: decision is required')\n }\n if (!isPlatformMarketingConsoleSourceKind(input.source)) {\n throw new Error('recordPlatformMarketingConsent: a console source is required')\n }\n const textVersion = String(input.textVersion ?? '').trim()\n if (!textVersion) {\n throw new Error('recordPlatformMarketingConsent: textVersion is required')\n }\n\n const atMs = input.now ?? Date.now()\n const source = platformMarketingConsentSource({\n kind: input.source,\n decision: input.decision,\n uid,\n atMs,\n textVersion,\n })\n\n const userRef = (input.firestore ?? firestore()).collection('users').doc(uid)\n const snapshot = await userRef.get()\n await userRef.set(\n platformMarketingUserFields({ decision: input.decision, atMs, source }),\n { merge: true },\n )\n\n const hostId =\n input.hostId === undefined ? platformMarketingHostId() : input.hostId\n if (!hostId) return { atMs, contact: { status: 'unconfigured' } }\n const email = String(input.email ?? '').trim()\n if (!email) return { atMs, contact: { status: 'no-email' } }\n\n const name = accountName(snapshot, input.name)\n const verdict = await (input.upsert ?? upsertHostContact)({\n hostId,\n email,\n ...(name ? { name } : {}),\n source: 'account',\n // The sentence the provenance carries is also the timeline row's: what\n // the person did, in the words an auditor reads.\n interaction: { atMs, summary: source.reason },\n ...(input.decision === 'granted'\n ? { marketingConsent: true }\n : { declineMarketingConsent: true }),\n marketingConsentSource: source,\n // An account on the platform is at least a lead for the platform's own\n // CRM, whichever way the marketing decision went; the floor never moves\n // a customer back.\n initialLifecycleStage: 'lead',\n })\n const contact: PlatformMarketingContactOutcome =\n 'refused' in verdict\n ? { status: 'refused', reason: verdict.refused }\n : { status: 'recorded', contactId: verdict.contactId, created: verdict.created }\n if (input.decision !== 'granted') return { atMs, contact }\n\n /*\n * A Yes reopens the one list it stands for (AGL-3305).\n *\n * After the contact, whatever the contact's outcome: an erasure that\n * refused the row also left a suppression the plugin will not lift, and it\n * says so. One list wide, never \"resubscribe to everything\" — somebody who\n * left every list and now asks for product updates has asked for product\n * updates.\n */\n const stream: PlatformMarketingStreamOutcome = input.mailboxVerified\n ? await (input.rejoin ?? rejoinEmailStream)({\n hostId,\n email,\n topicId: PLATFORM_MARKETING_TOPIC_ID,\n })\n : { status: 'unverified' }\n return { atMs, contact, stream }\n}\n\n/**\n * Stamps a prompt dismissal — no decision — so the console stays quiet for\n * the snooze window and asks once more after it.\n */\nexport async function snoozePlatformMarketingPrompt(\n uid: string,\n options: { now?: number; firestore?: any } = {},\n): Promise<{ atMs: number }> {\n const trimmed = String(uid ?? '').trim()\n if (!trimmed) throw new Error('snoozePlatformMarketingPrompt: uid is required')\n const atMs = options.now ?? Date.now()\n await (options.firestore ?? firestore())\n .collection('users')\n .doc(trimmed)\n .set({ [USER_MARKETING_PROMPT_DISMISSED_AT_FIELD]: atMs }, { merge: true })\n return { atMs }\n}\n\n/** The decision the person's own document holds, for the console to show. */\nexport async function readPlatformMarketingConsentForUser(\n uid: string,\n options: { firestore?: any } = {},\n): Promise<PlatformMarketingConsentState> {\n const trimmed = String(uid ?? '').trim()\n if (!trimmed) throw new Error('readPlatformMarketingConsentForUser: uid is required')\n const snapshot = await (options.firestore ?? firestore())\n .collection('users')\n .doc(trimmed)\n .get()\n return readPlatformMarketingConsent(\n (snapshot?.data?.() as Record<string, unknown> | undefined) ?? null,\n )\n}\n\n/** An entry on one of the two lists every campaign send passes. */\nexport interface PlatformMarketingSuppression {\n /** `site`: the marketing site's own list. `platform`: every sender's. */\n list: 'site' | 'platform'\n /** `unsubscribe` on the site list; `bounce`, `complaint` or `staff` platform-wide. */\n reason: string\n}\n\n/**\n * What the operator's own product email would decide about one address\n * (AGL-3292), read the way a campaign from the marketing site reads it.\n *\n * The decision the person made lives on `users/{uid}`, and nothing a send\n * does looks there. A campaign from the configured marketing site reads the\n * CONTACT `recordPlatformMarketingConsent` wrote into that site's\n * organization — its basis for the site, under the organization's policy —\n * and then drops anybody on either suppression list, and a campaign on the\n * product-updates list anybody who left it. An unsubscribe writes those\n * lists; since AGL-3305 one about product updates also turns the account's\n * answer to No, but the contact's basis stays as it was, because it covers\n * every list the site sends. So a staff reader asking \"may we email them?\"\n * needs this half, and needs it from the sender's own readers — the same\n * basis read, consent group, policy and lists — or it answers a question no\n * send asks.\n */\nexport type PlatformMarketingReach =\n /** No marketing site is configured — a self-hosted install. */\n | { status: 'unconfigured' }\n /** The account carries no address a contact could be keyed on. */\n | { status: 'no-email' }\n /** A read failed. Nothing about the person follows from it. */\n | { status: 'unreadable'; hostId: string }\n | {\n status: 'read'\n hostId: string\n orgId: string\n orgSlug: string | null\n /** `null` when the operator's CRM holds no contact for the address. */\n contactId: string | null\n basis: MarketingBasis\n basisAtMs: number | null\n /** Whose act the basis is — a person's own, or an operator's assertion. */\n assertedBy: MarketingConsentAssertedBy | null\n /** Which door recorded the basis (`console-signup`, a form, a backfill). */\n basisKind: string | null\n /** The consent rule's answer alone, before either list is consulted. */\n verdict: MarketingConsentVerdict\n reason: MarketingConsentReason\n /** The list that stops every send to the address, when one does. */\n suppression: PlatformMarketingSuppression | null\n /**\n * The address on the list the account's answer stands for (AGL-3305):\n * `opted-out` when they left product updates, `pending` while a\n * confirmation is unanswered. A campaign on that list drops both.\n */\n topic: PlatformMarketingTopicState\n }\n\n/** One address on {@link PLATFORM_MARKETING_TOPIC_ID}, as the site stores it. */\nexport interface PlatformMarketingTopicState {\n id: string\n state: TopicSubscriptionState\n /** When they left it, for an `opted-out` state; else `null`. */\n leftAtMs: number | null\n}\n\n/**\n * The entry that suppresses `email` for sends from `hostId`, if any.\n *\n * The site's own list first, because it is the one a person puts themselves\n * on from this sender's footer; the platform list second. The same two reads\n * `filterSendableForHost` makes: a site entry suppresses by existing, and a\n * platform entry until it is released.\n */\nasync function readPlatformMarketingSuppression(\n db: any,\n hostId: string,\n email: string,\n): Promise<PlatformMarketingSuppression | null> {\n const key = emailSuppressionKey(email)\n if (!key) return null\n const [site, platform] = await Promise.all([\n db\n .collection('hosts')\n .doc(hostId)\n .collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n .doc(key)\n .get(),\n db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key).get(),\n ])\n if (site?.exists) {\n return { list: 'site', reason: String(site.get('reason') ?? 'unsubscribe') }\n }\n if (platform?.exists && !platform.get('releasedAt')) {\n return { list: 'platform', reason: String(platform.get('reason') ?? 'staff') }\n }\n return null\n}\n\n/** Milliseconds from a stored Firestore timestamp or a number, else `null`. */\nfunction storedMs(value: unknown): number | null {\n if (typeof value === 'number' && Number.isFinite(value)) return value\n const toMillis = (value as { toMillis?: () => number } | null)?.toMillis\n return typeof toMillis === 'function' ? toMillis.call(value) : null\n}\n\n/**\n * Where the address stands on {@link PLATFORM_MARKETING_TOPIC_ID}.\n *\n * The site's per-topic record, read by key and through the one function that\n * knows its three states — the same read `filterTopicSendable` makes before\n * a campaign on that list goes out. No record is `subscribed`: nobody has\n * left a list they were never taken off.\n */\nasync function readPlatformMarketingTopic(\n db: any,\n hostId: string,\n email: string,\n): Promise<PlatformMarketingTopicState> {\n const key = emailSuppressionKey(email)\n const unrecorded = { id: PLATFORM_MARKETING_TOPIC_ID, state: 'subscribed' as const, leftAtMs: null }\n if (!key) return unrecorded\n const snapshot = await db\n .collection('hosts')\n .doc(hostId)\n .collection(TOPIC_OPT_OUTS_SUBCOLLECTION)\n .doc(key)\n .get()\n if (!snapshot?.exists) return unrecorded\n const entry = ((snapshot.get('topics') ?? {}) as Record<string, TopicSubscriptionEntry | undefined>)[\n PLATFORM_MARKETING_TOPIC_ID\n ]\n const state = readTopicSubscriptionState(entry)\n return {\n id: PLATFORM_MARKETING_TOPIC_ID,\n state,\n leftAtMs: state === 'opted-out' ? storedMs(entry?.optedOutAt) : null,\n }\n}\n\n/**\n * Reads {@link PlatformMarketingReach} for an address.\n *\n * Never throws: a failed read is `unreadable`, which the staff page shows as\n * unknown — never as \"no consent\", which would be a claim about the person.\n */\nexport async function readPlatformMarketingReach(input: {\n email: string | null | undefined\n /** Injectable for tests; defaults to {@link platformMarketingHostId}. */\n hostId?: string | null\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n}): Promise<PlatformMarketingReach> {\n const hostId =\n input.hostId === undefined ? platformMarketingHostId() : input.hostId\n if (!hostId) return { status: 'unconfigured' }\n const email = String(input.email ?? '').trim().toLowerCase()\n if (!email) return { status: 'no-email' }\n const db = input.firestore ?? firestore()\n try {\n const owner = await getOrgForHost(hostId)\n if (!owner) return { status: 'unreadable', hostId }\n const org = owner.org as Record<string, unknown>\n const [contact, group, suppression, topic] = await Promise.all([\n // Through the org-data seam, as the capture door finds the same row.\n orgDataCollectionForHost(hostId, 'contacts').then((contacts) =>\n findContactByEmail(contacts, email),\n ),\n consentGroupForSite(hostId, org),\n readPlatformMarketingSuppression(db, hostId, email),\n readPlatformMarketingTopic(db, hostId, email),\n ])\n const record = readMarketingBasis(\n (contact?.data?.() as Record<string, unknown> | undefined) ?? null,\n group,\n )\n const decision = marketingConsentDecision(\n record,\n resolveMarketingConsentPolicy(org['marketingConsentPolicy']),\n )\n return {\n status: 'read',\n hostId,\n orgId: owner.orgId,\n orgSlug: typeof org['slug'] === 'string' ? org['slug'] : null,\n contactId: contact?.id ?? null,\n basis: record.basis,\n basisAtMs: record.basisAtMs,\n assertedBy: record.assertedBy,\n basisKind: record.source?.kind ?? null,\n verdict: decision.verdict,\n reason: decision.reason,\n suppression,\n topic,\n }\n } catch (error) {\n console.error('[platform-marketing-consent] reach read failed', error)\n return { status: 'unreadable', hostId }\n }\n}\n\n/**\n * Why somebody who said Yes will not receive product updates, in the terms\n * of what they can do about it (AGL-3305) — or `null` when nothing on the\n * marketing site's lists stands in the way.\n *\n * - `unsubscribed`: they left everything, or left product updates, from an\n * email. Theirs to undo, and a Yes from a verified account does.\n * - `unconfirmed`: a confirmation for the list was asked and not answered.\n * - `paused`: a bounce, a complaint, an erasure or a staff hold. Not a\n * preference, so no switch of theirs lifts it.\n *\n * The consent basis is not judged here: the Yes this is asked about is the\n * basis. Only the lists a Yes cannot overrule are.\n */\nexport type PlatformMarketingHold =\n | { kind: 'unsubscribed' }\n | { kind: 'unconfirmed' }\n | { kind: 'paused'; reason: string }\n\nexport function platformMarketingHold(\n reach: PlatformMarketingReach,\n): PlatformMarketingHold | null {\n if (reach.status !== 'read') return null\n if (reach.suppression) {\n return reach.suppression.list === 'site' && reach.suppression.reason === 'unsubscribe'\n ? { kind: 'unsubscribed' }\n : { kind: 'paused', reason: reach.suppression.reason }\n }\n if (reach.topic.state === 'opted-out') return { kind: 'unsubscribed' }\n if (reach.topic.state === 'pending') return { kind: 'unconfirmed' }\n return null\n}\n\n/** What mirroring an email-side change onto the account did (AGL-3305). */\nexport type PlatformMarketingMirrorOutcome =\n /** Another site's list. An account is never touched from one. */\n | { status: 'not-platform' }\n /** A list the account's answer is not about. */\n | { status: 'other-list' }\n /** No account holds the address — or more than one does. */\n | { status: 'no-account' }\n /** The account already says so, or the rule leaves it where it is. */\n | { status: 'unchanged' }\n | { status: 'recorded'; uid: string; decision: PlatformMarketingConsentDecision }\n /** Logged; the site's own write, which is what stops the mail, stands. */\n | { status: 'failed' }\n\nexport interface PlatformMarketingMirrorInput {\n /** The site whose page the person used. */\n hostId: string\n /** The address the signed link was for. */\n email: string\n /** Injectable for tests; defaults to the clock. */\n now?: number\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n /** Injectable for tests; defaults to {@link platformMarketingHostId}. */\n platformHostId?: string | null\n /**\n * Injectable for tests; defaults to `attributableAccountForAddress`, which\n * answers `null` for nobody AND for more than one account. Changing the\n * wrong person's answer is worse than changing nobody's.\n */\n findAccount?: (email: string) => Promise<string | null>\n}\n\n/** The account a mirror would write, or the reason there is none. */\nasync function mirrorTarget(\n input: PlatformMarketingMirrorInput,\n): Promise<{ uid: string; ref: any; state: PlatformMarketingConsentState } | PlatformMarketingMirrorOutcome> {\n const platformHost =\n input.platformHostId === undefined ? platformMarketingHostId() : input.platformHostId\n if (!platformHost || input.hostId !== platformHost) return { status: 'not-platform' }\n const uid = await (input.findAccount ?? ((email: string) => attributableAccountForAddress(email)))(\n input.email,\n )\n if (!uid) return { status: 'no-account' }\n const ref = (input.firestore ?? firestore()).collection('users').doc(uid)\n const snapshot = await ref.get()\n return {\n uid,\n ref,\n state: readPlatformMarketingConsent(\n (snapshot?.data?.() as Record<string, unknown> | undefined) ?? null,\n ),\n }\n}\n\n/**\n * Mirrors an unsubscribe from the marketing site onto the account's answer.\n *\n * Called by the site's own pages AFTER their write, with what the person\n * left: `'everything'` for the site's list, or the topics left on the\n * preference page. Only the platform's marketing site counts, and only a\n * list the answer is about — {@link PLATFORM_MARKETING_TOPIC_ID}, which\n * everything includes. The first No keeps its date and its door: a second\n * unsubscribe is not a later decision.\n *\n * Never throws. The page has already stopped the mail; this is the account\n * catching up with it.\n */\nexport async function mirrorPlatformUnsubscribe(\n input: PlatformMarketingMirrorInput & { left: 'everything' | readonly string[] },\n): Promise<PlatformMarketingMirrorOutcome> {\n try {\n const everything = input.left === 'everything'\n if (!everything && !input.left.includes(PLATFORM_MARKETING_TOPIC_ID)) {\n // Cheaper to answer before the account lookup, and the answer does not\n // depend on the site: no other list is this one on any site.\n const platformHost =\n input.platformHostId === undefined ? platformMarketingHostId() : input.platformHostId\n return input.hostId === platformHost ? { status: 'other-list' } : { status: 'not-platform' }\n }\n const target = await mirrorTarget(input)\n if ('status' in target) return target\n if (target.state.decision === 'declined') return { status: 'unchanged' }\n const atMs = input.now ?? Date.now()\n const source = platformMarketingEmailSource(\n everything\n ? { kind: 'email-unsubscribe', decision: 'declined', uid: target.uid, atMs }\n : { kind: 'email-preferences', decision: 'declined', uid: target.uid, atMs },\n )\n await target.ref.set(\n platformMarketingUserFields({ decision: 'declined', atMs, source }),\n { merge: true },\n )\n return { status: 'recorded', uid: target.uid, decision: 'declined' }\n } catch (error) {\n console.error('[platform-marketing-consent] could not mirror an unsubscribe onto the account', error)\n return { status: 'failed' }\n }\n}\n\n/**\n * Mirrors a way back in from the marketing site: the resubscribe link, or\n * the list ticked again on the preference page.\n *\n * Narrower than the unsubscribe on purpose. It restores only a No that an\n * email door recorded — a No given in the console is the person's answer in\n * the console, and a link in an email does not overturn it — and only once\n * product updates would really reach them: nothing on either suppression\n * list, and the list itself not left. A resubscribe that lifted the site's\n * list while product updates stay unticked restores nothing.\n */\nexport async function mirrorPlatformResubscribe(\n input: PlatformMarketingMirrorInput & { via: 'email-resubscribe' | 'email-preferences' },\n): Promise<PlatformMarketingMirrorOutcome> {\n try {\n const target = await mirrorTarget(input)\n if ('status' in target) return target\n if (\n target.state.decision !== 'declined' ||\n !isPlatformMarketingEmailSourceKind(target.state.sourceKind)\n ) {\n return { status: 'unchanged' }\n }\n const db = input.firestore ?? firestore()\n const [suppression, topic] = await Promise.all([\n readPlatformMarketingSuppression(db, input.hostId, input.email),\n readPlatformMarketingTopic(db, input.hostId, input.email),\n ])\n if (suppression || topic.state !== 'subscribed') return { status: 'unchanged' }\n const atMs = input.now ?? Date.now()\n const source = platformMarketingEmailSource({\n kind: input.via,\n decision: 'granted',\n uid: target.uid,\n atMs,\n })\n await target.ref.set(\n platformMarketingUserFields({ decision: 'granted', atMs, source }),\n { merge: true },\n )\n return { status: 'recorded', uid: target.uid, decision: 'granted' }\n } catch (error) {\n console.error('[platform-marketing-consent] could not mirror a resubscribe onto the account', error)\n return { status: 'failed' }\n }\n}\n"],"names":["readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","isPlatformMarketingConsentDecision","isPlatformMarketingConsoleSourceKind","isPlatformMarketingEmailSourceKind","platformMarketingConsentSource","platformMarketingEmailSource","PLATFORM_MARKETING_TOPIC_ID","platformMarketingUserFields","readPlatformMarketingConsent","USER_MARKETING_PROMPT_DISMISSED_AT_FIELD","marketingConsentDecision","readMarketingBasis","resolveMarketingConsentPolicy","rejoinEmailStream","attributableAccountForAddress","findContactByEmail","EMAIL_SUPPRESSIONS_COLLECTION","emailSuppressionKey","HOST_SUPPRESSIONS_SUBCOLLECTION","firebaseAdmin","consentGroupForSite","getOrgForHost","orgDataCollectionForHost","upsertHostContact","firestore","app","PLATFORM_MARKETING_HOST_ID_ENV","platformMarketingHostId","process","value","String","env","PLATFORM_MARKETING_HOST_ID","trim","accountName","snapshot","fallback","first","get","last","stored","filter","Boolean","join","undefined","recordPlatformMarketingConsent","input","uid","Error","decision","source","textVersion","atMs","now","Date","kind","userRef","collection","doc","set","merge","hostId","contact","status","email","name","verdict","upsert","interaction","summary","reason","marketingConsent","declineMarketingConsent","marketingConsentSource","initialLifecycleStage","refused","contactId","created","stream","mailboxVerified","rejoin","topicId","snoozePlatformMarketingPrompt","options","trimmed","readPlatformMarketingConsentForUser","data","readPlatformMarketingSuppression","db","key","site","platform","Promise","all","exists","list","storedMs","Number","isFinite","toMillis","call","readPlatformMarketingTopic","unrecorded","id","state","leftAtMs","entry","optedOutAt","readPlatformMarketingReach","toLowerCase","record","owner","org","group","suppression","topic","then","contacts","orgId","orgSlug","basis","basisAtMs","assertedBy","basisKind","error","console","platformMarketingHold","reach","mirrorTarget","platformHost","platformHostId","findAccount","ref","mirrorPlatformUnsubscribe","everything","left","includes","target","mirrorPlatformResubscribe","sourceKind","via"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/platform-marketing-consent.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The record a console door writes when a person decides about the\n * platform's own product email (AGL-3185).\n *\n * ## Two documents, one decision\n *\n * The decision goes onto the person's own `users/{uid}` first — the console\n * shows the preference from there, and it must not depend on the operator's\n * CRM accepting a row — and then onto the operator's marketing contact for\n * the same address through `upsertHostContact`, which is every other capture\n * door's path and the only one the send-time consent join reads. The contact\n * half carries the decision AND its provenance, so a campaign's audience\n * count reports this basis as the person's own click and never as something\n * asserted on their behalf.\n *\n * ## Server-side, because the client cannot and should not\n *\n * The sign-up page is a client of the person's own project; the operator's\n * marketing org is somebody else's data. Only the Admin SDK writes there, and\n * only after the person's ID token has been verified by the route in front\n * of this module. The user-document half could be a client write — the rules\n * make it owner-writable — but a mirror the client writes and the server\n * reads back is two writers of one fact, so both halves are written here.\n *\n * ## Which host, and why it is a setting\n *\n * The marketing host is the deployment's, not the code's: this repository is\n * what a self-hosted install runs, and an install with no product audience of\n * its own has nowhere for a contact to go. `PLATFORM_MARKETING_HOST_ID` names\n * it, and when it is unset the decision is recorded on the person's document\n * alone. No hostname or document id is hardcoded here for the same reason\n * `platform-marketing-host.ts` gives: nothing in the code knows which site\n * is the operator's.\n *\n * ## The site's own pages answer the same question, for one list (AGL-3305)\n *\n * The marketing site's emails carry their own way out, and it writes the\n * site's lists, not either consent record. An unsubscribe that was about the\n * list the console's question stands for — `PLATFORM_MARKETING_TOPIC_ID`, or\n * everything the site sends — is mirrored onto the ACCOUNT here, through an\n * email door; an unsubscribe from any other list is not, because the person\n * said nothing about product updates. The contact is left alone on purpose:\n * its basis covers every list the site sends, and declining it would stop the\n * newsletter for somebody who only left product updates.\n *\n * The console's switch undoes it the same way, one list wide: a Yes from a\n * verified account asks the plugin that keeps the site's preferences to\n * reopen that list and nothing else (`plugin-email-streams`).\n */\n\nimport {\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionEntry,\n type TopicSubscriptionState,\n} from '@aglyn/aglyn/app-utils/subscription-topics'\nimport {\n isPlatformMarketingConsentDecision,\n isPlatformMarketingConsoleSourceKind,\n isPlatformMarketingEmailSourceKind,\n type PlatformMarketingConsentDecision,\n type PlatformMarketingConsoleSourceKind,\n type PlatformMarketingConsentState,\n platformMarketingConsentSource,\n platformMarketingEmailSource,\n PLATFORM_MARKETING_TOPIC_ID,\n platformMarketingUserFields,\n readPlatformMarketingConsent,\n USER_MARKETING_PROMPT_DISMISSED_AT_FIELD,\n} from '@aglyn/aglyn/app-utils/platform-marketing-consent'\nimport {\n marketingConsentDecision,\n type MarketingBasis,\n type MarketingConsentAssertedBy,\n type MarketingConsentReason,\n type MarketingConsentVerdict,\n readMarketingBasis,\n resolveMarketingConsentPolicy,\n} from '@aglyn/aglyn/app-utils/marketing-consent'\nimport {\n type EmailStreamRejoinOutcome,\n rejoinEmailStream,\n} from '@aglyn/aglyn/plugin-manager/plugin-email-streams'\nimport { attributableAccountForAddress } from './account-addresses'\nimport { findContactByEmail } from './contact-email-index'\nimport {\n EMAIL_SUPPRESSIONS_COLLECTION,\n emailSuppressionKey,\n HOST_SUPPRESSIONS_SUBCOLLECTION,\n} from './email-suppression'\nimport firebaseAdmin from './firebase-admin'\nimport {\n consentGroupForSite,\n getOrgForHost,\n orgDataCollectionForHost,\n} from './organizations'\nimport { upsertHostContact } from './upsert-contact'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/**\n * The variable naming the host whose contacts receive product-marketing\n * consent — the operator's own marketing site. Unset on a self-hosted\n * install, and honored as \"record the preference, capture no contact\".\n */\nexport const PLATFORM_MARKETING_HOST_ID_ENV = 'PLATFORM_MARKETING_HOST_ID'\n\n/**\n * The configured marketing host, or `null` when the deployment names none.\n *\n * Read at call time rather than at module load, so a test can set it and a\n * process that started without it picks up a later value the same way every\n * other runtime setting here does.\n */\nexport function platformMarketingHostId(): string | null {\n const value = String(process.env.PLATFORM_MARKETING_HOST_ID ?? '').trim()\n return value || null\n}\n\n/** What became of the contact half of the record. */\nexport type PlatformMarketingContactOutcome =\n | { status: 'recorded'; contactId: string; created: boolean }\n /** No marketing host is configured — a self-hosted install. */\n | { status: 'unconfigured' }\n /** The account carries no address a contact could be keyed on. */\n | { status: 'no-email' }\n /** The capture door refused the row — its band, an erasure, or an error. */\n | { status: 'refused'; reason: string }\n\nexport interface RecordPlatformMarketingConsentInput {\n uid: string\n /** The account's address, from the verified token. */\n email: string | null | undefined\n /** The name the token carries, for an account whose document holds none. */\n name?: string | null\n decision: PlatformMarketingConsentDecision\n /** A console door. An email door is recorded by the site's own pages. */\n source: PlatformMarketingConsoleSourceKind\n /** The wording version the door showed, stamped by the route. */\n textVersion: string\n /**\n * Whether the token proves the caller holds the mailbox — its email is\n * verified (AGL-3305). Only then does a Yes reopen the list on the\n * marketing site, because reopening it reverses what somebody did from\n * that mailbox; an unverified account could name a stranger's address.\n */\n mailboxVerified?: boolean\n /** Injectable for tests; defaults to the clock. */\n now?: number\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n /** Injectable for tests; defaults to {@link platformMarketingHostId}. */\n hostId?: string | null\n /** Injectable for tests; defaults to the real capture door. */\n upsert?: typeof upsertHostContact\n /** Injectable for tests; defaults to the `plugin-email-streams` slot. */\n rejoin?: typeof rejoinEmailStream\n}\n\n/**\n * What a Yes did to the marketing site's own lists (AGL-3305). Absent for a\n * No, and whenever there is no site or no address to reopen a list for.\n */\nexport type PlatformMarketingStreamOutcome =\n | EmailStreamRejoinOutcome\n /** The token does not prove the mailbox, so nothing was reopened. */\n | { status: 'unverified' }\n\nexport interface RecordPlatformMarketingConsentResult {\n /** The timestamp both documents carry. */\n atMs: number\n contact: PlatformMarketingContactOutcome\n stream?: PlatformMarketingStreamOutcome\n}\n\n/**\n * The name the person's own document holds, else the one the caller has.\n *\n * The sign-up form writes `firstName`/`lastName` onto the document; a Google\n * account carries its name on the token instead. Either is the name the\n * contact row should show, and a blank is left blank rather than invented.\n */\nfunction accountName(\n snapshot: { get?: (field: string) => unknown } | null | undefined,\n fallback: string | null | undefined,\n): string | undefined {\n const first = String(snapshot?.get?.('firstName') ?? '').trim()\n const last = String(snapshot?.get?.('lastName') ?? '').trim()\n const stored = [first, last].filter(Boolean).join(' ')\n return stored || String(fallback ?? '').trim() || undefined\n}\n\n/**\n * Records a decision on both documents.\n *\n * Loud on bad input, like `recordLegalAcceptance`: a decision that cannot be\n * attributed to an account, a door and a wording is not a record of anything.\n * Quiet on the contact half — a refused row is returned, not thrown — because\n * the person's own preference has already been recorded by then, and the\n * operator's CRM band is not a reason to tell them their choice failed.\n */\nexport async function recordPlatformMarketingConsent(\n input: RecordPlatformMarketingConsentInput,\n): Promise<RecordPlatformMarketingConsentResult> {\n const uid = String(input.uid ?? '').trim()\n if (!uid) throw new Error('recordPlatformMarketingConsent: uid is required')\n if (!isPlatformMarketingConsentDecision(input.decision)) {\n throw new Error('recordPlatformMarketingConsent: decision is required')\n }\n if (!isPlatformMarketingConsoleSourceKind(input.source)) {\n throw new Error('recordPlatformMarketingConsent: a console source is required')\n }\n const textVersion = String(input.textVersion ?? '').trim()\n if (!textVersion) {\n throw new Error('recordPlatformMarketingConsent: textVersion is required')\n }\n\n const atMs = input.now ?? Date.now()\n const source = platformMarketingConsentSource({\n kind: input.source,\n decision: input.decision,\n uid,\n atMs,\n textVersion,\n })\n\n const userRef = (input.firestore ?? firestore()).collection('users').doc(uid)\n const snapshot = await userRef.get()\n await userRef.set(\n platformMarketingUserFields({ decision: input.decision, atMs, source }),\n { merge: true },\n )\n\n const hostId =\n input.hostId === undefined ? platformMarketingHostId() : input.hostId\n if (!hostId) return { atMs, contact: { status: 'unconfigured' } }\n const email = String(input.email ?? '').trim()\n if (!email) return { atMs, contact: { status: 'no-email' } }\n\n const name = accountName(snapshot, input.name)\n const verdict = await (input.upsert ?? upsertHostContact)({\n hostId,\n email,\n ...(name ? { name } : {}),\n source: 'account',\n // The sentence the provenance carries is also the timeline row's: what\n // the person did, in the words an auditor reads.\n interaction: { atMs, summary: source.reason },\n ...(input.decision === 'granted'\n ? { marketingConsent: true }\n : { declineMarketingConsent: true }),\n marketingConsentSource: source,\n // An account on the platform is at least a lead for the platform's own\n // CRM, whichever way the marketing decision went; the floor never moves\n // a customer back.\n initialLifecycleStage: 'lead',\n })\n const contact: PlatformMarketingContactOutcome =\n 'refused' in verdict\n ? { status: 'refused', reason: verdict.refused }\n : { status: 'recorded', contactId: verdict.contactId, created: verdict.created }\n if (input.decision !== 'granted') return { atMs, contact }\n\n /*\n * A Yes reopens the one list it stands for (AGL-3305).\n *\n * After the contact, whatever the contact's outcome: an erasure that\n * refused the row also left a suppression the plugin will not lift, and it\n * says so. One list wide, never \"resubscribe to everything\" — somebody who\n * left every list and now asks for product updates has asked for product\n * updates.\n */\n const stream: PlatformMarketingStreamOutcome = input.mailboxVerified\n ? await (input.rejoin ?? rejoinEmailStream)({\n hostId,\n email,\n topicId: PLATFORM_MARKETING_TOPIC_ID,\n })\n : { status: 'unverified' }\n return { atMs, contact, stream }\n}\n\n/**\n * Stamps a prompt dismissal — no decision — so the console stays quiet for\n * the snooze window and asks once more after it.\n */\nexport async function snoozePlatformMarketingPrompt(\n uid: string,\n options: { now?: number; firestore?: any } = {},\n): Promise<{ atMs: number }> {\n const trimmed = String(uid ?? '').trim()\n if (!trimmed) throw new Error('snoozePlatformMarketingPrompt: uid is required')\n const atMs = options.now ?? Date.now()\n await (options.firestore ?? firestore())\n .collection('users')\n .doc(trimmed)\n .set({ [USER_MARKETING_PROMPT_DISMISSED_AT_FIELD]: atMs }, { merge: true })\n return { atMs }\n}\n\n/** The decision the person's own document holds, for the console to show. */\nexport async function readPlatformMarketingConsentForUser(\n uid: string,\n options: { firestore?: any } = {},\n): Promise<PlatformMarketingConsentState> {\n const trimmed = String(uid ?? '').trim()\n if (!trimmed) throw new Error('readPlatformMarketingConsentForUser: uid is required')\n const snapshot = await (options.firestore ?? firestore())\n .collection('users')\n .doc(trimmed)\n .get()\n return readPlatformMarketingConsent(\n (snapshot?.data?.() as Record<string, unknown> | undefined) ?? null,\n )\n}\n\n/** An entry on one of the two lists every campaign send passes. */\nexport interface PlatformMarketingSuppression {\n /** `site`: the marketing site's own list. `platform`: every sender's. */\n list: 'site' | 'platform'\n /** `unsubscribe` on the site list; `bounce`, `complaint` or `staff` platform-wide. */\n reason: string\n}\n\n/**\n * What the operator's own product email would decide about one address\n * (AGL-3292), read the way a campaign from the marketing site reads it.\n *\n * The decision the person made lives on `users/{uid}`, and nothing a send\n * does looks there. A campaign from the configured marketing site reads the\n * CONTACT `recordPlatformMarketingConsent` wrote into that site's\n * organization — its basis for the site, under the organization's policy —\n * and then drops anybody on either suppression list, and a campaign on the\n * product-updates list anybody who left it. An unsubscribe writes those\n * lists; since AGL-3305 one about product updates also turns the account's\n * answer to No, but the contact's basis stays as it was, because it covers\n * every list the site sends. So a staff reader asking \"may we email them?\"\n * needs this half, and needs it from the sender's own readers — the same\n * basis read, consent group, policy and lists — or it answers a question no\n * send asks.\n */\nexport type PlatformMarketingReach =\n /** No marketing site is configured — a self-hosted install. */\n | { status: 'unconfigured' }\n /** The account carries no address a contact could be keyed on. */\n | { status: 'no-email' }\n /** A read failed. Nothing about the person follows from it. */\n | { status: 'unreadable'; hostId: string }\n | {\n status: 'read'\n hostId: string\n orgId: string\n orgSlug: string | null\n /** `null` when the operator's CRM holds no contact for the address. */\n contactId: string | null\n basis: MarketingBasis\n basisAtMs: number | null\n /** Whose act the basis is — a person's own, or an operator's assertion. */\n assertedBy: MarketingConsentAssertedBy | null\n /** Which door recorded the basis (`console-signup`, a form, a backfill). */\n basisKind: string | null\n /** The consent rule's answer alone, before either list is consulted. */\n verdict: MarketingConsentVerdict\n reason: MarketingConsentReason\n /** The list that stops every send to the address, when one does. */\n suppression: PlatformMarketingSuppression | null\n /**\n * The address on the list the account's answer stands for (AGL-3305):\n * `opted-out` when they left product updates, `pending` while a\n * confirmation is unanswered. A campaign on that list drops both.\n */\n topic: PlatformMarketingTopicState\n }\n\n/** One address on {@link PLATFORM_MARKETING_TOPIC_ID}, as the site stores it. */\nexport interface PlatformMarketingTopicState {\n id: string\n state: TopicSubscriptionState\n /** When they left it, for an `opted-out` state; else `null`. */\n leftAtMs: number | null\n}\n\n/**\n * The entry that suppresses `email` for sends from `hostId`, if any.\n *\n * The site's own list first, because it is the one a person puts themselves\n * on from this sender's footer; the platform list second. The same two reads\n * `filterSendableForHost` makes: a site entry suppresses by existing, and a\n * platform entry until it is released.\n */\nasync function readPlatformMarketingSuppression(\n db: any,\n hostId: string,\n email: string,\n): Promise<PlatformMarketingSuppression | null> {\n const key = emailSuppressionKey(email)\n if (!key) return null\n const [site, platform] = await Promise.all([\n db\n .collection('hosts')\n .doc(hostId)\n .collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n .doc(key)\n .get(),\n db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key).get(),\n ])\n if (site?.exists) {\n return { list: 'site', reason: String(site.get('reason') ?? 'unsubscribe') }\n }\n if (platform?.exists && !platform.get('releasedAt')) {\n return { list: 'platform', reason: String(platform.get('reason') ?? 'staff') }\n }\n return null\n}\n\n/** Milliseconds from a stored Firestore timestamp or a number, else `null`. */\nfunction storedMs(value: unknown): number | null {\n if (typeof value === 'number' && Number.isFinite(value)) return value\n const toMillis = (value as { toMillis?: () => number } | null)?.toMillis\n return typeof toMillis === 'function' ? toMillis.call(value) : null\n}\n\n/**\n * Where the address stands on {@link PLATFORM_MARKETING_TOPIC_ID}.\n *\n * The site's per-topic record, read by key and through the one function that\n * knows its three states — the same read `filterTopicSendable` makes before\n * a campaign on that list goes out. No record is `subscribed`: nobody has\n * left a list they were never taken off.\n */\nasync function readPlatformMarketingTopic(\n db: any,\n hostId: string,\n email: string,\n): Promise<PlatformMarketingTopicState> {\n const key = emailSuppressionKey(email)\n const unrecorded = { id: PLATFORM_MARKETING_TOPIC_ID, state: 'subscribed' as const, leftAtMs: null }\n if (!key) return unrecorded\n const snapshot = await db\n .collection('hosts')\n .doc(hostId)\n .collection(TOPIC_OPT_OUTS_SUBCOLLECTION)\n .doc(key)\n .get()\n if (!snapshot?.exists) return unrecorded\n const entry = ((snapshot.get('topics') ?? {}) as Record<string, TopicSubscriptionEntry | undefined>)[\n PLATFORM_MARKETING_TOPIC_ID\n ]\n const state = readTopicSubscriptionState(entry)\n return {\n id: PLATFORM_MARKETING_TOPIC_ID,\n state,\n leftAtMs: state === 'opted-out' ? storedMs(entry?.optedOutAt) : null,\n }\n}\n\n/**\n * Reads {@link PlatformMarketingReach} for an address.\n *\n * Never throws: a failed read is `unreadable`, which the staff page shows as\n * unknown — never as \"no consent\", which would be a claim about the person.\n */\nexport async function readPlatformMarketingReach(input: {\n email: string | null | undefined\n /** Injectable for tests; defaults to {@link platformMarketingHostId}. */\n hostId?: string | null\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n}): Promise<PlatformMarketingReach> {\n const hostId =\n input.hostId === undefined ? platformMarketingHostId() : input.hostId\n if (!hostId) return { status: 'unconfigured' }\n const email = String(input.email ?? '').trim().toLowerCase()\n if (!email) return { status: 'no-email' }\n const db = input.firestore ?? firestore()\n try {\n const owner = await getOrgForHost(hostId)\n if (!owner) return { status: 'unreadable', hostId }\n const org = owner.org as Record<string, unknown>\n const [contact, group, suppression, topic] = await Promise.all([\n // Through the org-data seam, as the capture door finds the same row.\n orgDataCollectionForHost(hostId, 'contacts').then((contacts) =>\n findContactByEmail(contacts, email),\n ),\n consentGroupForSite(hostId, org),\n readPlatformMarketingSuppression(db, hostId, email),\n readPlatformMarketingTopic(db, hostId, email),\n ])\n const record = readMarketingBasis(\n (contact?.data?.() as Record<string, unknown> | undefined) ?? null,\n group,\n )\n const decision = marketingConsentDecision(\n record,\n resolveMarketingConsentPolicy(org['marketingConsentPolicy']),\n )\n return {\n status: 'read',\n hostId,\n orgId: owner.orgId,\n orgSlug: typeof org['slug'] === 'string' ? org['slug'] : null,\n contactId: contact?.id ?? null,\n basis: record.basis,\n basisAtMs: record.basisAtMs,\n assertedBy: record.assertedBy,\n basisKind: record.source?.kind ?? null,\n verdict: decision.verdict,\n reason: decision.reason,\n suppression,\n topic,\n }\n } catch (error) {\n console.error('[platform-marketing-consent] reach read failed', error)\n return { status: 'unreadable', hostId }\n }\n}\n\n/**\n * Why somebody who said Yes will not receive product updates, in the terms\n * of what they can do about it (AGL-3305) — or `null` when nothing on the\n * marketing site's lists stands in the way.\n *\n * - `unsubscribed`: they left everything, or left product updates, from an\n * email. Theirs to undo, and a Yes from a verified account does.\n * - `unconfirmed`: a confirmation for the list was asked and not answered.\n * - `paused`: a bounce, a complaint, an erasure or a staff hold. Not a\n * preference, so no switch of theirs lifts it.\n *\n * The consent basis is not judged here: the Yes this is asked about is the\n * basis. Only the lists a Yes cannot overrule are.\n */\nexport type PlatformMarketingHold =\n | { kind: 'unsubscribed' }\n | { kind: 'unconfirmed' }\n | { kind: 'paused'; reason: string }\n\nexport function platformMarketingHold(\n reach: PlatformMarketingReach,\n): PlatformMarketingHold | null {\n if (reach.status !== 'read') return null\n if (reach.suppression) {\n return reach.suppression.list === 'site' && reach.suppression.reason === 'unsubscribe'\n ? { kind: 'unsubscribed' }\n : { kind: 'paused', reason: reach.suppression.reason }\n }\n if (reach.topic.state === 'opted-out') return { kind: 'unsubscribed' }\n if (reach.topic.state === 'pending') return { kind: 'unconfirmed' }\n return null\n}\n\n/** What mirroring an email-side change onto the account did (AGL-3305). */\nexport type PlatformMarketingMirrorOutcome =\n /** Another site's list. An account is never touched from one. */\n | { status: 'not-platform' }\n /** A list the account's answer is not about. */\n | { status: 'other-list' }\n /** No account holds the address — or more than one does. */\n | { status: 'no-account' }\n /** The account already says so, or the rule leaves it where it is. */\n | { status: 'unchanged' }\n | { status: 'recorded'; uid: string; decision: PlatformMarketingConsentDecision }\n /** Logged; the site's own write, which is what stops the mail, stands. */\n | { status: 'failed' }\n\nexport interface PlatformMarketingMirrorInput {\n /** The site whose page the person used. */\n hostId: string\n /** The address the signed link was for. */\n email: string\n /** Injectable for tests; defaults to the clock. */\n now?: number\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n /** Injectable for tests; defaults to {@link platformMarketingHostId}. */\n platformHostId?: string | null\n /**\n * Injectable for tests; defaults to `attributableAccountForAddress`, which\n * answers `null` for nobody AND for more than one account. Changing the\n * wrong person's answer is worse than changing nobody's.\n */\n findAccount?: (email: string) => Promise<string | null>\n}\n\n/** The account a mirror would write, or the reason there is none. */\nasync function mirrorTarget(\n input: PlatformMarketingMirrorInput,\n): Promise<{ uid: string; ref: any; state: PlatformMarketingConsentState } | PlatformMarketingMirrorOutcome> {\n const platformHost =\n input.platformHostId === undefined ? platformMarketingHostId() : input.platformHostId\n if (!platformHost || input.hostId !== platformHost) return { status: 'not-platform' }\n const uid = await (input.findAccount ?? ((email: string) => attributableAccountForAddress(email)))(\n input.email,\n )\n if (!uid) return { status: 'no-account' }\n const ref = (input.firestore ?? firestore()).collection('users').doc(uid)\n const snapshot = await ref.get()\n return {\n uid,\n ref,\n state: readPlatformMarketingConsent(\n (snapshot?.data?.() as Record<string, unknown> | undefined) ?? null,\n ),\n }\n}\n\n/**\n * Mirrors an unsubscribe from the marketing site onto the account's answer.\n *\n * Called by the site's own pages AFTER their write, with what the person\n * left: `'everything'` for the site's list, or the topics left on the\n * preference page. Only the platform's marketing site counts, and only a\n * list the answer is about — {@link PLATFORM_MARKETING_TOPIC_ID}, which\n * everything includes. The first No keeps its date and its door: a second\n * unsubscribe is not a later decision.\n *\n * Never throws. The page has already stopped the mail; this is the account\n * catching up with it.\n */\nexport async function mirrorPlatformUnsubscribe(\n input: PlatformMarketingMirrorInput & { left: 'everything' | readonly string[] },\n): Promise<PlatformMarketingMirrorOutcome> {\n try {\n const everything = input.left === 'everything'\n if (!everything && !input.left.includes(PLATFORM_MARKETING_TOPIC_ID)) {\n // Cheaper to answer before the account lookup, and the answer does not\n // depend on the site: no other list is this one on any site.\n const platformHost =\n input.platformHostId === undefined ? platformMarketingHostId() : input.platformHostId\n return input.hostId === platformHost ? { status: 'other-list' } : { status: 'not-platform' }\n }\n const target = await mirrorTarget(input)\n if ('status' in target) return target\n if (target.state.decision === 'declined') return { status: 'unchanged' }\n const atMs = input.now ?? Date.now()\n const source = platformMarketingEmailSource(\n everything\n ? { kind: 'email-unsubscribe', decision: 'declined', uid: target.uid, atMs }\n : { kind: 'email-preferences', decision: 'declined', uid: target.uid, atMs },\n )\n await target.ref.set(\n platformMarketingUserFields({ decision: 'declined', atMs, source }),\n { merge: true },\n )\n return { status: 'recorded', uid: target.uid, decision: 'declined' }\n } catch (error) {\n console.error('[platform-marketing-consent] could not mirror an unsubscribe onto the account', error)\n return { status: 'failed' }\n }\n}\n\n/**\n * Mirrors a way back in from the marketing site: the resubscribe link, or\n * the list ticked again on the preference page.\n *\n * Narrower than the unsubscribe on purpose. It restores only a No that an\n * email door recorded — a No given in the console is the person's answer in\n * the console, and a link in an email does not overturn it — and only once\n * product updates would really reach them: nothing on either suppression\n * list, and the list itself not left. A resubscribe that lifted the site's\n * list while product updates stay unticked restores nothing.\n */\nexport async function mirrorPlatformResubscribe(\n input: PlatformMarketingMirrorInput & { via: 'email-resubscribe' | 'email-preferences' },\n): Promise<PlatformMarketingMirrorOutcome> {\n try {\n const target = await mirrorTarget(input)\n if ('status' in target) return target\n if (\n target.state.decision !== 'declined' ||\n !isPlatformMarketingEmailSourceKind(target.state.sourceKind)\n ) {\n return { status: 'unchanged' }\n }\n const db = input.firestore ?? firestore()\n const [suppression, topic] = await Promise.all([\n readPlatformMarketingSuppression(db, input.hostId, input.email),\n readPlatformMarketingTopic(db, input.hostId, input.email),\n ])\n if (suppression || topic.state !== 'subscribed') return { status: 'unchanged' }\n const atMs = input.now ?? Date.now()\n const source = platformMarketingEmailSource({\n kind: input.via,\n decision: 'granted',\n uid: target.uid,\n atMs,\n })\n await target.ref.set(\n platformMarketingUserFields({ decision: 'granted', atMs, source }),\n { merge: true },\n )\n return { status: 'recorded', uid: target.uid, decision: 'granted' }\n } catch (error) {\n console.error('[platform-marketing-consent] could not mirror a resubscribe onto the account', error)\n return { status: 'failed' }\n }\n}\n"],"names":["readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","isPlatformMarketingConsentDecision","isPlatformMarketingConsoleSourceKind","isPlatformMarketingEmailSourceKind","platformMarketingConsentSource","platformMarketingEmailSource","PLATFORM_MARKETING_TOPIC_ID","platformMarketingUserFields","readPlatformMarketingConsent","USER_MARKETING_PROMPT_DISMISSED_AT_FIELD","marketingConsentDecision","readMarketingBasis","resolveMarketingConsentPolicy","rejoinEmailStream","attributableAccountForAddress","findContactByEmail","EMAIL_SUPPRESSIONS_COLLECTION","emailSuppressionKey","HOST_SUPPRESSIONS_SUBCOLLECTION","firebaseAdmin","consentGroupForSite","getOrgForHost","orgDataCollectionForHost","upsertHostContact","firestore","app","PLATFORM_MARKETING_HOST_ID_ENV","platformMarketingHostId","process","value","String","env","PLATFORM_MARKETING_HOST_ID","trim","accountName","snapshot","fallback","first","get","last","stored","filter","Boolean","join","undefined","recordPlatformMarketingConsent","input","uid","Error","decision","source","textVersion","atMs","now","Date","kind","userRef","collection","doc","set","merge","hostId","contact","status","email","name","verdict","upsert","interaction","summary","reason","marketingConsent","declineMarketingConsent","marketingConsentSource","initialLifecycleStage","refused","contactId","created","stream","mailboxVerified","rejoin","topicId","snoozePlatformMarketingPrompt","options","trimmed","readPlatformMarketingConsentForUser","data","readPlatformMarketingSuppression","db","key","site","platform","Promise","all","exists","list","storedMs","Number","isFinite","toMillis","call","readPlatformMarketingTopic","unrecorded","id","state","leftAtMs","entry","optedOutAt","readPlatformMarketingReach","toLowerCase","record","owner","org","group","suppression","topic","then","contacts","orgId","orgSlug","basis","basisAtMs","assertedBy","basisKind","error","console","platformMarketingHold","reach","mirrorTarget","platformHost","platformHostId","findAccount","ref","mirrorPlatformUnsubscribe","everything","left","includes","target","mirrorPlatformResubscribe","sourceKind","via"],"mappings":"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"}
@@ -1068,7 +1068,17 @@ export default consumeRateLimit;
1068
1068
  * two hours, so expiry is a backstop, not the mechanism.
1069
1069
  */ export const SIGNUP_CANARY_DOC_ID = 'signupCanary_production';
1070
1070
  /** How long a recorded walk survives the sweep. */ const SIGNUP_CANARY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
1071
- /** Budget for the single-document read on the health path. */ const SIGNUP_CANARY_READ_BUDGET_MS = 2000;
1071
+ /**
1072
+ * Budget for a single marker-document read on the journeys health path.
1073
+ *
1074
+ * The journeys sweep runs every probe in parallel, and on a fresh instance
1075
+ * they all share one cold Firestore client whose first connect has measured
1076
+ * ~3 s. At 2 s these reads lost that race on the first sweep after a deploy,
1077
+ * and the memoized red paged for two minutes while every marker was fresh
1078
+ * (AGL-3421). Five seconds fits the cold connect, matches the journeys rules
1079
+ * fetch, and still reds a read that is actually hung.
1080
+ */ const HEALTH_MARKER_READ_BUDGET_MS = 5000;
1081
+ /** Budget for the single-document read on the health path. */ const SIGNUP_CANARY_READ_BUDGET_MS = HEALTH_MARKER_READ_BUDGET_MS;
1072
1082
  /**
1073
1083
  * Record the outcome of one signup walk.
1074
1084
  *
@@ -1140,7 +1150,7 @@ export default consumeRateLimit;
1140
1150
  * One document, like every sibling here. The sampler owns the judgement about
1141
1151
  * whether the total moved; this file only carries it.
1142
1152
  */ export const EDGE_ADMISSION_DOC_ID = 'edgeAdmission_production';
1143
- /** Budget for the single-document read on the health path. */ const EDGE_ADMISSION_READ_BUDGET_MS = 2000;
1153
+ /** Budget for the single-document read on the health path. */ const EDGE_ADMISSION_READ_BUDGET_MS = HEALTH_MARKER_READ_BUDGET_MS;
1144
1154
  /**
1145
1155
  * Read the last metered-traffic sample.
1146
1156
  *
@@ -1169,7 +1179,7 @@ export default consumeRateLimit;
1169
1179
  }
1170
1180
  export const APP_CHECK_ATTESTATION_DOC_ID = 'appCheckAttestation_production';
1171
1181
  /** How long a sample survives the sweep. */ const APP_CHECK_ATTESTATION_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
1172
- /** Budget for the single-document read on the health path. */ const APP_CHECK_ATTESTATION_READ_BUDGET_MS = 2000;
1182
+ /** Budget for the single-document read on the health path. */ const APP_CHECK_ATTESTATION_READ_BUDGET_MS = HEALTH_MARKER_READ_BUDGET_MS;
1173
1183
  /**
1174
1184
  * Record one sampling of the App Check verification counts.
1175
1185
  *