@aglyn/tenant-data-admin 1.0.0-beta.143

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 (374) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/jest.integration.config.d.ts +39 -0
  4. package/jest.integration.setup.d.ts +17 -0
  5. package/package.json +51 -0
  6. package/src/index.d.ts +17 -0
  7. package/src/index.js +18 -0
  8. package/src/index.js.map +1 -0
  9. package/src/lib/render-cache.d.ts +184 -0
  10. package/src/lib/render-cache.js +198 -0
  11. package/src/lib/render-cache.js.map +1 -0
  12. package/src/lib/server/account-addresses.d.ts +160 -0
  13. package/src/lib/server/account-addresses.js +311 -0
  14. package/src/lib/server/account-addresses.js.map +1 -0
  15. package/src/lib/server/account-emails.d.ts +219 -0
  16. package/src/lib/server/account-emails.js +640 -0
  17. package/src/lib/server/account-emails.js.map +1 -0
  18. package/src/lib/server/admin-audit.d.ts +180 -0
  19. package/src/lib/server/admin-audit.js +233 -0
  20. package/src/lib/server/admin-audit.js.map +1 -0
  21. package/src/lib/server/analytics-retention.d.ts +63 -0
  22. package/src/lib/server/analytics-retention.js +66 -0
  23. package/src/lib/server/analytics-retention.js.map +1 -0
  24. package/src/lib/server/api-http.d.ts +159 -0
  25. package/src/lib/server/api-http.js +230 -0
  26. package/src/lib/server/api-http.js.map +1 -0
  27. package/src/lib/server/api-keys.d.ts +117 -0
  28. package/src/lib/server/api-keys.js +298 -0
  29. package/src/lib/server/api-keys.js.map +1 -0
  30. package/src/lib/server/auth-handoff.d.ts +165 -0
  31. package/src/lib/server/auth-handoff.js +482 -0
  32. package/src/lib/server/auth-handoff.js.map +1 -0
  33. package/src/lib/server/auth-pools.d.ts +246 -0
  34. package/src/lib/server/auth-pools.js +551 -0
  35. package/src/lib/server/auth-pools.js.map +1 -0
  36. package/src/lib/server/billing-addons.d.ts +171 -0
  37. package/src/lib/server/billing-addons.js +277 -0
  38. package/src/lib/server/billing-addons.js.map +1 -0
  39. package/src/lib/server/campaign-attribution-store.d.ts +55 -0
  40. package/src/lib/server/campaign-attribution-store.js +95 -0
  41. package/src/lib/server/campaign-attribution-store.js.map +1 -0
  42. package/src/lib/server/campaign-conversion-attribution.d.ts +205 -0
  43. package/src/lib/server/campaign-conversion-attribution.js +279 -0
  44. package/src/lib/server/campaign-conversion-attribution.js.map +1 -0
  45. package/src/lib/server/client-error-report.d.ts +301 -0
  46. package/src/lib/server/client-error-report.js +755 -0
  47. package/src/lib/server/client-error-report.js.map +1 -0
  48. package/src/lib/server/connect-account-status.d.ts +74 -0
  49. package/src/lib/server/connect-account-status.js +94 -0
  50. package/src/lib/server/connect-account-status.js.map +1 -0
  51. package/src/lib/server/connect-payout-failure.d.ts +103 -0
  52. package/src/lib/server/connect-payout-failure.js +127 -0
  53. package/src/lib/server/connect-payout-failure.js.map +1 -0
  54. package/src/lib/server/console-api-rate-limit.d.ts +81 -0
  55. package/src/lib/server/console-api-rate-limit.js +111 -0
  56. package/src/lib/server/console-api-rate-limit.js.map +1 -0
  57. package/src/lib/server/console-domains.d.ts +326 -0
  58. package/src/lib/server/console-domains.js +779 -0
  59. package/src/lib/server/console-domains.js.map +1 -0
  60. package/src/lib/server/consume-once.d.ts +91 -0
  61. package/src/lib/server/consume-once.js +96 -0
  62. package/src/lib/server/consume-once.js.map +1 -0
  63. package/src/lib/server/contact-company-link.d.ts +85 -0
  64. package/src/lib/server/contact-company-link.js +105 -0
  65. package/src/lib/server/contact-company-link.js.map +1 -0
  66. package/src/lib/server/contact-email-engagement.d.ts +32 -0
  67. package/src/lib/server/contact-email-engagement.js +145 -0
  68. package/src/lib/server/contact-email-engagement.js.map +1 -0
  69. package/src/lib/server/contact-email-index.d.ts +56 -0
  70. package/src/lib/server/contact-email-index.js +158 -0
  71. package/src/lib/server/contact-email-index.js.map +1 -0
  72. package/src/lib/server/contact-lifecycle-floor.d.ts +89 -0
  73. package/src/lib/server/contact-lifecycle-floor.js +88 -0
  74. package/src/lib/server/contact-lifecycle-floor.js.map +1 -0
  75. package/src/lib/server/contact-merge.d.ts +55 -0
  76. package/src/lib/server/contact-merge.js +279 -0
  77. package/src/lib/server/contact-merge.js.map +1 -0
  78. package/src/lib/server/contact-suppression.d.ts +153 -0
  79. package/src/lib/server/contact-suppression.js +259 -0
  80. package/src/lib/server/contact-suppression.js.map +1 -0
  81. package/src/lib/server/crm-booking-activity.d.ts +86 -0
  82. package/src/lib/server/crm-booking-activity.js +251 -0
  83. package/src/lib/server/crm-booking-activity.js.map +1 -0
  84. package/src/lib/server/crm-email-activity.d.ts +112 -0
  85. package/src/lib/server/crm-email-activity.js +143 -0
  86. package/src/lib/server/crm-email-activity.js.map +1 -0
  87. package/src/lib/server/crm-inbound-email.d.ts +145 -0
  88. package/src/lib/server/crm-inbound-email.js +287 -0
  89. package/src/lib/server/crm-inbound-email.js.map +1 -0
  90. package/src/lib/server/crm-next-activity.d.ts +74 -0
  91. package/src/lib/server/crm-next-activity.js +194 -0
  92. package/src/lib/server/crm-next-activity.js.map +1 -0
  93. package/src/lib/server/crm-records.d.ts +144 -0
  94. package/src/lib/server/crm-records.js +218 -0
  95. package/src/lib/server/crm-records.js.map +1 -0
  96. package/src/lib/server/csp-aggregate.d.ts +62 -0
  97. package/src/lib/server/csp-aggregate.js +276 -0
  98. package/src/lib/server/csp-aggregate.js.map +1 -0
  99. package/src/lib/server/data-storage-gate.d.ts +66 -0
  100. package/src/lib/server/data-storage-gate.js +48 -0
  101. package/src/lib/server/data-storage-gate.js.map +1 -0
  102. package/src/lib/server/dataset-live-pages.d.ts +179 -0
  103. package/src/lib/server/dataset-live-pages.js +421 -0
  104. package/src/lib/server/dataset-live-pages.js.map +1 -0
  105. package/src/lib/server/dns-probe.d.ts +53 -0
  106. package/src/lib/server/dns-probe.js +184 -0
  107. package/src/lib/server/dns-probe.js.map +1 -0
  108. package/src/lib/server/document-id.d.ts +29 -0
  109. package/src/lib/server/document-id.js +100 -0
  110. package/src/lib/server/document-id.js.map +1 -0
  111. package/src/lib/server/domain-provider-vercel.d.ts +33 -0
  112. package/src/lib/server/domain-provider-vercel.js +298 -0
  113. package/src/lib/server/domain-provider-vercel.js.map +1 -0
  114. package/src/lib/server/domain-provider-webhook.d.ts +73 -0
  115. package/src/lib/server/domain-provider-webhook.js +223 -0
  116. package/src/lib/server/domain-provider-webhook.js.map +1 -0
  117. package/src/lib/server/domain-provider-wildcard.d.ts +54 -0
  118. package/src/lib/server/domain-provider-wildcard.js +163 -0
  119. package/src/lib/server/domain-provider-wildcard.js.map +1 -0
  120. package/src/lib/server/domain-provider.d.ts +197 -0
  121. package/src/lib/server/domain-provider.js +221 -0
  122. package/src/lib/server/domain-provider.js.map +1 -0
  123. package/src/lib/server/duplicate-activity.d.ts +54 -0
  124. package/src/lib/server/duplicate-activity.js +67 -0
  125. package/src/lib/server/duplicate-activity.js.map +1 -0
  126. package/src/lib/server/duplicate-resource.d.ts +55 -0
  127. package/src/lib/server/duplicate-resource.js +474 -0
  128. package/src/lib/server/duplicate-resource.js.map +1 -0
  129. package/src/lib/server/dynamic-list-materialize.d.ts +153 -0
  130. package/src/lib/server/dynamic-list-materialize.js +581 -0
  131. package/src/lib/server/dynamic-list-materialize.js.map +1 -0
  132. package/src/lib/server/edit-access-authz.d.ts +56 -0
  133. package/src/lib/server/edit-access-authz.js +107 -0
  134. package/src/lib/server/edit-access-authz.js.map +1 -0
  135. package/src/lib/server/edit-access-token.d.ts +72 -0
  136. package/src/lib/server/edit-access-token.js +116 -0
  137. package/src/lib/server/edit-access-token.js.map +1 -0
  138. package/src/lib/server/edit-hint-token.d.ts +83 -0
  139. package/src/lib/server/edit-hint-token.js +119 -0
  140. package/src/lib/server/edit-hint-token.js.map +1 -0
  141. package/src/lib/server/email-delivery-log.d.ts +509 -0
  142. package/src/lib/server/email-delivery-log.js +1043 -0
  143. package/src/lib/server/email-delivery-log.js.map +1 -0
  144. package/src/lib/server/email-flow-gate.d.ts +80 -0
  145. package/src/lib/server/email-flow-gate.js +153 -0
  146. package/src/lib/server/email-flow-gate.js.map +1 -0
  147. package/src/lib/server/email-marketing-gate.d.ts +333 -0
  148. package/src/lib/server/email-marketing-gate.js +639 -0
  149. package/src/lib/server/email-marketing-gate.js.map +1 -0
  150. package/src/lib/server/email-metering.d.ts +293 -0
  151. package/src/lib/server/email-metering.js +359 -0
  152. package/src/lib/server/email-metering.js.map +1 -0
  153. package/src/lib/server/email-revenue-attribution.d.ts +185 -0
  154. package/src/lib/server/email-revenue-attribution.js +300 -0
  155. package/src/lib/server/email-revenue-attribution.js.map +1 -0
  156. package/src/lib/server/email-send-rate.d.ts +232 -0
  157. package/src/lib/server/email-send-rate.js +417 -0
  158. package/src/lib/server/email-send-rate.js.map +1 -0
  159. package/src/lib/server/email-sender-reputation.d.ts +317 -0
  160. package/src/lib/server/email-sender-reputation.js +464 -0
  161. package/src/lib/server/email-sender-reputation.js.map +1 -0
  162. package/src/lib/server/email-suppression.d.ts +490 -0
  163. package/src/lib/server/email-suppression.js +648 -0
  164. package/src/lib/server/email-suppression.js.map +1 -0
  165. package/src/lib/server/email-topic-confirmation.d.ts +150 -0
  166. package/src/lib/server/email-topic-confirmation.js +272 -0
  167. package/src/lib/server/email-topic-confirmation.js.map +1 -0
  168. package/src/lib/server/email-unsubscribe-link.d.ts +174 -0
  169. package/src/lib/server/email-unsubscribe-link.js +236 -0
  170. package/src/lib/server/email-unsubscribe-link.js.map +1 -0
  171. package/src/lib/server/erase-person.d.ts +116 -0
  172. package/src/lib/server/erase-person.js +272 -0
  173. package/src/lib/server/erase-person.js.map +1 -0
  174. package/src/lib/server/erase.d.ts +458 -0
  175. package/src/lib/server/erase.js +1506 -0
  176. package/src/lib/server/erase.js.map +1 -0
  177. package/src/lib/server/firebase-admin.d.ts +71 -0
  178. package/src/lib/server/firebase-admin.js +343 -0
  179. package/src/lib/server/firebase-admin.js.map +1 -0
  180. package/src/lib/server/form-dataset-binding-token.d.ts +35 -0
  181. package/src/lib/server/form-dataset-binding-token.js +107 -0
  182. package/src/lib/server/form-dataset-binding-token.js.map +1 -0
  183. package/src/lib/server/free-workspace-cap.d.ts +201 -0
  184. package/src/lib/server/free-workspace-cap.js +404 -0
  185. package/src/lib/server/free-workspace-cap.js.map +1 -0
  186. package/src/lib/server/ga4-measurement-protocol.d.ts +256 -0
  187. package/src/lib/server/ga4-measurement-protocol.js +558 -0
  188. package/src/lib/server/ga4-measurement-protocol.js.map +1 -0
  189. package/src/lib/server/host-email-tokens.d.ts +47 -0
  190. package/src/lib/server/host-email-tokens.js +72 -0
  191. package/src/lib/server/host-email-tokens.js.map +1 -0
  192. package/src/lib/server/host-memberships.d.ts +54 -0
  193. package/src/lib/server/host-memberships.js +194 -0
  194. package/src/lib/server/host-memberships.js.map +1 -0
  195. package/src/lib/server/host-sending-domain.d.ts +292 -0
  196. package/src/lib/server/host-sending-domain.js +451 -0
  197. package/src/lib/server/host-sending-domain.js.map +1 -0
  198. package/src/lib/server/host-visitor-records.d.ts +150 -0
  199. package/src/lib/server/host-visitor-records.js +262 -0
  200. package/src/lib/server/host-visitor-records.js.map +1 -0
  201. package/src/lib/server/id-token-refusal.d.ts +28 -0
  202. package/src/lib/server/id-token-refusal.js +111 -0
  203. package/src/lib/server/id-token-refusal.js.map +1 -0
  204. package/src/lib/server/legal-acceptance.d.ts +214 -0
  205. package/src/lib/server/legal-acceptance.js +305 -0
  206. package/src/lib/server/legal-acceptance.js.map +1 -0
  207. package/src/lib/server/list-members.d.ts +211 -0
  208. package/src/lib/server/list-members.js +176 -0
  209. package/src/lib/server/list-members.js.map +1 -0
  210. package/src/lib/server/live-page-usage.d.ts +202 -0
  211. package/src/lib/server/live-page-usage.js +300 -0
  212. package/src/lib/server/live-page-usage.js.map +1 -0
  213. package/src/lib/server/lockdown.d.ts +313 -0
  214. package/src/lib/server/lockdown.js +643 -0
  215. package/src/lib/server/lockdown.js.map +1 -0
  216. package/src/lib/server/media-cdn-rate-limit.d.ts +188 -0
  217. package/src/lib/server/media-cdn-rate-limit.js +204 -0
  218. package/src/lib/server/media-cdn-rate-limit.js.map +1 -0
  219. package/src/lib/server/media-delivery.d.ts +267 -0
  220. package/src/lib/server/media-delivery.js +485 -0
  221. package/src/lib/server/media-delivery.js.map +1 -0
  222. package/src/lib/server/media-download-tokens.d.ts +161 -0
  223. package/src/lib/server/media-download-tokens.js +386 -0
  224. package/src/lib/server/media-download-tokens.js.map +1 -0
  225. package/src/lib/server/media-quarantine.d.ts +127 -0
  226. package/src/lib/server/media-quarantine.js +170 -0
  227. package/src/lib/server/media-quarantine.js.map +1 -0
  228. package/src/lib/server/media-signing.d.ts +126 -0
  229. package/src/lib/server/media-signing.js +159 -0
  230. package/src/lib/server/media-signing.js.map +1 -0
  231. package/src/lib/server/media-storage-path.d.ts +96 -0
  232. package/src/lib/server/media-storage-path.js +111 -0
  233. package/src/lib/server/media-storage-path.js.map +1 -0
  234. package/src/lib/server/media-strong-digest.d.ts +85 -0
  235. package/src/lib/server/media-strong-digest.js +190 -0
  236. package/src/lib/server/media-strong-digest.js.map +1 -0
  237. package/src/lib/server/media-tombstone.d.ts +276 -0
  238. package/src/lib/server/media-tombstone.js +421 -0
  239. package/src/lib/server/media-tombstone.js.map +1 -0
  240. package/src/lib/server/media-variants.d.ts +309 -0
  241. package/src/lib/server/media-variants.js +428 -0
  242. package/src/lib/server/media-variants.js.map +1 -0
  243. package/src/lib/server/member-email-aliases.d.ts +180 -0
  244. package/src/lib/server/member-email-aliases.js +420 -0
  245. package/src/lib/server/member-email-aliases.js.map +1 -0
  246. package/src/lib/server/member-photo.d.ts +88 -0
  247. package/src/lib/server/member-photo.js +185 -0
  248. package/src/lib/server/member-photo.js.map +1 -0
  249. package/src/lib/server/membership-recover-throttle.d.ts +211 -0
  250. package/src/lib/server/membership-recover-throttle.js +231 -0
  251. package/src/lib/server/membership-recover-throttle.js.map +1 -0
  252. package/src/lib/server/notifications.d.ts +55 -0
  253. package/src/lib/server/notifications.js +134 -0
  254. package/src/lib/server/notifications.js.map +1 -0
  255. package/src/lib/server/org-billing.d.ts +97 -0
  256. package/src/lib/server/org-billing.js +236 -0
  257. package/src/lib/server/org-billing.js.map +1 -0
  258. package/src/lib/server/organizations.d.ts +837 -0
  259. package/src/lib/server/organizations.js +1814 -0
  260. package/src/lib/server/organizations.js.map +1 -0
  261. package/src/lib/server/password-reset-throttle.d.ts +84 -0
  262. package/src/lib/server/password-reset-throttle.js +102 -0
  263. package/src/lib/server/password-reset-throttle.js.map +1 -0
  264. package/src/lib/server/personal-data-export.d.ts +241 -0
  265. package/src/lib/server/personal-data-export.js +858 -0
  266. package/src/lib/server/personal-data-export.js.map +1 -0
  267. package/src/lib/server/platform-domain-names.d.ts +62 -0
  268. package/src/lib/server/platform-domain-names.js +197 -0
  269. package/src/lib/server/platform-domain-names.js.map +1 -0
  270. package/src/lib/server/platform-marketing-consent.d.ts +133 -0
  271. package/src/lib/server/platform-marketing-consent.js +209 -0
  272. package/src/lib/server/platform-marketing-consent.js.map +1 -0
  273. package/src/lib/server/rate-limit-store.d.ts +702 -0
  274. package/src/lib/server/rate-limit-store.js +1220 -0
  275. package/src/lib/server/rate-limit-store.js.map +1 -0
  276. package/src/lib/server/realm-plugins.d.ts +67 -0
  277. package/src/lib/server/realm-plugins.js +250 -0
  278. package/src/lib/server/realm-plugins.js.map +1 -0
  279. package/src/lib/server/recaptcha-allowlist.d.ts +90 -0
  280. package/src/lib/server/recaptcha-allowlist.js +409 -0
  281. package/src/lib/server/recaptcha-allowlist.js.map +1 -0
  282. package/src/lib/server/release-flags.d.ts +105 -0
  283. package/src/lib/server/release-flags.js +183 -0
  284. package/src/lib/server/release-flags.js.map +1 -0
  285. package/src/lib/server/replay-erasures.d.ts +126 -0
  286. package/src/lib/server/replay-erasures.js +217 -0
  287. package/src/lib/server/replay-erasures.js.map +1 -0
  288. package/src/lib/server/resolve-people.d.ts +66 -0
  289. package/src/lib/server/resolve-people.js +112 -0
  290. package/src/lib/server/resolve-people.js.map +1 -0
  291. package/src/lib/server/safe-equal.d.ts +45 -0
  292. package/src/lib/server/safe-equal.js +58 -0
  293. package/src/lib/server/safe-equal.js.map +1 -0
  294. package/src/lib/server/sending-domain-debt.d.ts +127 -0
  295. package/src/lib/server/sending-domain-debt.js +268 -0
  296. package/src/lib/server/sending-domain-debt.js.map +1 -0
  297. package/src/lib/server/sending-domain-recheck.d.ts +151 -0
  298. package/src/lib/server/sending-domain-recheck.js +253 -0
  299. package/src/lib/server/sending-domain-recheck.js.map +1 -0
  300. package/src/lib/server/sending-domains.d.ts +364 -0
  301. package/src/lib/server/sending-domains.js +683 -0
  302. package/src/lib/server/sending-domains.js.map +1 -0
  303. package/src/lib/server/serve-media-cdn.d.ts +433 -0
  304. package/src/lib/server/serve-media-cdn.js +1370 -0
  305. package/src/lib/server/serve-media-cdn.js.map +1 -0
  306. package/src/lib/server/serve-plugin-fetch.d.ts +61 -0
  307. package/src/lib/server/serve-plugin-fetch.js +294 -0
  308. package/src/lib/server/serve-plugin-fetch.js.map +1 -0
  309. package/src/lib/server/sms-keywords.d.ts +63 -0
  310. package/src/lib/server/sms-keywords.js +146 -0
  311. package/src/lib/server/sms-keywords.js.map +1 -0
  312. package/src/lib/server/sso-break-glass-owners.d.ts +91 -0
  313. package/src/lib/server/sso-break-glass-owners.js +227 -0
  314. package/src/lib/server/sso-break-glass-owners.js.map +1 -0
  315. package/src/lib/server/sso-domain-policy.d.ts +140 -0
  316. package/src/lib/server/sso-domain-policy.js +239 -0
  317. package/src/lib/server/sso-domain-policy.js.map +1 -0
  318. package/src/lib/server/sso-drift-logic.d.ts +106 -0
  319. package/src/lib/server/sso-drift-logic.js +95 -0
  320. package/src/lib/server/sso-drift-logic.js.map +1 -0
  321. package/src/lib/server/sso-enforcement.d.ts +204 -0
  322. package/src/lib/server/sso-enforcement.js +309 -0
  323. package/src/lib/server/sso-enforcement.js.map +1 -0
  324. package/src/lib/server/sso-provisioning.d.ts +320 -0
  325. package/src/lib/server/sso-provisioning.js +715 -0
  326. package/src/lib/server/sso-provisioning.js.map +1 -0
  327. package/src/lib/server/staff-alert-email.d.ts +41 -0
  328. package/src/lib/server/staff-alert-email.js +63 -0
  329. package/src/lib/server/staff-alert-email.js.map +1 -0
  330. package/src/lib/server/stripe-account-mode.d.ts +156 -0
  331. package/src/lib/server/stripe-account-mode.js +162 -0
  332. package/src/lib/server/stripe-account-mode.js.map +1 -0
  333. package/src/lib/server/tenant-write-lockdown.d.ts +131 -0
  334. package/src/lib/server/tenant-write-lockdown.js +176 -0
  335. package/src/lib/server/tenant-write-lockdown.js.map +1 -0
  336. package/src/lib/server/test-firestore.d.ts +45 -0
  337. package/src/lib/server/test-firestore.js +124 -0
  338. package/src/lib/server/test-firestore.js.map +1 -0
  339. package/src/lib/server/token-revocation.d.ts +140 -0
  340. package/src/lib/server/token-revocation.js +255 -0
  341. package/src/lib/server/token-revocation.js.map +1 -0
  342. package/src/lib/server/update-existing.d.ts +32 -0
  343. package/src/lib/server/update-existing.js +73 -0
  344. package/src/lib/server/update-existing.js.map +1 -0
  345. package/src/lib/server/upload-cors-reconcile.d.ts +118 -0
  346. package/src/lib/server/upload-cors-reconcile.js +234 -0
  347. package/src/lib/server/upload-cors-reconcile.js.map +1 -0
  348. package/src/lib/server/upsert-contact.d.ts +262 -0
  349. package/src/lib/server/upsert-contact.js +546 -0
  350. package/src/lib/server/upsert-contact.js.map +1 -0
  351. package/src/lib/server/usage-invoice.d.ts +203 -0
  352. package/src/lib/server/usage-invoice.js +397 -0
  353. package/src/lib/server/usage-invoice.js.map +1 -0
  354. package/src/lib/server/user-profiles.d.ts +151 -0
  355. package/src/lib/server/user-profiles.js +364 -0
  356. package/src/lib/server/user-profiles.js.map +1 -0
  357. package/src/lib/server/vercel-drain-signature.d.ts +30 -0
  358. package/src/lib/server/vercel-drain-signature.js +61 -0
  359. package/src/lib/server/vercel-drain-signature.js.map +1 -0
  360. package/src/lib/server/vercel-log-drain.d.ts +259 -0
  361. package/src/lib/server/vercel-log-drain.js +479 -0
  362. package/src/lib/server/vercel-log-drain.js.map +1 -0
  363. package/src/lib/server/verify-email-cooldown.d.ts +102 -0
  364. package/src/lib/server/verify-email-cooldown.js +114 -0
  365. package/src/lib/server/verify-email-cooldown.js.map +1 -0
  366. package/src/lib/server/visitor-write-rate-limit.d.ts +87 -0
  367. package/src/lib/server/visitor-write-rate-limit.js +117 -0
  368. package/src/lib/server/visitor-write-rate-limit.js.map +1 -0
  369. package/src/lib/server/workspace-domains.d.ts +156 -0
  370. package/src/lib/server/workspace-domains.js +271 -0
  371. package/src/lib/server/workspace-domains.js.map +1 -0
  372. package/src/tenancy.d.ts +115 -0
  373. package/src/tenancy.js +142 -0
  374. package/src/tenancy.js.map +1 -0
@@ -0,0 +1,1506 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { FieldValue } from "firebase-admin/firestore";
18
+ import { firebaseAdmin } from "./firebase-admin.js";
19
+ import { eraseMediaDeliveryScope } from "./media-delivery.js";
20
+ import { deleteHostProjectionForAllMembers } from "./host-memberships.js";
21
+ import { detachWorkspaceDomain } from "./workspace-domains.js";
22
+ import { CONSOLE_DOMAINS_COLLECTION, releaseConsoleDomain } from "./console-domains.js";
23
+ import { authForPool, findUserByUidAcrossPools } from "./auth-pools.js";
24
+ import { eraseEmailDeliveriesForAddresses } from "./email-delivery-log.js";
25
+ import { resolveAccountAddresses } from "./account-addresses.js";
26
+ import { EMAIL_IDENTITY_INDEX_COLLECTION } from "./account-emails.js";
27
+ import { removeOrgMember } from "./organizations.js";
28
+ import { runPluginUserErasers } from "@aglyn/aglyn/plugin-manager/plugin-user-erasure";
29
+ import { runPluginOrgErasers } from "@aglyn/aglyn/plugin-manager/plugin-org-erasure";
30
+ import { isBillingSubscription } from "@aglyn/aglyn/server";
31
+ import { readOrgBilling } from "./org-billing.js";
32
+ import { disposeHostSendingDomain, readSendingDomainTeardownByLabel } from "./sending-domain-debt.js";
33
+ /** The reversible hold before a requested erasure is executed (AGL-485). */ export const ERASURE_HOLD_MS = 7 * 24 * 60 * 60 * 1000;
34
+ /**
35
+ * The admin app is initialized without a default storageBucket, so every
36
+ * bucket access must name it explicitly (same as the media routes). Falls
37
+ * through to the admin default if the env is somehow unset.
38
+ */ function storageBucket() {
39
+ const name = process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET;
40
+ return firebaseAdmin.app().storage().bucket(name || undefined);
41
+ }
42
+ /**
43
+ * `supplierDeliveries` — the dropship outbox, keyed by `hostId` as a FIELD.
44
+ *
45
+ * The literal rather than an import: the collection belongs to
46
+ * `libs/plugins/commerce/src/lib/server/supplier-outbox.ts`
47
+ * (`SUPPLIER_DELIVERY_COLLECTION`), which is tagged `aglyn:addons`, and this
48
+ * library is `scope:data` — the nx boundary forbids the edge, and reaching
49
+ * for a dynamic import to dodge it would poison the lint graph instead. The
50
+ * emulator spec asserts the two spellings still agree by reading that file,
51
+ * so a rename fails a test rather than silently un-wiring this sweep.
52
+ */ const SUPPLIER_DELIVERY_COLLECTION = 'supplierDeliveries';
53
+ /**
54
+ * `outreachMailboxCredentials` — the OAuth grant behind each mailbox a rep
55
+ * connects to Outreach, keyed by the mailbox id and carrying `orgId` as a
56
+ * FIELD.
57
+ *
58
+ * The literal rather than an import, for `SUPPLIER_DELIVERY_COLLECTION`'s
59
+ * reason: the collection belongs to `libs/plugins/outreach`
60
+ * (`OUTREACH_COLLECTIONS.mailboxCredentials` in
61
+ * `src/lib/model/outreach.types.ts`), and this `scope:data` library may not
62
+ * import a plugin. The plugin's own spec asserts the two spellings agree, so
63
+ * a rename fails a test rather than silently un-wiring the sweep.
64
+ *
65
+ * A static sweep here, not a hook the plugin registers, because an erasure
66
+ * cannot rely on the plugin being present. An org that switched Outreach off
67
+ * still holds the tokens its reps granted, and the process running the
68
+ * erasure — the cron, the operator script, a replay — may never have loaded
69
+ * the plugin's bundle at all. A credential's lifetime cannot depend on which
70
+ * bundles an erasing process happened to load.
71
+ */ const OUTREACH_MAILBOX_CREDENTIALS_COLLECTION = 'outreachMailboxCredentials';
72
+ /**
73
+ * Destroy the site's DEAD-LETTERED supplier deliveries (AGL-1448).
74
+ *
75
+ * `supplierDeliveries/{id}` is TOP-LEVEL and carries `hostId` as a field, so
76
+ * neither `recursiveDelete(hosts/{hostId})` here nor
77
+ * `recursiveDelete(orgs/{orgId})` in `eraseOrg` can see it. That placement is
78
+ * deliberate and correct — a subcollection would be editor-writable under the
79
+ * permissive host catch-all, and the row carries the exact JSON body we sign
80
+ * and POST to the supplier — so the fix is a sweep, not a move.
81
+ *
82
+ * **The gap is the dead letter, and the module that created it named it.**
83
+ * `supplier-outbox.ts` DELETES a row the moment delivery succeeds, and its
84
+ * docblock says why in terms: the body holds the buyer's email and name, and
85
+ * a top-level document is outside the recursive delete an erasure runs. The
86
+ * happy path was solved by that deletion. The failure path was not: after
87
+ * `SUPPLIER_DELIVERY_MAX_ATTEMPTS` the row is left standing as `failed`,
88
+ * because a human is supposed to act on it and the merchant has been told.
89
+ * Nothing ever collects it afterwards — no TTL policy keys on it, no cron
90
+ * reaps it, and the erasure could not reach it. So the one class of row that
91
+ * survives is the one holding a THIRD PARTY's name, email and shipping
92
+ * address, against a site whose owner has since asked to be erased and whom
93
+ * nobody can ask to act on the dead letter any more.
94
+ *
95
+ * Bounded by the `hostId` field, never a collection sweep: this collection
96
+ * holds every other merchant's orders. Chunked, because a batch caps at 500
97
+ * and nothing bounds a dead-letter backlog below that.
98
+ *
99
+ * In `eraseHost` rather than `eraseOrg` on purpose — it is host-scoped data,
100
+ * so the self-serve "delete this site" path gets the same cleanup as a full
101
+ * org erasure instead of leaving the residue behind for the smaller action.
102
+ *
103
+ * Best-effort, like every other trailing cleanup in `eraseHost`: a site
104
+ * delete must not fail because an outbox query did.
105
+ */ async function eraseHostSupplierDeliveries(hostId) {
106
+ if (!hostId) return 0;
107
+ try {
108
+ const firestore = firebaseAdmin.app().firestore();
109
+ const rows = await firestore.collection(SUPPLIER_DELIVERY_COLLECTION).where('hostId', '==', hostId).get();
110
+ for(let index = 0; index < rows.docs.length; index += 400){
111
+ const batch = firestore.batch();
112
+ for (const doc of rows.docs.slice(index, index + 400)){
113
+ batch.delete(doc.ref);
114
+ }
115
+ await batch.commit();
116
+ }
117
+ return rows.size;
118
+ } catch (error) {
119
+ console.error(`eraseHost: supplier outbox cleanup failed for ${hostId}`, error);
120
+ return 0;
121
+ }
122
+ }
123
+ export async function eraseHost(hostId, options = {}) {
124
+ var _hostSnapshot_get;
125
+ const firestore = firebaseAdmin.app().firestore();
126
+ const hostRef = firestore.collection('hosts').doc(hostId);
127
+ const hostSnapshot = await hostRef.get();
128
+ const orgId = hostSnapshot.get('orgId');
129
+ /*==========================================
130
+ * THE SENDING DOMAIN, READ FIRST AND RELEASED LAST.
131
+ *
132
+ * Read here because the pinned label lives on the host document and the
133
+ * `recursiveDelete` at the bottom of this function destroys it. Read
134
+ * afterwards there would be nothing left to say WHICH provider domain holds
135
+ * a plan-capped account slot and WHICH records in our zone carry a signing
136
+ * key for a name a future site can claim.
137
+ *
138
+ * Released after everything else, so a teardown that dies partway leaves the
139
+ * claim standing and the domain findable rather than orphaning two vendors'
140
+ * resources with nothing pointing at them.
141
+ *=========================================*/ const sendingLabel = String((_hostSnapshot_get = hostSnapshot.get('sendingLabel')) != null ? _hostSnapshot_get : '').trim();
142
+ const sendingTeardown = sendingLabel ? await readSendingDomainTeardownByLabel(sendingLabel).catch(()=>null) : null;
143
+ // Storage first (best-effort — the object tree is derived, regenerable).
144
+ try {
145
+ await storageBucket().deleteFiles({
146
+ prefix: `hosts/${hostId}/`
147
+ });
148
+ } catch (error) {
149
+ console.error(`eraseHost: storage cleanup failed for ${hostId}`, error);
150
+ }
151
+ // And the delivery provider's copies of the site's video (AGL-2824), held
152
+ // under the same library prefix. Nothing runs when no provider is
153
+ // configured to store; a failure is logged, like the bucket's.
154
+ await eraseMediaDeliveryScope({
155
+ collection: 'hosts',
156
+ scopeId: hostId
157
+ });
158
+ // Dead-lettered supplier deliveries (AGL-1448). See the note on
159
+ // SUPPLIER_DELIVERY_COLLECTION below for why this is here and not implied
160
+ // by the recursiveDelete at the end of this function.
161
+ await eraseHostSupplierDeliveries(hostId);
162
+ // Routing: the middleware resolves a request to a host via hostIndex and
163
+ // the owning org's hosts map — drop both so the subdomain/cname 404s.
164
+ await firestore.collection('hostIndex').doc(hostId).delete().catch(()=>undefined);
165
+ if (orgId) {
166
+ // The routing entry and the site's POOLED SEATS go in the SAME write —
167
+ // POS registers (AGL-1775) and collaborators (AGL-2439), which are the
168
+ // same mechanism on two keys and must be released together or a deleted
169
+ // site strands one pool while returning the other.
170
+ //
171
+ // `registerAllocations[hostId]` is capacity the org has
172
+ // paid for and assigned here; a deleted site must return it to the pool
173
+ // or the org keeps paying $89/mo for a seat pinned to a site that no
174
+ // longer exists and cannot be reassigned from any surface. Releasing it
175
+ // by deleting the key rather than by decrementing a counter means the
176
+ // pool is `purchased - sum(allocations)` by arithmetic and has nothing to
177
+ // drift out of step with.
178
+ await firestore.collection('orgs').doc(orgId).set({
179
+ hosts: {
180
+ [hostId]: FieldValue.delete()
181
+ },
182
+ registerAllocations: {
183
+ [hostId]: FieldValue.delete()
184
+ },
185
+ collaboratorAllocations: {
186
+ [hostId]: FieldValue.delete()
187
+ }
188
+ }, {
189
+ merge: true
190
+ }).catch(()=>undefined);
191
+ // Drop every member's reverse-index row for this host (AGL-844); the
192
+ // members still exist here (recursiveDelete of the org, if any, is later).
193
+ await deleteHostProjectionForAllMembers(orgId, hostId).catch(()=>undefined);
194
+ }
195
+ // The host document tree (screens/layouts/versions/counters/products/…).
196
+ await firestore.recursiveDelete(hostRef);
197
+ // Never throws, and never leaves the slot unaccounted for: whatever the
198
+ // vendors refuse is recorded on the label claim for the reaper to finish.
199
+ return {
200
+ sendingDomain: await disposeHostSendingDomain({
201
+ teardown: sendingTeardown ? _extends({}, sendingTeardown, {
202
+ hostId
203
+ }) : null,
204
+ tearDown: options == null ? void 0 : options.tearDownSendingDomain
205
+ })
206
+ };
207
+ }
208
+ /**
209
+ * Delete a single resource document *and everything under it* (AGL-945).
210
+ *
211
+ * Firestore does not cascade: deleting `orgs/{o}/datasets/{d}` leaves every
212
+ * `records/{r}` beneath it alive but unreachable from the console — still
213
+ * billed, still matched by the rules (which match subcollection paths
214
+ * independently of the parent), and resurrected wholesale if the parent id
215
+ * is ever reused. Only the Admin SDK has `recursiveDelete`, so the console
216
+ * cards route their deletes through /api/resources/erase, which lands here.
217
+ *
218
+ * Authorization belongs to the caller — this only walks the tree.
219
+ */ export async function eraseSubtree(path) {
220
+ const firestore = firebaseAdmin.app().firestore();
221
+ const [scope, scopeId, kind, id] = path;
222
+ await firestore.recursiveDelete(firestore.collection(scope).doc(scopeId).collection(kind).doc(id));
223
+ }
224
+ /** Best-effort Stripe customer deletion — PII lives at the processor too. */ async function deleteStripeCustomer(customerId) {
225
+ const key = process.env.STRIPE_SECRET_KEY;
226
+ if (!key || !customerId) return;
227
+ try {
228
+ await fetch(`https://api.stripe.com/v1/customers/${customerId}`, {
229
+ method: 'DELETE',
230
+ headers: {
231
+ Authorization: `Bearer ${key}`
232
+ }
233
+ });
234
+ } catch (error) {
235
+ console.error(`eraseOrg: Stripe customer ${customerId} delete failed`, error);
236
+ }
237
+ }
238
+ /**
239
+ * Delete every API credential the org owns (AGL-1444).
240
+ *
241
+ * `apiKeys` is a TOP-LEVEL collection keyed by the SHA-256 of the token and
242
+ * carrying `orgId` as a FIELD, so `recursiveDelete(orgRef)` cannot see it —
243
+ * a path-scoped cascade is structurally blind to anything not under the path.
244
+ * The credential therefore outlived the workspace: `verifyApiKey` kept
245
+ * resolving the token to a live principal naming an org that no longer
246
+ * existed. `authenticateApiV1` happens to refuse it (it reads the org doc and
247
+ * 401s), but that is a gate one layer above the credential, and a key whose
248
+ * revocation depends on every future caller repeating a lookup is not
249
+ * revoked. The document is also a small record of the erased workspace in its
250
+ * own right — a human-authored label, the creating uid, the granted scopes.
251
+ *
252
+ * Bounded by the `orgId` field, never a collection sweep: this collection
253
+ * holds every other customer's integration credentials too.
254
+ *
255
+ * Returns the number destroyed, for the audit row — an erasure trail that
256
+ * understates what it removed is the one record that has to be right.
257
+ */ async function eraseOrgApiKeys(orgId, dryRun = false) {
258
+ return deleteDocsByOrgId('apiKeys', orgId, dryRun);
259
+ }
260
+ /**
261
+ * Delete every Outreach mailbox credential the org holds (AGL-2974).
262
+ *
263
+ * `outreachMailboxCredentials` is a TOP-LEVEL collection keyed by the mailbox
264
+ * id and carrying `orgId` as a FIELD, so `recursiveDelete(orgRef)` cannot see
265
+ * it — the blindness `eraseOrgApiKeys` answers, on a more dangerous document.
266
+ * Left standing, the OAuth grant a rep gave for their own mailbox would
267
+ * outlive the workspace it was given to: tokens that can still send as that
268
+ * person, held for an organization that no longer exists and that nobody can
269
+ * ask to disconnect them.
270
+ *
271
+ * This destroys the platform's copy of the grant; it does not call the
272
+ * provider's revocation endpoint, which needs the plugin's provider adapter.
273
+ * With the tokens gone the grant has nothing left to act through.
274
+ *
275
+ * Bounded by the `orgId` field, never a collection sweep: this collection
276
+ * holds every other workspace's connected mailboxes too.
277
+ *
278
+ * Returns the number destroyed, for the audit row — an erasure trail that
279
+ * understates what it removed is the one record that has to be right.
280
+ */ async function eraseOrgOutreachMailboxCredentials(orgId, dryRun = false) {
281
+ return deleteDocsByOrgId(OUTREACH_MAILBOX_CREDENTIALS_COLLECTION, orgId, dryRun);
282
+ }
283
+ /**
284
+ * Delete every document in a TOP-LEVEL collection that names this org in its
285
+ * `orgId` FIELD (AGL-1444/AGL-1448).
286
+ *
287
+ * The shared mechanism behind every collection a path-scoped cascade cannot
288
+ * see. Two properties are the whole point, and neither is optional:
289
+ *
290
+ * - **Bounded by the field, never a collection sweep.** Every one of these
291
+ * collections holds other customers' credentials, routing and billing
292
+ * correlations. A `collection(name).get()` here would erase the estate.
293
+ * - **Chunked**, because a batch caps at 500 writes and nothing bounds an
294
+ * org's row count below that.
295
+ *
296
+ * Returns the number destroyed, for the audit row — an erasure trail that
297
+ * understates what it removed is the one record that has to be right.
298
+ *
299
+ * `dryRun` runs the query and skips the commit (AGL-1481), so a plan counts
300
+ * rows through the SAME function that deletes them. A separate counting pass
301
+ * would be a second enumeration of the sweep list, which is precisely the
302
+ * divergence AGL-1481 exists to remove.
303
+ *
304
+ * **`platformRevenue` must NEVER be added to this sweep (AGL-1811).** It is
305
+ * org-keyed by field — exactly the shape this mechanism eats — and that is
306
+ * the trap: those rows are per-transaction TAX FILING RECORDS (gross, tax,
307
+ * jurisdiction) with a statutory retention obligation, and the quarterly
308
+ * Texas return is their sum. GDPR Art. 17(3)(b) exempts them: erasure does
309
+ * not extend to records retained for compliance with a legal obligation. An
310
+ * erased org's rows deliberately outlive it — the
311
+ * `erase-org-tax-retention.emulator.spec` pins survival, so an over-eager
312
+ * future sweep fails a test instead of un-filing a tax period.
313
+ *
314
+ * **`storefrontTaxCollected` must NEVER be added either (AGL-1904).** Same
315
+ * shape, same trap, same reason: it carries `orgId` as a field, and its rows
316
+ * are the record of sales tax charged to shoppers on that org's storefront —
317
+ * including the tax a `mode: 'stripe'` store collects under AGLYN's own
318
+ * Texas registration, which Aglyn must be able to account for long after the
319
+ * merchant has gone.
320
+ */ async function deleteDocsByOrgId(collection, orgId, dryRun = false) {
321
+ if (!orgId) return 0;
322
+ const firestore = firebaseAdmin.app().firestore();
323
+ const rows = await firestore.collection(collection).where('orgId', '==', orgId).get();
324
+ if (dryRun) return rows.size;
325
+ for(let index = 0; index < rows.docs.length; index += 400){
326
+ const batch = firestore.batch();
327
+ for (const doc of rows.docs.slice(index, index + 400))batch.delete(doc.ref);
328
+ await batch.commit();
329
+ }
330
+ return rows.size;
331
+ }
332
+ /**
333
+ * Drop the org's public SSO routing documents (AGL-1448).
334
+ *
335
+ * `ssoDomains/{domain}` is keyed by the DOMAIN and carries `orgId` as a field,
336
+ * so `recursiveDelete(orgRef)` never reaches it. The org's own claims under
337
+ * `orgs/{orgId}/ssoDomains/{domain}` do die with the tree; the public routing
338
+ * doc — the one that decides where a sign-in goes — does not.
339
+ *
340
+ * **`unpublishSsoDomains` is not the fix, though it looks like it.** That
341
+ * function is the reversible half of the console's SSO toggle: it sets
342
+ * `active: false` and leaves the document standing precisely so re-enabling
343
+ * restores the same pool and the same uids. Applied to an erasure it would
344
+ * leave behind a record naming the deleted org, its GCIP tenant and provider
345
+ * ids, its IdP display name and the customer's email domain — and, worse, a
346
+ * live reservation: `issueDomainClaim` refuses a domain whose `ssoDomains` doc
347
+ * belongs to another org REGARDLESS of `active`, so a ghost would hold a real
348
+ * customer's domain against them with no way to appeal. Absence is the only
349
+ * state that both stops the routing and releases the name.
350
+ *
351
+ * Deleting is safe on the read side: `/api/auth/sso-lookup` already treats a
352
+ * missing doc and an inactive one identically (`{ ssoEnabled: false }`).
353
+ */ async function eraseOrgSsoDomains(orgId, dryRun = false) {
354
+ return deleteDocsByOrgId('ssoDomains', orgId, dryRun);
355
+ }
356
+ /**
357
+ * Release every custom console domain the org holds (AGL-1448).
358
+ *
359
+ * `releaseConsoleDomain` already does exactly the right thing and was called
360
+ * from nothing but its own spec. It is wired rather than reimplemented, and
361
+ * the ordering it enforces is the reason: Vercel detach FIRST, documents
362
+ * second, and the claim KEPT if a detach fails. `resolveConsoleDomain`'s
363
+ * `unknown` verdict — the one that lets localhost, previews and self-hosted
364
+ * installs work — is only safe while every name the console project still
365
+ * answers for has a document (AGL-1430). Deleting the claim here directly
366
+ * would break that correspondence and hand the name to the next org that
367
+ * claims it, with Vercel's `already-exists` reading as health.
368
+ *
369
+ * It takes one domain, so the org-bounded query is the part that was missing.
370
+ * Every name in a claim set — the primary and its `www` twin — carries the
371
+ * same `orgId` and the same `primaryHost`, so releasing once per distinct
372
+ * primary covers the set exactly, without a second pass over the twins.
373
+ *
374
+ * Best-effort, like every other external cleanup here: an erasure must not
375
+ * fail because Vercel did. A name that could not be detached keeps its claim
376
+ * and is reported in the audit row as un-released.
377
+ */ async function releaseOrgConsoleDomains(orgId, dryRun = false) {
378
+ const firestore = firebaseAdmin.app().firestore();
379
+ const claims = await firestore.collection(CONSOLE_DOMAINS_COLLECTION).where('orgId', '==', orgId).get();
380
+ const primaries = new Set(claims.docs.map((doc)=>doc.get('primaryHost') || doc.id));
381
+ // A plan counts the claim sets it WOULD release. `releaseConsoleDomain`
382
+ // detaches at Vercel, so it must not run for a dry run (AGL-1481).
383
+ if (dryRun) return primaries.size;
384
+ let released = 0;
385
+ for (const domain of primaries){
386
+ const result = await releaseConsoleDomain({
387
+ orgId,
388
+ domain
389
+ }).catch((error)=>{
390
+ console.error(`eraseOrg: console domain ${domain} release failed`, error);
391
+ return {
392
+ released: false
393
+ };
394
+ });
395
+ if (result.released) released += 1;
396
+ }
397
+ return released;
398
+ }
399
+ /**
400
+ * Drop the local Stripe reverse index (AGL-1448, AGL-1028).
401
+ *
402
+ * `stripeCustomers/{stripeCustomerId} -> { orgId }` exists because AGL-1028
403
+ * moved `stripeCustomerId` off the org doc into a manager-gated subcollection,
404
+ * which broke the webhook's `where('stripeCustomerId', '==', …)` lookup. It is
405
+ * denied to every client for one reason, stated in the rules: readable, it
406
+ * maps a billing identity back to a workspace.
407
+ *
408
+ * The erasure had this exactly inverted. It deleted the customer AT STRIPE and
409
+ * kept the local index — so the artefact that survived was the correlation the
410
+ * whole issue existed to prevent, now pointing at an org that no longer exists
411
+ * to be gated on. This deletes the local rows. The Stripe-side delete is
412
+ * unchanged and still runs: PII lives at the processor too, and one is not a
413
+ * substitute for the other.
414
+ *
415
+ * Bounded by the `orgId` field rather than by the org's CURRENT customer id,
416
+ * because nothing cleans up the index when a customer id changes — an org that
417
+ * was re-created in Stripe has more than one row pointing at it.
418
+ */ async function eraseOrgStripeIndex(orgId, dryRun = false) {
419
+ return deleteDocsByOrgId('stripeCustomers', orgId, dryRun);
420
+ }
421
+ /**
422
+ * Drop the org's REST API replay keys (AGL-1448).
423
+ *
424
+ * `apiIdempotency/{sha256(orgId:key)}` dedupes `POST`s by `Idempotency-Key`
425
+ * (AGL-618). Low severity — a replay key names an org and a record id and
426
+ * authorises nothing — but it does NOT age out on its own, which is the part
427
+ * worth writing down because the assumption runs the other way: the only TTL
428
+ * policy on the database is `rateLimits.expiresAt`
429
+ * (`docs/FIRESTORE_MANUAL_CONFIG.md`), and these documents carry no expiry
430
+ * field for a policy to key on. Left alone they accumulate against a dead org
431
+ * indefinitely.
432
+ *
433
+ * Taken with the credentials rather than at the end: the record ids they carry
434
+ * point into the org tree that step 4 destroys.
435
+ */ async function eraseOrgIdempotencyKeys(orgId, dryRun = false) {
436
+ return deleteDocsByOrgId('apiIdempotency', orgId, dryRun);
437
+ }
438
+ /**
439
+ * Release every slug the org has EVER held (AGL-1448).
440
+ *
441
+ * The erasure deleted `orgSlugs/{org.slug}` — the current name only. But
442
+ * `changeOrgSlug` leaves a tombstone at the previous slug (`{ orgId, movedTo,
443
+ * renamedAt }`) so old workspace URLs keep redirecting (AGL-585/AGL-236), and
444
+ * nothing ever collected them. Every historical name an org held therefore
445
+ * survived its erasure, in the one collection the rules make
446
+ * `allow read: if true` — public because it doubles as the pre-auth health
447
+ * probe. An erased workspace's naming history stayed world-readable, each
448
+ * tombstone still naming the dead org id.
449
+ *
450
+ * Bounded by the `orgId` field, which is exactly the right boundary here and
451
+ * not merely the safe one: claiming a tombstoned slug FULL-REPLACES the
452
+ * document with the new owner's `{ orgId }`, so a name this org renamed away
453
+ * from and somebody else has since taken does not match — and must not be
454
+ * swept, because it is now a live tenant's reservation.
455
+ *
456
+ * The current slug is unioned in rather than assumed present: an org whose
457
+ * reservation was lost to a legacy write would otherwise keep its workspace
458
+ * subdomain attached to the project. ⛔ ONLY WHEN NOBODY HOLDS IT (AGL-2585).
459
+ * A workspace address taken by an unverified signup is a reservation that
460
+ * lapses, and a live workspace may since have claimed the name — at which
461
+ * point `orgSlugs/{slug}` names THAT org, and the erasure of this one would
462
+ * otherwise delete a working tenant's reservation and detach their subdomain.
463
+ * The `orgId` query above already refuses that document; this is the one line
464
+ * that used to add it back by assumption.
465
+ *
466
+ * Each name's `{slug}.aglyn.com` is detached too. `changeOrgSlug` deliberately
467
+ * KEEPS the old subdomain attached so the tombstone's 308 has a hostname to
468
+ * run on; once the tombstone goes, the attachment is a name resolving to a
469
+ * console for an org that does not exist. Best-effort, like every other
470
+ * external cleanup here: an erasure must not fail because a DNS API did.
471
+ */ async function eraseOrgSlugs(orgId, currentSlug, dryRun = false) {
472
+ const firestore = firebaseAdmin.app().firestore();
473
+ const held = await firestore.collection('orgSlugs').where('orgId', '==', orgId).get();
474
+ const slugs = new Set(held.docs.map((doc)=>doc.id));
475
+ if (currentSlug && !slugs.has(currentSlug)) {
476
+ // Absent means the reservation was lost and only the DNS attachment is
477
+ // left to clean up. Present but not matched by the query above means it
478
+ // belongs to another org now, and is not this erasure's to touch.
479
+ const reservation = await firestore.collection('orgSlugs').doc(currentSlug).get().catch(()=>null);
480
+ if (reservation && !reservation.exists) slugs.add(currentSlug);
481
+ }
482
+ // `detachWorkspaceDomain` calls a live DNS API — never on a plan (AGL-1481).
483
+ if (dryRun) return slugs.size;
484
+ for (const slug of slugs){
485
+ await firestore.collection('orgSlugs').doc(slug).delete().catch(()=>undefined);
486
+ await detachWorkspaceDomain(slug).catch(()=>undefined);
487
+ }
488
+ return slugs.size;
489
+ }
490
+ /**
491
+ * Sweep up ORPHANED `hostIndex` rows the per-host loop cannot enumerate
492
+ * (AGL-1448).
493
+ *
494
+ * `hostIndex/{hostId}` is `{ orgId, subdomain }` and `allow read: if
495
+ * isSignedIn()`. It is deleted today inside `eraseHost`, which `eraseOrg`
496
+ * calls once per document in `hosts where orgId == org` — so the row is
497
+ * reached DERIVATIVELY, through a second collection, and only for as long as
498
+ * the two agree.
499
+ *
500
+ * **The erasure is itself one of the ways they stop agreeing.** `eraseHost`'s
501
+ * `hostIndex` delete is `.catch(() => undefined)` — fail-soft on purpose, so
502
+ * a transient failure never blocks the Firestore delete — and the very next
503
+ * statement `recursiveDelete`s `hosts/{hostId}`. The instant that lands, the
504
+ * orphaned index row is unreachable from the org side forever: nothing points
505
+ * at it, so neither this run nor any retry can ever enumerate it again. A
506
+ * fail-soft that manufactures a PERMANENT orphan is not fail-soft, and the
507
+ * residue is a signed-in-readable row naming a dead org and the subdomain its
508
+ * customer chose.
509
+ *
510
+ * Bounded by the `orgId` field — this is the table the middleware resolves
511
+ * every request on, so an unbounded sweep here does not lose an index, it
512
+ * takes the estate offline.
513
+ *
514
+ * A BACKSTOP, not a replacement: the per-host loop still does the real work
515
+ * (Storage, the org routing map, the member projections, the host tree). This
516
+ * only collects what that loop structurally cannot see.
517
+ */ async function eraseOrgHostIndex(orgId, dryRun = false) {
518
+ return deleteDocsByOrgId('hostIndex', orgId, dryRun);
519
+ }
520
+ /**
521
+ * Sweep up `hostMemberships` projections belonging to FORMER members
522
+ * (AGL-1448).
523
+ *
524
+ * `users/{uid}/hostMemberships/{hostId}` carries `{ orgId, subdomain,
525
+ * displayName, nameLower, role }` — the site switcher's row. It lives under a
526
+ * USER, so `recursiveDelete(orgs/{orgId})` cannot reach it, and `eraseOrg`'s
527
+ * members loop only walks `orgs/{orgId}/members`.
528
+ *
529
+ * `deleteHostProjectionForAllMembers(orgId, hostId)` clears the row for
530
+ * everyone in that member list at the moment the erasure runs. **A former
531
+ * member is not in it.** Their row is written by the projection fan-out and
532
+ * removed by `removeOrgMember`, which is fail-soft in the same way as
533
+ * everything else on that path; one missed fan-out leaves a row no later
534
+ * erasure can find, because the enumeration starts from a membership that no
535
+ * longer exists. The erased workspace's site NAME and subdomain then live on
536
+ * in the user document of somebody who left the company.
537
+ *
538
+ * ## This is a COLLECTION-GROUP query, and that owes an index
539
+ *
540
+ * `hostMemberships` has two composite indexes, both `queryScope: COLLECTION`.
541
+ * A collection-group equality on `orgId` needs a `COLLECTION_GROUP`
542
+ * single-field index, which Firestore does NOT create automatically — the
543
+ * automatic single-field indexes are collection-scoped except for
544
+ * array-contains. `cloud/firebase-firestore.indexes.json` therefore gains a
545
+ * `fieldOverrides` entry alongside the existing `members.uid` and
546
+ * `messages.authorId` ones, and **it must be deployed by hand.**
547
+ *
548
+ * Until it is, the query throws `FAILED_PRECONDITION`. AGL-1793 is this
549
+ * codebase's record of exactly that failure mode — both commerce crons
550
+ * silently dead in production for want of a `COLLECTION_GROUP_ASC` override —
551
+ * so this refuses to be the thing that kills an erasure. It is caught and
552
+ * reported as `null`, which is deliberately NOT `0`: a zero would say "there
553
+ * was nothing to sweep" and a missing index would wear that as a clean bill
554
+ * of health. `null` in the audit row says "attempted, could not run", and the
555
+ * spec asserts a NUMBER so the wired-and-indexed path is what is pinned.
556
+ */ async function eraseOrgHostMemberships(orgId, dryRun = false) {
557
+ if (!orgId) return 0;
558
+ try {
559
+ const firestore = firebaseAdmin.app().firestore();
560
+ const rows = await firestore.collectionGroup('hostMemberships').where('orgId', '==', orgId).get();
561
+ if (dryRun) return rows.size;
562
+ for(let index = 0; index < rows.docs.length; index += 400){
563
+ const batch = firestore.batch();
564
+ for (const doc of rows.docs.slice(index, index + 400)){
565
+ batch.delete(doc.ref);
566
+ }
567
+ await batch.commit();
568
+ }
569
+ return rows.size;
570
+ } catch (error) {
571
+ console.error(`eraseOrg: hostMemberships projection sweep failed for ${orgId} — a missing COLLECTION_GROUP index on hostMemberships.orgId is the likeliest cause`, error);
572
+ return null;
573
+ }
574
+ }
575
+ /**
576
+ * Drop the org's PUBLIC marketplace identity — the profile and every handle
577
+ * it reserved (AGL-1970).
578
+ *
579
+ * Both survived every erasure until now, and both are `allow read: if true`
580
+ * (`cloud/firebase-firestore.rules`, `match /publisherProfiles/{orgId}`). This
581
+ * is the AGL-1448 shape with one twist that is the whole reason it was missed:
582
+ * `publisherProfiles` is keyed by the org id **as the document id**, so it is
583
+ * invisible to `deleteDocsByOrgId` *and* to a reader scanning this file for
584
+ * the field-keyed sweep list. Nothing prompts you to notice it.
585
+ *
586
+ * What survived is not cosmetic. `stripeAccountId` is a **payout
587
+ * destination** — written server-side only after Connect onboarding/KYC and
588
+ * trusted by every checkout path — so an erased org stayed world-readable with
589
+ * a live payment-account identifier attached to a dead identity. That is the
590
+ * `stripeCustomers` correlation AGL-1448 removed, pointing the other way.
591
+ *
592
+ * `publisherHandles` is the easy half and goes unconditionally: it carries
593
+ * `orgId` as a FIELD, so `deleteDocsByOrgId` fits exactly, and it is a **live
594
+ * reservation** — `claimPublisherHandle` refuses a handle another org's row
595
+ * names, so a ghost holds a marketplace name against a real customer with no
596
+ * way to appeal. That is the `ssoDomains` argument verbatim, and absence is
597
+ * again the only state that both stops the read and releases the name. Rename
598
+ * tombstones (`{ orgId, movedTo }`) carry the same `orgId` and go with it, and
599
+ * the field bound is what makes that safe: re-claiming a tombstone FULL-
600
+ * REPLACES it with the new owner's `{ orgId }`, so a handle this org renamed
601
+ * away from and somebody else has since taken does not match the query.
602
+ *
603
+ * **The profile is the half with a genuine tension, and it is resolved here
604
+ * rather than deferred.** `marketplaceListings` outlives an erasure — that is
605
+ * AGL-1448's parked Tier 3 product decision, an erased org's listing being
606
+ * something buyers paid for — so deleting the publisher document outright can
607
+ * leave a listing attributed to nothing. So:
608
+ *
609
+ * - **No surviving listing** → `recursiveDelete` the profile. `deleted`.
610
+ * `recursiveDelete` and not `delete`: `publish-plugin` keeps its daily
611
+ * publish-rate window at `publisherProfiles/{orgId}/meta/publishWindow`,
612
+ * and a document delete would orphan it under a path with no owner.
613
+ * - **A listing survives** → the same `recursiveDelete`, then a minimal
614
+ * `{ erased: true, erasedAt }` in its place. `tombstoned`. The tombstone
615
+ * carries no `handle`, no `displayName`, no `bio`/`avatarUrl`/`website`,
616
+ * no `publisherAgreement` and — the point — no `stripeAccountId`. It is
617
+ * "an internal record that the erasure happened", which is exactly what
618
+ * the Privacy Policy §5 sentence reserves; every byte the sentence calls
619
+ * content is gone either way. Readers already handle it: with no `handle`,
620
+ * `resolvePublisherProfile` returns `null`, which is the pre-existing
621
+ * "this org has no profile" path.
622
+ *
623
+ * The count of surviving listings is returned either way, so an erasure that
624
+ * left something standing SAYS SO in its audit row instead of reporting a
625
+ * clean success — the Tier 3 decision is still open, and an erasure is the
626
+ * one place its cost is measurable.
627
+ */ async function eraseOrgPublisherIdentity(orgId, dryRun = false) {
628
+ const firestore = firebaseAdmin.app().firestore();
629
+ const handles = await deleteDocsByOrgId('publisherHandles', orgId, dryRun);
630
+ const profileRef = firestore.collection('publisherProfiles').doc(orgId);
631
+ const [profileSnapshot, listings] = await Promise.all([
632
+ profileRef.get(),
633
+ // `profileId` is the listing's publishing-org id (AGL-652) — the same
634
+ // value as this profile's document id, under the older field name.
635
+ firestore.collection('marketplaceListings').where('profileId', '==', orgId).get()
636
+ ]);
637
+ const listingsRetained = listings.size;
638
+ if (!profileSnapshot.exists) {
639
+ return {
640
+ handles,
641
+ profile: 'absent',
642
+ listingsRetained
643
+ };
644
+ }
645
+ if (dryRun) {
646
+ return {
647
+ handles,
648
+ profile: listingsRetained ? 'tombstoned' : 'deleted',
649
+ listingsRetained
650
+ };
651
+ }
652
+ await firestore.recursiveDelete(profileRef);
653
+ if (!listingsRetained) return {
654
+ handles,
655
+ profile: 'deleted',
656
+ listingsRetained
657
+ };
658
+ await profileRef.set({
659
+ erased: true,
660
+ erasedAt: FieldValue.serverTimestamp()
661
+ });
662
+ return {
663
+ handles,
664
+ profile: 'tombstoned',
665
+ listingsRetained
666
+ };
667
+ }
668
+ /**
669
+ * Destroy every support ticket the org opened, and the messages under each
670
+ * (AGL-1971).
671
+ *
672
+ * `supportTickets/{id}` is top-level with `orgId` as a field — the exact shape
673
+ * `deleteDocsByOrgId` exists for — and it was in neither sweep list. It is
674
+ * also the highest-PII-density collection outside the org tree: `messages`
675
+ * rows carry `authorId`, `authorEmail` and up to 5000 characters of prose a
676
+ * customer wrote to us, which is where somebody pastes the invoice, the member
677
+ * list, or the very data they are asking us to delete.
678
+ *
679
+ * **`deleteDocsByOrgId` is deliberately NOT used here**, though the query is
680
+ * identical. It deletes documents, not subtrees, and a batch delete of the
681
+ * ticket documents would leave every `messages` row alive under a parent that
682
+ * no longer exists — the prose and the email surviving the delete that was
683
+ * supposed to remove them, now unreachable by any query that starts from
684
+ * `orgId`. `recursiveDelete` per ticket is the only correct shape.
685
+ *
686
+ * **No retention exception, and the alternative was considered.** A closed
687
+ * support thread is arguable dispute evidence, which is the class of argument
688
+ * that keeps `platformRevenue` alive (AGL-1811). It does not reach here: that
689
+ * exemption is GDPR Art. 17(3)(b), *records retained to comply with a legal
690
+ * obligation*, and no law requires us to keep a support thread — a Texas tax
691
+ * return is filed from `platformRevenue`; nothing is filed from a ticket.
692
+ * Live DPA §11 commits the other way in terms ("delete or return Customer
693
+ * Personal Data, except to the extent retention is required by law"). What
694
+ * remains as evidence is the `adminAudit` row: the erasure happened, when, at
695
+ * whose instruction, and how many tickets it destroyed — ids and counts,
696
+ * never content (AGL-1443).
697
+ *
698
+ * Sequential rather than parallel: `recursiveDelete` is itself a bulk writer,
699
+ * and fanning N of them out at once is how a 60-second cron loses an erasure.
700
+ */ async function eraseOrgSupportTickets(orgId, dryRun = false) {
701
+ if (!orgId) return 0;
702
+ const firestore = firebaseAdmin.app().firestore();
703
+ const tickets = await firestore.collection('supportTickets').where('orgId', '==', orgId).get();
704
+ if (dryRun) return tickets.size;
705
+ for (const ticket of tickets.docs){
706
+ await firestore.recursiveDelete(ticket.ref);
707
+ }
708
+ return tickets.size;
709
+ }
710
+ /**
711
+ * A stable label for an error, carrying no free text (AGL-1455).
712
+ *
713
+ * The class name and the SDK's error code are enough to tell a Firestore
714
+ * permission denial from a deadline exceeded from an out-of-memory kill. The
715
+ * MESSAGE is deliberately not stored: a Firestore or Storage error quotes the
716
+ * path — and sometimes the payload — it choked on, and this row lands in a
717
+ * collection that must not become a second copy of what the erasure failed to
718
+ * delete (AGL-1443). The full error still goes to `console.error`, which is
719
+ * the log, not the record.
720
+ */ function errorLabel(error) {
721
+ const source = error != null ? error : {};
722
+ const parts = [
723
+ source.name,
724
+ source.code
725
+ ].filter((part)=>typeof part === 'string' || typeof part === 'number');
726
+ return parts.length ? parts.join(':') : 'unknown';
727
+ }
728
+ /**
729
+ * Record a failed erasure attempt where somebody will actually find it
730
+ * (AGL-1455).
731
+ *
732
+ * Aborting when the export cannot be written is correct and stays — the bug
733
+ * was that the abort was invisible. `skippedReason` reached exactly one
734
+ * place, the cron endpoint's HTTP response body, and nobody reads a
735
+ * scheduler's 200. Meanwhile `erasureRequestedAt` stays set, so the same org
736
+ * is retried on the next run and fails the same way indefinitely, and the
737
+ * customer who requested the erasure is told nothing.
738
+ *
739
+ * An `adminAudit` row is the cheapest durable form and the one the success
740
+ * path already uses, so the two read as a pair: same `actorUid`, same
741
+ * `target`, same `before` inventory, and `after` carrying what actually
742
+ * happened. Staff can find every stuck erasure by filtering the audit log on
743
+ * the action, which is exactly the search that was impossible before.
744
+ *
745
+ * `after.exportWritten`/`after.exportPath` used to be the fields that
746
+ * mattered here: they named the complete dump of a still-existing workspace
747
+ * that a post-export failure left in the bucket. AGL-1443 removed the write,
748
+ * so that state is no longer reachable and the fields no longer exist. What
749
+ * replaces them is `after.requestedAt` — WHICH erasure request is stuck,
750
+ * which the object's name used to be the only record of.
751
+ *
752
+ * Ids, counts, a step name and a timestamp. Never customer data.
753
+ *
754
+ * Best-effort and never throws: this runs while an erasure is already
755
+ * failing, and losing the original error to a bookkeeping write would be
756
+ * worse than losing the row.
757
+ */ async function recordErasureFailure(entry) {
758
+ await firebaseAdmin.app().firestore().collection('adminAudit').add({
759
+ actorUid: entry.actorUid,
760
+ action: 'org.erase-failed',
761
+ target: `orgs/${entry.orgId}`,
762
+ before: entry.before,
763
+ after: _extends({
764
+ failedStep: entry.step,
765
+ error: errorLabel(entry.error),
766
+ requestedAt: entry.requestedAt
767
+ }, entry.after),
768
+ at: FieldValue.serverTimestamp()
769
+ }).catch((auditError)=>{
770
+ console.error(`eraseOrg: could not record the failed attempt for ${entry.orgId}`, auditError);
771
+ });
772
+ }
773
+ /**
774
+ * Permanently erase an organization once its 7-day hold has elapsed
775
+ * (AGL-485/487). Runs from the automated cron and the manual staff path.
776
+ *
777
+ * **Both of those are literally this function (AGL-1481).** The manual path,
778
+ * `tools/scripts/erase-tenant.mjs`, used to be a second implementation, and a
779
+ * second implementation of a cascade delete is a divergence with a schedule:
780
+ * within a week of `eraseOrgApiKeys`, the SSO/console-domain release and the
781
+ * org-keyed index sweeps landing here, the script was missing all four and
782
+ * reporting success without them — leaving a live API credential, live domain
783
+ * reservations, `orgSlugs` tombstones and the `stripeCustomers` reverse index
784
+ * behind an erasure a human had been told was complete. The script now calls
785
+ * this, and `EraseOrgOptions` is where the two capabilities it had and this
786
+ * did not (a plan, and a named actor) live instead.
787
+ *
788
+ * Order matters and every step is defensive:
789
+ * 1. Re-read the org and re-verify erasureRequestedAt + hold — never
790
+ * delete on a stale/cancelled request.
791
+ * 2. Nothing is written. This step used to persist a final JSON export of
792
+ * the org and host trees to `erasures/{orgId}/…` and abort if it could
793
+ * not — see the note below for why it is gone (AGL-1443).
794
+ * 3. Revoke the org's API credentials (AGL-1444), its public routing — SSO
795
+ * domains and custom console domains (AGL-1448) — and its public
796
+ * marketplace identity: the publisher profile and every handle it
797
+ * reserved (AGL-1970); and destroy the OAuth grants behind its reps'
798
+ * Outreach mailboxes (AGL-2974). All of them are top-level collections
799
+ * keyed by something other than a path under the org, so the org-tree
800
+ * delete cannot reach them; doing them before the content delete also
801
+ * closes the mid-erasure window, in which a credential, a domain or a
802
+ * public publisher page still resolves to a half-deleted workspace.
803
+ * 4. Delete each host (eraseHost), org-level Storage, the Stripe customer
804
+ * AND its local reverse index, member back-references, then the org tree
805
+ * and every slug the org ever held — tombstones included (AGL-1448).
806
+ * 5. Audit.
807
+ *
808
+ * Every ending is audited, not just the successful one (AGL-1455). A throw
809
+ * from any step writes an `org.erase-failed` row before re-throwing, because
810
+ * `erasureRequestedAt` stays set on a failure: without a record the org is
811
+ * retried and fails identically on every subsequent run, forever, and nothing
812
+ * anywhere says so.
813
+ *
814
+ * **The erasure writes no copy of the workspace (AGL-1443).** It used to
815
+ * persist `erasures/{orgId}/{requestedMs}.json` — and "export" understated
816
+ * it: `exportDocTree` recursed `listCollections()` with no bound and copied
817
+ * `snapshot.data()` wholesale, so the object was a complete verbatim copy of
818
+ * the org tree and every host tree, carrying `webhooks.secret` (a plaintext
819
+ * HMAC key), `orders.paymentLinkUrl` (a live bearer URL that lets its holder
820
+ * pay), `screens.protection.passwordHash` and `ssoDomains.token`. It landed
821
+ * on a prefix this function's own storage sweep does not cover, in a bucket
822
+ * with no lifecycle rule, so the most sensitive object the platform can
823
+ * produce outlived the request that deleted everything else — indefinitely,
824
+ * unread, and belonging to a customer who had been told their workspace was
825
+ * gone. Four facts decided it, and none of them is about tidiness:
826
+ *
827
+ * - **Nothing read it.** One producer, zero consumers in the repo; the
828
+ * owner's confirmation email never mentioned it.
829
+ * - **A governed full copy already exists.** Firestore backups run weekly
830
+ * with 14-week retention (AGL-871), so the dump added no recoverability —
831
+ * only a second, ungoverned place the data lived.
832
+ * - **The minimum that proves an erasure was already being written.** The
833
+ * `adminAudit` row below is ids and counts, in a collection with a
834
+ * retention policy. The one thing the object's NAME carried that the row
835
+ * did not — which request this run fulfilled — is now `after.requestedAt`.
836
+ * - **The DPA commits to "a limited period, after which deleted or
837
+ * de-identified"**, which indefinite retention of a full dump sits
838
+ * outside of.
839
+ *
840
+ * Two consequences are deliberate, not side effects. The export-write abort
841
+ * is gone with the write: there is nothing left to fail before the first
842
+ * delete, so an erasure can no longer be blocked by Storage. And the export
843
+ * was the only unbounded work in this function — it built the whole workspace
844
+ * in memory inside a 60-second cron, so the larger the workspace the likelier
845
+ * its erasure silently never happened (AGL-1455 half 1). That defect is
846
+ * removed by deletion rather than by a streaming exporter.
847
+ */ export async function eraseOrg(orgId, options = {}) {
848
+ var _ref;
849
+ var _orgSnapshot_get_toMillis, _orgSnapshot_get;
850
+ const { dryRun = false, actorUid = 'cron:run-erasures', tearDownSendingDomain = null, withoutRequest = null } = options;
851
+ const firestore = firebaseAdmin.app().firestore();
852
+ const orgRef = firestore.collection('orgs').doc(orgId);
853
+ const orgSnapshot = await orgRef.get();
854
+ if (!orgSnapshot.exists) return {
855
+ ok: false,
856
+ skippedReason: 'not-found'
857
+ };
858
+ const requestedMs = (_ref = (_orgSnapshot_get = orgSnapshot.get('erasureRequestedAt')) == null ? void 0 : (_orgSnapshot_get_toMillis = _orgSnapshot_get.toMillis) == null ? void 0 : _orgSnapshot_get_toMillis.call(_orgSnapshot_get)) != null ? _ref : null;
859
+ // The request and its hold, unless the caller carries its own proof
860
+ // (AGL-2585). Both refusals are skipped together: a workspace with no
861
+ // request has no stamp for a hold to run from, so keeping the second while
862
+ // dropping the first would be a hold measured against `null`.
863
+ if (!withoutRequest) {
864
+ if (!requestedMs) return {
865
+ ok: false,
866
+ skippedReason: 'no-request'
867
+ };
868
+ if (Date.now() - requestedMs < ERASURE_HOLD_MS) {
869
+ return {
870
+ ok: false,
871
+ skippedReason: 'hold-active'
872
+ };
873
+ }
874
+ }
875
+ const hosts = await firestore.collection('hosts').where('orgId', '==', orgId).get();
876
+ const members = await orgRef.collection('members').get();
877
+ const slug = orgSnapshot.get('slug');
878
+ // AGL-1028: moved to `orgs/{orgId}/billing/stripe`; the helper falls back
879
+ // to the org doc for orgs the backfill has not reached.
880
+ const stripeCustomerId = (await readOrgBilling(orgId)).stripeCustomerId;
881
+ // The inventory both audit rows report, taken before anything is touched.
882
+ const before = {
883
+ hosts: hosts.size,
884
+ members: members.size
885
+ };
886
+ // No export. The write that used to stand here is gone (AGL-1443) — see
887
+ // the note above this function. Nothing is persisted anywhere before the
888
+ // deleting starts, and `requestedMs` now travels in the audit row rather
889
+ // than in an object's name.
890
+ // None of what follows was guarded (AGL-1455) — `eraseOrgApiKeys`,
891
+ // `eraseOrgSsoDomains`, `eraseOrgIdempotencyKeys` and `recursiveDelete` all
892
+ // throw. A throw here leaves a workspace half destroyed and still standing,
893
+ // recorded nowhere. Each step now names itself, and the error is re-thrown
894
+ // unchanged — this adds a record, it does not swallow the failure. What it
895
+ // can no longer leave behind is the state that used to be the worst one: a
896
+ // surviving workspace with its own complete dump already in the bucket.
897
+ const progress = {};
898
+ let step = 'plugins';
899
+ try {
900
+ // The plugins' share first (AGL-2978), while the org, its roster and
901
+ // every record swept below still exist: an eraser that revokes a grant at
902
+ // its provider opens the stored grant to do it. Each eraser is isolated,
903
+ // so a failing one is recorded as `null` and stops nothing; on a plan
904
+ // each one counts and touches no provider.
905
+ progress.plugins = await runPluginOrgErasers({
906
+ orgId,
907
+ dryRun
908
+ });
909
+ step = 'credentials';
910
+ // Credentials and routing BEFORE content (AGL-1444/AGL-1448). The org doc
911
+ // survives until the recursiveDelete at the end, so a key presented — or a
912
+ // sign-in routed, or a console domain resolved — mid-erasure would still
913
+ // pass the org gate and reach a half-deleted workspace. Revoking first
914
+ // closes that window as well as the permanent one.
915
+ progress.apiKeys = await eraseOrgApiKeys(orgId, dryRun);
916
+ progress.outreachMailboxCredentials = await eraseOrgOutreachMailboxCredentials(orgId, dryRun);
917
+ progress.ssoDomains = await eraseOrgSsoDomains(orgId, dryRun);
918
+ progress.consoleDomains = await releaseOrgConsoleDomains(orgId, dryRun);
919
+ progress.apiIdempotency = await eraseOrgIdempotencyKeys(orgId, dryRun);
920
+ // The org's PUBLIC marketplace identity (AGL-1970). Here rather than with
921
+ // the Stripe step, though it carries a payout id, because `publisherHandles`
922
+ // is a live name reservation and belongs with the other reservations this
923
+ // step releases — and because the profile is world-readable, so the sooner
924
+ // it stops resolving the smaller the mid-erasure window.
925
+ const publisher = await eraseOrgPublisherIdentity(orgId, dryRun);
926
+ progress.publisherHandles = publisher.handles;
927
+ progress.publisherProfile = publisher.profile;
928
+ progress.listingsRetained = publisher.listingsRetained;
929
+ // Support threads (AGL-1971). Its own step because it is the only sweep
930
+ // here that walks a subtree per row, so it is the one most likely to be
931
+ // where a large workspace's erasure runs out of time — and a failure that
932
+ // cannot say WHICH step stopped it is the AGL-1455 defect again.
933
+ step = 'support';
934
+ progress.supportTickets = await eraseOrgSupportTickets(orgId, dryRun);
935
+ /*
936
+ * Hosts (Storage + routing + Firestore trees + the sending domain).
937
+ *
938
+ * Each site's dedicated domain is released here rather than by a sweep
939
+ * that never existed: a workspace erasure used to leave one plan-capped
940
+ * provider slot and three zone records per site standing forever, with
941
+ * the DKIM key still live under a label a future site could claim.
942
+ *
943
+ * The driver is passed in and may be absent, and either way the erasure
944
+ * finishes. What the vendors refuse is recorded as a debt on the label
945
+ * claim, which survives the `recursiveDelete` of both the host and the
946
+ * org, and the reaper settles it.
947
+ */ step = 'hosts';
948
+ progress.hosts = 0;
949
+ progress.sendingDomains = {
950
+ released: 0,
951
+ deferred: 0,
952
+ protected: 0
953
+ };
954
+ for (const host of hosts.docs){
955
+ if (!dryRun) {
956
+ const erased = await eraseHost(host.id, {
957
+ tearDownSendingDomain
958
+ });
959
+ if (erased.sendingDomain === 'released') {
960
+ progress.sendingDomains.released += 1;
961
+ }
962
+ if (erased.sendingDomain === 'deferred') {
963
+ progress.sendingDomains.deferred += 1;
964
+ }
965
+ if (erased.sendingDomain === 'protected') {
966
+ progress.sendingDomains.protected += 1;
967
+ }
968
+ }
969
+ progress.hosts += 1;
970
+ }
971
+ // The backstop for what the loop above cannot enumerate (AGL-1448): a
972
+ // `hostIndex` row whose `hosts` document is already gone. AFTER the loop
973
+ // deliberately — the loop's own fail-soft delete is one of the things
974
+ // that creates them, so sweeping first would leave this run's orphans
975
+ // behind for a retry that can no longer see them.
976
+ progress.hostIndex = await eraseOrgHostIndex(orgId, dryRun);
977
+ // Org-level Storage (media/dataset assets outside the host prefix).
978
+ step = 'org-storage';
979
+ if (!dryRun) {
980
+ try {
981
+ await storageBucket().deleteFiles({
982
+ prefix: `orgs/${orgId}/`
983
+ });
984
+ } catch (error) {
985
+ console.error(`eraseOrg: org storage cleanup failed for ${orgId}`, error);
986
+ }
987
+ // The org library's copies at the delivery provider (AGL-2824); each
988
+ // site's went with its `eraseHost` above.
989
+ await eraseMediaDeliveryScope({
990
+ collection: 'orgs',
991
+ scopeId: orgId
992
+ });
993
+ }
994
+ // Stripe: the customer at the processor, AND the local reverse index that
995
+ // correlates that identity back to this workspace (AGL-1448). Deleting
996
+ // only the first left exactly the record AGL-1028 denied to every client.
997
+ step = 'stripe';
998
+ // `readOrgBilling` hands back the customer for THIS deployment's Stripe
999
+ // mode (AGL-2486), which is the only one `deleteStripeCustomer` could act
1000
+ // on anyway — it spends `STRIPE_SECRET_KEY`, and a live key cannot delete a
1001
+ // test customer. Known residue, deliberately not chased here: an org that
1002
+ // was also exercised in TEST mode leaves its test customer standing at
1003
+ // Stripe after a live erasure. That is synthetic data created by us, not
1004
+ // subject data supplied by the workspace, and reaching it would mean
1005
+ // erasure spending a second API key in a mode this deployment is not in.
1006
+ if (!dryRun) await deleteStripeCustomer(stripeCustomerId);
1007
+ progress.stripeIndex = await eraseOrgStripeIndex(orgId, dryRun);
1008
+ // Members' reverse index into this org.
1009
+ step = 'members';
1010
+ if (!dryRun) {
1011
+ for (const member of members.docs){
1012
+ await firestore.collection('users').doc(member.id).collection('orgs').doc(orgId).delete().catch(()=>undefined);
1013
+ }
1014
+ }
1015
+ // The backstop for the members loop (AGL-1448): a `hostMemberships`
1016
+ // projection belonging to somebody who has LEFT the org, whom
1017
+ // `deleteHostProjectionForAllMembers` cannot enumerate because the
1018
+ // membership it would start from is already gone. Collection-group —
1019
+ // see the note on the function, and the index it owes.
1020
+ progress.hostMemberships = await eraseOrgHostMemberships(orgId, dryRun);
1021
+ // The org subtree, then every slug reservation it has ever held — the
1022
+ // current one and each rename tombstone (AGL-1448), with the matching
1023
+ // `{slug}.aglyn.com` detached (AGL-1136).
1024
+ step = 'org-tree';
1025
+ if (!dryRun) await firestore.recursiveDelete(orgRef);
1026
+ step = 'slugs';
1027
+ progress.slugs = await eraseOrgSlugs(orgId, slug, dryRun);
1028
+ } catch (error) {
1029
+ console.error(`eraseOrg: ${step} failed for ${orgId}`, error);
1030
+ await recordErasureFailure({
1031
+ orgId,
1032
+ step,
1033
+ error,
1034
+ actorUid,
1035
+ requestedAt: requestedMs,
1036
+ before,
1037
+ // How far it got, so the next reader knows what is already destroyed.
1038
+ after: _extends({}, progress)
1039
+ });
1040
+ throw error;
1041
+ }
1042
+ // A plan (AGL-1481). Every sweep above ran its query and skipped its write,
1043
+ // so these are the counts the real run would report — reached through the
1044
+ // same code, which is the whole point of the operator script calling this
1045
+ // function instead of listing the sweeps a second time. No audit row: an
1046
+ // erasure that did not happen is not a record of one.
1047
+ if (dryRun) {
1048
+ return _extends({
1049
+ ok: false,
1050
+ skippedReason: 'dry-run'
1051
+ }, progress, {
1052
+ hosts: hosts.size,
1053
+ members: members.size
1054
+ });
1055
+ }
1056
+ // The proof of erasure, and now the only record of it: actor, action,
1057
+ // target, the request it fulfilled, the inventory found and what each sweep
1058
+ // destroyed. Ids and counts — never the content (AGL-1443).
1059
+ await firestore.collection('adminAudit').add({
1060
+ actorUid,
1061
+ action: 'org.erased',
1062
+ target: `orgs/${orgId}`,
1063
+ before,
1064
+ after: _extends({
1065
+ requestedAt: requestedMs
1066
+ }, withoutRequest ? {
1067
+ withoutRequest: withoutRequest.reason
1068
+ } : {}, progress),
1069
+ at: FieldValue.serverTimestamp()
1070
+ }).catch(()=>undefined);
1071
+ return _extends({
1072
+ ok: true
1073
+ }, progress, {
1074
+ hosts: hosts.size,
1075
+ members: members.size
1076
+ });
1077
+ }
1078
+ /**
1079
+ * Strip an erased person from the support threads they wrote in, WITHOUT
1080
+ * destroying the threads (AGL-1971).
1081
+ *
1082
+ * The asymmetry with `eraseOrgSupportTickets` is the whole point and is worth
1083
+ * stating rather than assuming. A ticket belongs to an ORG, not to the person
1084
+ * who happened to open it: it is the workspace's support history, other people
1085
+ * on the roster can read it, and it is evidence in a dispute the workspace may
1086
+ * still be having with us. Erasing one member must not delete it — and cannot,
1087
+ * honestly: for that thread Aglyn is the processor and the ORG is the
1088
+ * controller, so deleting it on a member's instruction is acting without the
1089
+ * controller's instruction, which `docs/PRIVACY_REQUESTS.md` §6 refuses in the
1090
+ * mirror-image case. `eraseUser` also already refuses to cascade into
1091
+ * `eraseOrg` for exactly this reason.
1092
+ *
1093
+ * So the narrow correct action is redaction: `authorId` and `authorEmail` go
1094
+ * to `null` and `authorErased: true` marks why, on that person's messages
1095
+ * only. What remains is the message BODY, deliberately — it is the workspace's
1096
+ * record of a conversation with us, and it is the workspace's to delete. An
1097
+ * erasure of the ORG destroys it, which is the path that reaches it.
1098
+ *
1099
+ * **A collection-group query, not a walk over their current memberships.** The
1100
+ * membership walk is the tempting version and it is wrong in a specific way:
1101
+ * `eraseUser` only knows the orgs the person belongs to NOW, so every ticket
1102
+ * they wrote in a workspace they have since left would keep their email — the
1103
+ * residual is invisible and grows with tenure. `messages` is used as a
1104
+ * subcollection name by `supportTickets` and by nothing else in the repo, so
1105
+ * the group is exactly the right set. It needs the COLLECTION_GROUP index
1106
+ * override on `messages.authorId` in `cloud/firebase-firestore.indexes.json`,
1107
+ * added with this change.
1108
+ *
1109
+ * Returns the number of messages redacted, or **`null` when the query could
1110
+ * not run** — a missing index in an environment where it was never deployed.
1111
+ * `null` is not zero and the caller must not flatten it: it means this erasure
1112
+ * did not reach the support messages at all, and an erasure that quietly
1113
+ * reports success for a step it skipped is the failure this whole file is
1114
+ * organised against. It is reported in the result and in the audit row rather
1115
+ * than thrown, because the account deletion itself must still complete.
1116
+ */ async function redactUserSupportMessages(uid) {
1117
+ if (!uid) return 0;
1118
+ const firestore = firebaseAdmin.app().firestore();
1119
+ let rows;
1120
+ try {
1121
+ rows = await firestore.collectionGroup('messages').where('authorId', '==', uid).get();
1122
+ } catch (error) {
1123
+ console.error(`eraseUser: support message redaction could not run for ${uid}`, error);
1124
+ return null;
1125
+ }
1126
+ for(let index = 0; index < rows.docs.length; index += 400){
1127
+ const batch = firestore.batch();
1128
+ for (const doc of rows.docs.slice(index, index + 400)){
1129
+ batch.set(doc.ref, {
1130
+ authorId: null,
1131
+ authorEmail: null,
1132
+ authorErased: true
1133
+ }, {
1134
+ merge: true
1135
+ });
1136
+ }
1137
+ await batch.commit();
1138
+ }
1139
+ return rows.size;
1140
+ }
1141
+ /**
1142
+ * Which orgs block erasing `uid` (AGL-1140).
1143
+ *
1144
+ * Pure so the policy can be tested without Firestore, because the policy is
1145
+ * the part worth arguing about — the deletion itself is mechanical.
1146
+ *
1147
+ * **Owning an org blocks erasure, always.** Not only when it bills, and not
1148
+ * only when other members would be stranded. The alternative — cascading
1149
+ * into `eraseOrg` — deletes a workspace, its sites and its data as a side
1150
+ * effect of someone closing a personal account, and no consent given to the
1151
+ * second act was given to the first. Blocking is recoverable; a cascade is
1152
+ * not.
1153
+ *
1154
+ * A live subscription and stranded members are reported rather than gating,
1155
+ * so the message can say WHY this org needs attention instead of a bare
1156
+ * refusal — "transfer ownership" is useless advice if you do not know which
1157
+ * of eleven workspaces is the problem.
1158
+ */ export function userErasureBlockers(uid, orgs) {
1159
+ return orgs.filter((org)=>org.ownerUid === uid).map((org)=>({
1160
+ orgId: org.orgId,
1161
+ orgName: org.orgName,
1162
+ hasLiveSubscription: org.hasLiveSubscription,
1163
+ // The owner themselves is a member; anyone beyond that is stranded.
1164
+ otherMembers: Math.max(0, org.memberCount - 1)
1165
+ }));
1166
+ }
1167
+ /**
1168
+ * Which of an account's addresses a second account is also known to hold.
1169
+ *
1170
+ * Pure, and separate from the sweep, for the same reason `userErasureBlockers`
1171
+ * is: the policy is the arguable part. The mechanical part is deleting rows.
1172
+ *
1173
+ * ⚠️ `shared` is evidence in ONE direction. True proves a second holder;
1174
+ * false only means none was found, because no lookup exists for an account
1175
+ * holding an address through a federated provider. So this finds every case
1176
+ * we can SEE and cannot promise there is no other — which is exactly why the
1177
+ * sweep still tombstones every address it erases.
1178
+ */ export function sharedAddressBlockers(set) {
1179
+ return set.addresses.filter((entry)=>entry.shared === true).map((entry)=>({
1180
+ key: entry.key,
1181
+ sources: [
1182
+ ...entry.sources
1183
+ ]
1184
+ }));
1185
+ }
1186
+ /**
1187
+ * Permanently erase a person's account (AGL-1140).
1188
+ *
1189
+ * Nothing did this before: `eraseHost`, `eraseSubtree` and `eraseOrg` existed
1190
+ * and no path anywhere deleted a `users/{uid}`. That was survivable while the
1191
+ * doc held a name and a phone; AGL-1133 added a postal address, and "we have
1192
+ * no mechanism to delete it" is a bad answer to an erasure request.
1193
+ *
1194
+ * `eraseOrg` is deliberately NOT the model here. An org is a workspace with
1195
+ * one owner; a person belongs to many orgs, so erasing them must not take
1196
+ * anything with it that outlives them.
1197
+ *
1198
+ * Order matters:
1199
+ * 1. Refuse if they own an org — see `userErasureBlockers`.
1200
+ * 1b. Refuse if a second account holds one of their addresses — see
1201
+ * `sharedAddressBlockers`. Both refusals come before any delete, so a
1202
+ * blocked erasure leaves the account exactly as it was.
1203
+ * 2. Remove their membership from every org they belong to, so no roster
1204
+ * keeps their email and no host projection keeps granting them access.
1205
+ * This runs BEFORE the profile delete: a half-erased account that still
1206
+ * appears on a roster is worse than one not yet started.
1207
+ * 3. Delete the `users/{uid}` subtree, the avatar, the world-readable
1208
+ * `profiles/{uid}` public identity (AGL-1970), redact their name and
1209
+ * email off every support message they wrote (AGL-1971), then the auth
1210
+ * record last — once that is gone there is no uid to retry with.
1211
+ */ export async function eraseUser(uid) {
1212
+ var _ref;
1213
+ const firestore = firebaseAdmin.app().firestore();
1214
+ const userRef = firestore.collection('users').doc(uid);
1215
+ const snapshot = await userRef.get();
1216
+ const membership = await userRef.collection('orgs').get();
1217
+ // An absent profile doc is not an absent account — the doc is only born on
1218
+ // first save (AGL-1127), so the auth record and the org memberships can
1219
+ // outlive it. Treat "nothing anywhere" as not-found, not "no doc".
1220
+ if (!snapshot.exists && membership.empty) {
1221
+ const record = await findUserByUidAcrossPools(uid).catch(()=>null);
1222
+ if (!record) return {
1223
+ ok: false,
1224
+ skippedReason: 'not-found'
1225
+ };
1226
+ }
1227
+ const candidates = [];
1228
+ for (const row of membership.docs){
1229
+ var _org_get, _org_get1, _members_data_count;
1230
+ const orgRef = firestore.collection('orgs').doc(row.id);
1231
+ const [org, members] = await Promise.all([
1232
+ orgRef.get(),
1233
+ orgRef.collection('members').count().get()
1234
+ ]);
1235
+ if (!org.exists) continue;
1236
+ candidates.push({
1237
+ orgId: row.id,
1238
+ orgName: String((_org_get = org.get('name')) != null ? _org_get : row.id),
1239
+ ownerUid: (_org_get1 = org.get('ownerUid')) != null ? _org_get1 : null,
1240
+ hasLiveSubscription: isBillingSubscription(org.data()),
1241
+ memberCount: Number((_members_data_count = members.data().count) != null ? _members_data_count : 0)
1242
+ });
1243
+ }
1244
+ const blockers = userErasureBlockers(uid, candidates);
1245
+ if (blockers.length) {
1246
+ return {
1247
+ ok: false,
1248
+ skippedReason: 'owns-orgs',
1249
+ blockers
1250
+ };
1251
+ }
1252
+ // Inventory BEFORE anything is deleted. Taken after the membership sweep
1253
+ // below, this under-reported: `removeOrgMember` empties `users/{uid}/orgs`,
1254
+ // and Firestore does not list a collection with no documents in it — so a
1255
+ // live run erased three subcollections and the audit record named two.
1256
+ // An erasure audit trail that understates what it destroyed is the one
1257
+ // record that has to be right. Measured 2026-08-01 (AGL-1140).
1258
+ const subcollections = (await userRef.listCollections()).map((c)=>c.id);
1259
+ /*
1260
+ * EVERY ADDRESS, RESOLVED BEFORE ANY OF THEM IS DESTROYED.
1261
+ *
1262
+ * Two of the three sources are erased by this function itself:
1263
+ * `users/{uid}/emails` goes with the `recursiveDelete` below, and the Auth
1264
+ * record — the only place the primary lives — goes at the very end. Reading
1265
+ * them afterwards is reading nothing, and an erasure that resolves an empty
1266
+ * address set sweeps nothing while reporting success. That is the exact
1267
+ * shape of a spec passing because its fixture never reached the code, and
1268
+ * here it would be a live erasure reporting a completeness it does not have.
1269
+ *
1270
+ * `detectShared` because the SHARED case changes what the caller is told,
1271
+ * not what is destroyed — see `eraseEmailDeliveriesForAddresses`.
1272
+ */ const pooledForAddresses = await findUserByUidAcrossPools(uid).catch(()=>null);
1273
+ const addressSet = await resolveAccountAddresses({
1274
+ uid,
1275
+ record: (_ref = pooledForAddresses == null ? void 0 : pooledForAddresses.record) != null ? _ref : null,
1276
+ detectShared: true,
1277
+ firestore
1278
+ }).catch(()=>({
1279
+ uid,
1280
+ primary: null,
1281
+ addresses: [],
1282
+ incomplete: true
1283
+ }));
1284
+ /*
1285
+ * A SECOND ACCOUNT HOLDS ONE OF THESE ADDRESSES — REFUSE, HERE.
1286
+ *
1287
+ * The delivery log is filed under the address, so an address two accounts
1288
+ * hold has one set of rows answering "what did you send me" for two account
1289
+ * records. Whether that is one human with two accounts (the ordinary live
1290
+ * shape — a federated provider address that is another account's primary)
1291
+ * or a genuinely shared role mailbox like `billing@` decides whether
1292
+ * erasing those rows honours a request or destroys a stranger's history,
1293
+ * and NOTHING readable here distinguishes them. The data records that two
1294
+ * accounts name one address; it records nothing about the humans.
1295
+ *
1296
+ * Both automatic answers are unacceptable, and they fail differently:
1297
+ * erasing the second party's mail cannot be undone, while leaving it and
1298
+ * reporting the erasure complete is the gap this whole area was opened to
1299
+ * close. So neither is chosen — the erasure stops and says why.
1300
+ *
1301
+ * BEFORE the membership sweep, deliberately. A refusal raised after the
1302
+ * account is half-deleted is not a refusal, and the uid is the only handle
1303
+ * a human has to act on this: erase the auth record and there is no longer
1304
+ * anything to look at, no way to see which address was contested, and no
1305
+ * way to run the erasure once the question is settled. Refusing while the
1306
+ * account is whole is the only version of this that stays reversible.
1307
+ *
1308
+ * The remedy is ordinary staff work, not a special path: decide whether the
1309
+ * accounts are one person, detach the address from this one or merge them,
1310
+ * then run the erasure again. `/admin/users/[uid]` already lists this
1311
+ * account's addresses and marks the shared ones.
1312
+ */ const contested = sharedAddressBlockers(addressSet);
1313
+ if (contested.length) {
1314
+ return {
1315
+ ok: false,
1316
+ skippedReason: 'shared-address',
1317
+ sharedAddresses: contested
1318
+ };
1319
+ }
1320
+ // Memberships first — a roster row carries their email and a host
1321
+ // projection carries their access, and both outlive the profile doc.
1322
+ for (const candidate of candidates){
1323
+ await removeOrgMember(candidate.orgId, uid).catch((error)=>{
1324
+ console.error(`eraseUser: membership cleanup failed for ${candidate.orgId}`, error);
1325
+ });
1326
+ }
1327
+ // What the plugins keep about the person (AGL-2939). A document under an
1328
+ // org keyed by the uid is the shape no recursive delete on this path can
1329
+ // see — the same shape as the delivery log below. Each plugin's eraser
1330
+ // gets every workspace the person was in, because the memberships above
1331
+ // are already gone; one that fails is recorded as `null` and the erasure
1332
+ // goes on.
1333
+ const plugins = await runPluginUserErasers({
1334
+ uid,
1335
+ orgIds: candidates.map((candidate)=>candidate.orgId)
1336
+ });
1337
+ // The avatar outlives the doc otherwise.
1338
+ let photo = false;
1339
+ try {
1340
+ await storageBucket().deleteFiles({
1341
+ prefix: `users/${uid}/`
1342
+ });
1343
+ photo = true;
1344
+ } catch (error) {
1345
+ console.error(`eraseUser: storage cleanup failed for ${uid}`, error);
1346
+ }
1347
+ await firestore.recursiveDelete(userRef);
1348
+ // `profiles/{uid}` — the person's PUBLIC identity (AGL-1970).
1349
+ //
1350
+ // A separate top-level collection keyed by the uid AS THE DOCUMENT ID, so
1351
+ // `recursiveDelete(users/{uid})` above never reaches it and no reader
1352
+ // scanning this function for a sweep list is prompted to notice it. It is
1353
+ // `allow read: if true` in the rules and carries `handle`, `displayName`,
1354
+ // and `stripeAccountId`/`stripeChargesEnabled` — a Stripe Connect payout
1355
+ // destination written server-side after KYC. An erased person therefore
1356
+ // stayed publicly listed with a live payment-account identifier attached,
1357
+ // which Privacy Policy §5 ("a genuine recursive delete… we keep no copy of
1358
+ // the erased content") says in terms does not happen.
1359
+ //
1360
+ // No tombstone, unlike the org's publisher profile: the only reader of the
1361
+ // display name is the support forum's author rendering, and a forum post
1362
+ // losing its author name is the intended outcome of erasing its author.
1363
+ //
1364
+ // `recursiveDelete` rather than `delete` — nothing writes a subcollection
1365
+ // under a profile today, and a document delete would silently orphan the
1366
+ // first thing that does.
1367
+ let profile = false;
1368
+ try {
1369
+ const profileRef = firestore.collection('profiles').doc(uid);
1370
+ profile = (await profileRef.get()).exists;
1371
+ await firestore.recursiveDelete(profileRef);
1372
+ } catch (error) {
1373
+ console.error(`eraseUser: public profile delete failed for ${uid}`, error);
1374
+ }
1375
+ // Their name and email off every support message they wrote, in any
1376
+ // workspace, past or present (AGL-1971). The threads are the orgs' and
1377
+ // survive — see `redactUserSupportMessages`.
1378
+ const supportMessagesRedacted = await redactUserSupportMessages(uid);
1379
+ // Auth record LAST: once it is gone there is no uid to retry with, and
1380
+ // `authForPool` is required because a project-level delete cannot see an
1381
+ // SSO account at all (AGL-1122).
1382
+ //
1383
+ // The lookup is hoisted above the delete because the DELIVERY LOG is filed
1384
+ // under the ADDRESS, not the uid, and the auth record is the last place the
1385
+ // address exists. Erase it while there is still something to erase it by.
1386
+ let authRecord = false;
1387
+ let emailDeliveries = 0;
1388
+ /** Addresses actually swept — not the same as the ones we hold, on failure. */ let erasedAddressCount = 0;
1389
+ /** Addresses left intact because a second account also holds them. */ let contestedAddressCount = 0;
1390
+ /** Identity-index rows released, one per address that had one. */ let emailIdentityIndex = 0;
1391
+ try {
1392
+ const record = await findUserByUidAcrossPools(uid);
1393
+ if (record) {
1394
+ var _ref1, _ref2, _ref3;
1395
+ /*
1396
+ * `emailDeliveries/{sha256(address)}/messages` — every subject we ever
1397
+ * sent this person, with the times they opened and clicked. Personal
1398
+ * data by any reading of it, and reachable from neither
1399
+ * `recursiveDelete(users/{uid})` nor `profiles/{uid}` because it is
1400
+ * keyed by address in a third top-level collection. The same shape as
1401
+ * the `profiles/{uid}` omission this function already documents above:
1402
+ * a store nothing in the sweep list would prompt a reader to notice.
1403
+ *
1404
+ * EVERY ADDRESS, not the current primary. Passing one address left a
1405
+ * prior or provider-supplied address holding its full history —
1406
+ * recipient, subjects, open and click times — after the request had
1407
+ * been honoured and reported complete.
1408
+ *
1409
+ * Best-effort, like every other step here — a log that survives must
1410
+ * not stop the auth record from going.
1411
+ */ const sweep = await eraseEmailDeliveriesForAddresses(addressSet.addresses, firestore).catch(()=>null);
1412
+ emailDeliveries = (_ref1 = sweep == null ? void 0 : sweep.removed) != null ? _ref1 : 0;
1413
+ erasedAddressCount = (_ref2 = sweep == null ? void 0 : sweep.addresses.length) != null ? _ref2 : 0;
1414
+ contestedAddressCount = (_ref3 = sweep == null ? void 0 : sweep.contestedAddresses.length) != null ? _ref3 : 0;
1415
+ /*
1416
+ * `emailIdentityIndex/{address}` — the SAME omission one collection
1417
+ * over.
1418
+ *
1419
+ * A top-level collection keyed by the address IN THE CLEAR, holding
1420
+ * `{uid, address, claimedAt}`. `recursiveDelete(users/{uid})` cannot
1421
+ * see it for exactly the reason it cannot see the delivery log, so an
1422
+ * erased account left a readable address still pointing at its uid.
1423
+ *
1424
+ * Released rather than tombstoned, unlike the delivery log: this is a
1425
+ * uniqueness claim, and `removeAccountEmail` already argues that
1426
+ * holding one after the address is gone burns the address for whoever
1427
+ * might legitimately want it next.
1428
+ *
1429
+ * ⛔ Only rows this uid actually owns. The index is the guard against
1430
+ * two accounts holding one address, and deleting another account's
1431
+ * claim would hand their sign-in identifier to the next taker.
1432
+ */ for (const entry of addressSet.addresses){
1433
+ try {
1434
+ const ref = firestore.collection(EMAIL_IDENTITY_INDEX_COLLECTION).doc(entry.address);
1435
+ const indexed = await ref.get();
1436
+ if (indexed.exists && indexed.get('uid') === uid) {
1437
+ await ref.delete();
1438
+ emailIdentityIndex += 1;
1439
+ }
1440
+ } catch (error) {
1441
+ console.error(`eraseUser: identity index release failed for ${uid}`, error);
1442
+ }
1443
+ }
1444
+ await authForPool(record.tenantId).deleteUser(uid);
1445
+ authRecord = true;
1446
+ }
1447
+ } catch (error) {
1448
+ console.error(`eraseUser: auth record delete failed for ${uid}`, error);
1449
+ }
1450
+ /*
1451
+ * WHAT THE ADDRESS SWEEP ACTUALLY COVERED.
1452
+ *
1453
+ * Counts, never the addresses themselves: this collection is readable by
1454
+ * any staff role and `maskEmailAddress` exists because an audit row holding
1455
+ * a readable address made `adminAudit` the leakier of the two stores for
1456
+ * the same data. An erasure record listing the addresses it destroyed would
1457
+ * be the sharpest version of that — it would preserve, in the clear, the
1458
+ * exact set the request existed to remove.
1459
+ *
1460
+ * `addressesIncomplete` is the one that matters to a reader. It says a
1461
+ * source could not be read, so the sweep may have missed an address the
1462
+ * account held — the difference between "erased everywhere" and "erased
1463
+ * everywhere we could see", which is the difference a regulator asks about.
1464
+ */ const addressSweep = {
1465
+ resolved: addressSet.addresses.length,
1466
+ erased: erasedAddressCount,
1467
+ contested: contestedAddressCount,
1468
+ incomplete: addressSet.incomplete
1469
+ };
1470
+ await firestore.collection('adminAudit').add({
1471
+ actorUid: 'system:erase-user',
1472
+ action: 'user.erased',
1473
+ target: `users/${uid}`,
1474
+ before: {
1475
+ orgs: candidates.length
1476
+ },
1477
+ after: {
1478
+ subcollections,
1479
+ authRecord,
1480
+ photo,
1481
+ profile,
1482
+ supportMessagesRedacted,
1483
+ plugins,
1484
+ emailDeliveries,
1485
+ emailIdentityIndex,
1486
+ addressSweep
1487
+ },
1488
+ at: FieldValue.serverTimestamp()
1489
+ }).catch(()=>undefined);
1490
+ return {
1491
+ ok: true,
1492
+ deleted: {
1493
+ subcollections,
1494
+ authRecord,
1495
+ photo,
1496
+ profile,
1497
+ supportMessagesRedacted,
1498
+ plugins,
1499
+ emailDeliveries,
1500
+ emailIdentityIndex,
1501
+ addressSweep
1502
+ }
1503
+ };
1504
+ }
1505
+
1506
+ //# sourceMappingURL=erase.js.map