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

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 (99) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/api-v1-description.d.ts +112 -0
  3. package/src/lib/server/api-v1-description.js +126 -0
  4. package/src/lib/server/api-v1-description.js.map +1 -0
  5. package/src/lib/server/api-v1-kit.d.ts +156 -0
  6. package/src/lib/server/api-v1-kit.js +191 -0
  7. package/src/lib/server/api-v1-kit.js.map +1 -0
  8. package/src/lib/server/api-v1-resources.d.ts +144 -0
  9. package/src/lib/server/api-v1-resources.js +126 -0
  10. package/src/lib/server/api-v1-resources.js.map +1 -0
  11. package/src/lib/server/consent-group-change.d.ts +2 -1
  12. package/src/lib/server/consent-group-change.js +41 -16
  13. package/src/lib/server/consent-group-change.js.map +1 -1
  14. package/src/lib/server/contact-email-index.d.ts +22 -0
  15. package/src/lib/server/contact-email-index.js +108 -0
  16. package/src/lib/server/contact-email-index.js.map +1 -1
  17. package/src/lib/server/crm-email-activity.d.ts +1 -1
  18. package/src/lib/server/crm-email-activity.js +1 -1
  19. package/src/lib/server/crm-email-activity.js.map +1 -1
  20. package/src/lib/server/crm-inbound-email.d.ts +23 -0
  21. package/src/lib/server/crm-inbound-email.js +1 -16
  22. package/src/lib/server/crm-inbound-email.js.map +1 -1
  23. package/src/lib/server/crm-records.d.ts +9 -1
  24. package/src/lib/server/crm-records.js +19 -2
  25. package/src/lib/server/crm-records.js.map +1 -1
  26. package/src/lib/server/cron-auth.d.ts +44 -0
  27. package/src/lib/server/cron-auth.js +76 -0
  28. package/src/lib/server/cron-auth.js.map +1 -0
  29. package/src/lib/server/dataset-live-pages.js +6 -6
  30. package/src/lib/server/dataset-live-pages.js.map +1 -1
  31. package/src/lib/server/duplicate-resource.d.ts +26 -0
  32. package/src/lib/server/duplicate-resource.js +47 -25
  33. package/src/lib/server/duplicate-resource.js.map +1 -1
  34. package/src/lib/server/dynamic-list-materialize.js +4 -1
  35. package/src/lib/server/dynamic-list-materialize.js.map +1 -1
  36. package/src/lib/server/email-suppression.d.ts +11 -0
  37. package/src/lib/server/email-suppression.js +36 -1
  38. package/src/lib/server/email-suppression.js.map +1 -1
  39. package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
  40. package/src/lib/server/email-unsubscribe-link.js +7 -7
  41. package/src/lib/server/email-unsubscribe-link.js.map +1 -1
  42. package/src/lib/server/erase.d.ts +19 -33
  43. package/src/lib/server/erase.js +37 -162
  44. package/src/lib/server/erase.js.map +1 -1
  45. package/src/lib/server/host-email-tokens.js +1 -1
  46. package/src/lib/server/host-email-tokens.js.map +1 -1
  47. package/src/lib/server/host-memberships.js +3 -3
  48. package/src/lib/server/host-memberships.js.map +1 -1
  49. package/src/lib/server/member-email-aliases.d.ts +1 -1
  50. package/src/lib/server/member-email-aliases.js +1 -1
  51. package/src/lib/server/member-email-aliases.js.map +1 -1
  52. package/src/lib/server/new-account-announcement.d.ts +62 -0
  53. package/src/lib/server/new-account-announcement.js +77 -0
  54. package/src/lib/server/new-account-announcement.js.map +1 -0
  55. package/src/lib/server/notifications.d.ts +8 -2
  56. package/src/lib/server/notifications.js +11 -3
  57. package/src/lib/server/notifications.js.map +1 -1
  58. package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
  59. package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +1 -1
  60. package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
  61. package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
  62. package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
  63. package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
  64. package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
  65. package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
  66. package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
  67. package/src/lib/server/payment-provider.d.ts +173 -0
  68. package/src/lib/server/payment-provider.js +70 -0
  69. package/src/lib/server/payment-provider.js.map +1 -0
  70. package/src/lib/server/personal-data-export.js +1 -1
  71. package/src/lib/server/personal-data-export.js.map +1 -1
  72. package/src/lib/server/rate-limit-store.js +13 -3
  73. package/src/lib/server/rate-limit-store.js.map +1 -1
  74. package/src/lib/server/realm-plugins.js +8 -3
  75. package/src/lib/server/realm-plugins.js.map +1 -1
  76. package/src/lib/server/retained-refusals.d.ts +142 -0
  77. package/src/lib/server/retained-refusals.js +325 -0
  78. package/src/lib/server/retained-refusals.js.map +1 -0
  79. package/src/lib/server/test-firestore-queries.d.ts +7 -0
  80. package/src/lib/server/test-firestore-queries.js +70 -27
  81. package/src/lib/server/test-firestore-queries.js.map +1 -1
  82. package/src/lib/server/upsert-contact.d.ts +46 -1
  83. package/src/lib/server/upsert-contact.js +85 -18
  84. package/src/lib/server/upsert-contact.js.map +1 -1
  85. package/src/tenancy.d.ts +4 -5
  86. package/src/tenancy.js +11 -6
  87. package/src/tenancy.js.map +1 -1
  88. package/src/lib/server/connect-account-status.js.map +0 -1
  89. package/src/lib/server/connect-payout-failure.js.map +0 -1
  90. package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
  91. package/src/lib/server/contact-lifecycle-floor.js +0 -91
  92. package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
  93. package/src/lib/server/contact-merge.d.ts +0 -55
  94. package/src/lib/server/contact-merge.js +0 -288
  95. package/src/lib/server/contact-merge.js.map +0 -1
  96. package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
  97. package/src/lib/server/form-dataset-binding-token.js +0 -107
  98. package/src/lib/server/form-dataset-binding-token.js.map +0 -1
  99. package/src/lib/server/stripe-account-mode.js.map +0 -1
@@ -86,15 +86,6 @@ export declare function eraseHost(hostId: string, options?: EraseHostOptions): P
86
86
  * Authorization belongs to the caller — this only walks the tree.
87
87
  */
88
88
  export declare function eraseSubtree(path: readonly [string, string, string, string]): Promise<void>;
89
- /**
90
- * What an erasure did with `publisherProfiles/{orgId}` (AGL-1970).
91
- *
92
- * Three outcomes rather than a boolean, because the third one is a decision
93
- * the audit row has to be able to state: a listing the erased org published
94
- * can outlive it, and a listing whose publisher document is simply gone is
95
- * unattributable in a way nobody chose.
96
- */
97
- export type PublisherProfileDisposition = 'absent' | 'deleted' | 'tombstoned';
98
89
  /**
99
90
  * The two things an OPERATOR needs that a served caller does not (AGL-1481).
100
91
  *
@@ -180,10 +171,13 @@ export interface EraseOrgResult {
180
171
  * each eraser's count of what it would do.
181
172
  */
182
173
  plugins?: Record<string, PluginOrgErasureReport | null>;
183
- /** Outreach mailbox grants destroyed (AGL-2974) — outside the org path. */
184
- outreachMailboxCredentials?: number;
185
- /** Outreach short tracking links destroyed (AGL-3297). */
186
- outreachLinks?: number;
174
+ /**
175
+ * Rows destroyed from each plugin's top-level collection keyed by the org
176
+ * (AGL-3080), keyed by collection: a connected mailbox's grant (AGL-2974),
177
+ * a sequence email's tracking links (AGL-3297). Outside the org path, and
178
+ * declared by their owners rather than named here.
179
+ */
180
+ pluginCollections?: Record<string, number>;
187
181
  /** Public SSO routing docs destroyed (AGL-1448) — outside the org path. */
188
182
  ssoDomains?: number;
189
183
  /** Custom console domains released (AGL-1448) — outside the org path. */
@@ -209,17 +203,6 @@ export interface EraseOrgResult {
209
203
  * same number as one that reached it and found nothing.
210
204
  */
211
205
  hostMemberships?: number | null;
212
- /** Marketplace handle reservations released (AGL-1970) — id-keyed, public. */
213
- publisherHandles?: number;
214
- /** What became of the org's public publisher profile (AGL-1970). */
215
- publisherProfile?: PublisherProfileDisposition;
216
- /**
217
- * Marketplace listings that OUTLIVE this erasure and still name the org
218
- * (AGL-1970/AGL-1448 Tier 3). Reported rather than deleted: an erasure that
219
- * leaves something standing has to say so, and this is the number that makes
220
- * the parked listing-survival decision cost something measurable.
221
- */
222
- listingsRetained?: number;
223
206
  /**
224
207
  * Support threads destroyed, each with its `messages` subtree (AGL-1971) —
225
208
  * the highest-PII-density collection outside the org path.
@@ -262,15 +245,18 @@ export interface EraseOrgResult {
262
245
  * 2. Nothing is written. This step used to persist a final JSON export of
263
246
  * the org and host trees to `erasures/{orgId}/…` and abort if it could
264
247
  * not — see the note below for why it is gone (AGL-1443).
265
- * 3. Revoke the org's API credentials (AGL-1444), its public routing — SSO
266
- * domains and custom console domains (AGL-1448) — and its public
267
- * marketplace identity: the publisher profile and every handle it
268
- * reserved (AGL-1970); and destroy the OAuth grants behind its reps'
269
- * Outreach mailboxes (AGL-2974). All of them are top-level collections
270
- * keyed by something other than a path under the org, so the org-tree
271
- * delete cannot reach them; doing them before the content delete also
272
- * closes the mid-erasure window, in which a credential, a domain or a
273
- * public publisher page still resolves to a half-deleted workspace.
248
+ * 3. The plugins' erasers first (AGL-2978), while everything they may need
249
+ * to act through still exists — a REQUIRED one (the marketplace's, which
250
+ * erases the org's public publisher identity, AGL-1970) fails the step
251
+ * rather than recording its share as undone. Then revoke the org's API
252
+ * credentials (AGL-1444), its public routing — SSO domains and custom
253
+ * console domains (AGL-1448) — and sweep every plugin collection keyed
254
+ * by the org that its owner declared (a connected mailbox's OAuth grant,
255
+ * AGL-2974). All of them are top-level collections keyed by something
256
+ * other than a path under the org, so the org-tree delete cannot reach
257
+ * them; doing them before the content delete also closes the mid-erasure
258
+ * window, in which a credential, a domain or a public publisher page
259
+ * still resolves to a half-deleted workspace.
274
260
  * 4. Delete each host (eraseHost), org-level Storage, the Stripe customer
275
261
  * AND its local reverse index, member back-references, then the org tree
276
262
  * and every slug the org ever held — tombstones included (AGL-1448).
@@ -26,7 +26,7 @@ import { resolveAccountAddresses } from "./account-addresses.js";
26
26
  import { EMAIL_IDENTITY_INDEX_COLLECTION } from "./account-emails.js";
27
27
  import { removeOrgMember } from "./organizations.js";
28
28
  import { runPluginUserErasers } from "@aglyn/aglyn/plugin-manager/plugin-user-erasure";
29
- import { runPluginOrgErasers } from "@aglyn/aglyn/plugin-manager/plugin-org-erasure";
29
+ import { PLUGIN_ORG_KEYED_COLLECTIONS, runPluginOrgErasers } from "@aglyn/aglyn/plugin-manager/plugin-org-erasure";
30
30
  import { isBillingSubscription } from "@aglyn/aglyn/server";
31
31
  // The module path rather than the barrel: this module's specs substitute the
32
32
  // barrel, and which sites a consent group still needs is the rule itself.
@@ -55,31 +55,6 @@ import { addAdminAudit } from "./admin-audit-write.js";
55
55
  * emulator spec asserts the two spellings still agree by reading that file,
56
56
  * so a rename fails a test rather than silently un-wiring this sweep.
57
57
  */ const SUPPLIER_DELIVERY_COLLECTION = 'supplierDeliveries';
58
- /**
59
- * `outreachMailboxCredentials` — the OAuth grant behind each mailbox a rep
60
- * connects to Outreach, keyed by the mailbox id and carrying `orgId` as a
61
- * FIELD.
62
- *
63
- * The literal rather than an import, for `SUPPLIER_DELIVERY_COLLECTION`'s
64
- * reason: the collection belongs to `libs/plugins/outreach`
65
- * (`OUTREACH_COLLECTIONS.mailboxCredentials` in
66
- * `src/lib/model/outreach.types.ts`), and this `scope:data` library may not
67
- * import a plugin. The plugin's own spec asserts the two spellings agree, so
68
- * a rename fails a test rather than silently un-wiring the sweep.
69
- *
70
- * A static sweep here, not a hook the plugin registers, because an erasure
71
- * cannot rely on the plugin being present. An org that switched Outreach off
72
- * still holds the tokens its reps granted, and the process running the
73
- * erasure — the cron, the operator script, a replay — may never have loaded
74
- * the plugin's bundle at all. A credential's lifetime cannot depend on which
75
- * bundles an erasing process happened to load.
76
- */ const OUTREACH_MAILBOX_CREDENTIALS_COLLECTION = 'outreachMailboxCredentials';
77
- /**
78
- * `outreachLinks` — Outreach's short tracking links (AGL-3297), one document
79
- * per link in a sent sequence email, keyed by the link's random id and
80
- * carrying `orgId` as a FIELD. A literal for the reason above, held to the
81
- * plugin's spelling by the same spec.
82
- */ const OUTREACH_LINKS_COLLECTION = 'outreachLinks';
83
58
  /**
84
59
  * Destroy the site's DEAD-LETTERED supplier deliveries (AGL-1448).
85
60
  *
@@ -331,27 +306,21 @@ export async function eraseHost(hostId, options = {}) {
331
306
  return deleteDocsByOrgId('apiKeys', orgId, dryRun);
332
307
  }
333
308
  /**
334
- * Delete every Outreach mailbox credential the org holds (AGL-2974).
335
- *
336
- * `outreachMailboxCredentials` is a TOP-LEVEL collection keyed by the mailbox
337
- * id and carrying `orgId` as a FIELD, so `recursiveDelete(orgRef)` cannot see
338
- * it — the blindness `eraseOrgApiKeys` answers, on a more dangerous document.
339
- * Left standing, the OAuth grant a rep gave for their own mailbox would
340
- * outlive the workspace it was given to: tokens that can still send as that
341
- * person, held for an organization that no longer exists and that nobody can
342
- * ask to disconnect them.
343
- *
344
- * This destroys the platform's copy of the grant; it does not call the
345
- * provider's revocation endpoint, which needs the plugin's provider adapter.
346
- * With the tokens gone the grant has nothing left to act through.
347
- *
348
- * Bounded by the `orgId` field, never a collection sweep: this collection
349
- * holds every other workspace's connected mailboxes too.
350
- *
351
- * Returns the number destroyed, for the audit row — an erasure trail that
352
- * understates what it removed is the one record that has to be right.
353
- */ async function eraseOrgOutreachMailboxCredentials(orgId, dryRun = false) {
354
- return deleteDocsByOrgId(OUTREACH_MAILBOX_CREDENTIALS_COLLECTION, orgId, dryRun);
309
+ * Sweep every top-level plugin collection keyed by this org (AGL-3080).
310
+ *
311
+ * The collections are the plugins' own, DECLARED by each owner in
312
+ * `plugins.config.json` (`orgKeyedCollections`) and compiled into core, so the
313
+ * sweep names none of them and runs in every process whether or not the
314
+ * owner is loaded. Sequential and field-bounded like every other org-keyed
315
+ * sweep: each of these collections holds every other workspace's rows too.
316
+ *
317
+ * Returns the count destroyed per collection, for the audit row.
318
+ */ async function erasePluginOrgKeyedCollections(orgId, dryRun = false) {
319
+ const counts = {};
320
+ for (const { name, orgField } of PLUGIN_ORG_KEYED_COLLECTIONS){
321
+ counts[name] = await deleteDocsByOrgId(name, orgId, dryRun, orgField);
322
+ }
323
+ return counts;
355
324
  }
356
325
  /**
357
326
  * Delete every document in a TOP-LEVEL collection that names this org in its
@@ -390,10 +359,10 @@ export async function eraseHost(hostId, options = {}) {
390
359
  * including the tax a `mode: 'stripe'` store collects under AGLYN's own
391
360
  * Texas registration, which Aglyn must be able to account for long after the
392
361
  * merchant has gone.
393
- */ async function deleteDocsByOrgId(collection, orgId, dryRun = false) {
362
+ */ async function deleteDocsByOrgId(collection, orgId, dryRun = false, orgField = 'orgId') {
394
363
  if (!orgId) return 0;
395
364
  const firestore = firebaseAdmin.app().firestore();
396
- const rows = await firestore.collection(collection).where('orgId', '==', orgId).get();
365
+ const rows = await firestore.collection(collection).where(orgField, '==', orgId).get();
397
366
  if (dryRun) return rows.size;
398
367
  for(let index = 0; index < rows.docs.length; index += 400){
399
368
  const batch = firestore.batch();
@@ -645,99 +614,6 @@ export async function eraseHost(hostId, options = {}) {
645
614
  return null;
646
615
  }
647
616
  }
648
- /**
649
- * Drop the org's PUBLIC marketplace identity — the profile and every handle
650
- * it reserved (AGL-1970).
651
- *
652
- * Both survived every erasure until now, and both are `allow read: if true`
653
- * (`cloud/firebase-firestore.rules`, `match /publisherProfiles/{orgId}`). This
654
- * is the AGL-1448 shape with one twist that is the whole reason it was missed:
655
- * `publisherProfiles` is keyed by the org id **as the document id**, so it is
656
- * invisible to `deleteDocsByOrgId` *and* to a reader scanning this file for
657
- * the field-keyed sweep list. Nothing prompts you to notice it.
658
- *
659
- * What survived is not cosmetic. `stripeAccountId` is a **payout
660
- * destination** — written server-side only after Connect onboarding/KYC and
661
- * trusted by every checkout path — so an erased org stayed world-readable with
662
- * a live payment-account identifier attached to a dead identity. That is the
663
- * `stripeCustomers` correlation AGL-1448 removed, pointing the other way.
664
- *
665
- * `publisherHandles` is the easy half and goes unconditionally: it carries
666
- * `orgId` as a FIELD, so `deleteDocsByOrgId` fits exactly, and it is a **live
667
- * reservation** — `claimPublisherHandle` refuses a handle another org's row
668
- * names, so a ghost holds a marketplace name against a real customer with no
669
- * way to appeal. That is the `ssoDomains` argument verbatim, and absence is
670
- * again the only state that both stops the read and releases the name. Rename
671
- * tombstones (`{ orgId, movedTo }`) carry the same `orgId` and go with it, and
672
- * the field bound is what makes that safe: re-claiming a tombstone FULL-
673
- * REPLACES it with the new owner's `{ orgId }`, so a handle this org renamed
674
- * away from and somebody else has since taken does not match the query.
675
- *
676
- * **The profile is the half with a genuine tension, and it is resolved here
677
- * rather than deferred.** `marketplaceListings` outlives an erasure — that is
678
- * AGL-1448's parked Tier 3 product decision, an erased org's listing being
679
- * something buyers paid for — so deleting the publisher document outright can
680
- * leave a listing attributed to nothing. So:
681
- *
682
- * - **No surviving listing** → `recursiveDelete` the profile. `deleted`.
683
- * `recursiveDelete` and not `delete`: `publish-plugin` keeps its daily
684
- * publish-rate window at `publisherProfiles/{orgId}/meta/publishWindow`,
685
- * and a document delete would orphan it under a path with no owner.
686
- * - **A listing survives** → the same `recursiveDelete`, then a minimal
687
- * `{ erased: true, erasedAt }` in its place. `tombstoned`. The tombstone
688
- * carries no `handle`, no `displayName`, no `bio`/`avatarUrl`/`website`,
689
- * no `publisherAgreement` and — the point — no `stripeAccountId`. It is
690
- * "an internal record that the erasure happened", which is exactly what
691
- * the Privacy Policy §5 sentence reserves; every byte the sentence calls
692
- * content is gone either way. Readers already handle it: with no `handle`,
693
- * `resolvePublisherProfile` returns `null`, which is the pre-existing
694
- * "this org has no profile" path.
695
- *
696
- * The count of surviving listings is returned either way, so an erasure that
697
- * left something standing SAYS SO in its audit row instead of reporting a
698
- * clean success — the Tier 3 decision is still open, and an erasure is the
699
- * one place its cost is measurable.
700
- */ async function eraseOrgPublisherIdentity(orgId, dryRun = false) {
701
- const firestore = firebaseAdmin.app().firestore();
702
- const handles = await deleteDocsByOrgId('publisherHandles', orgId, dryRun);
703
- const profileRef = firestore.collection('publisherProfiles').doc(orgId);
704
- const [profileSnapshot, listings] = await Promise.all([
705
- profileRef.get(),
706
- // `profileId` is the listing's publishing-org id (AGL-652) — the same
707
- // value as this profile's document id, under the older field name.
708
- firestore.collection('marketplaceListings').where('profileId', '==', orgId).get()
709
- ]);
710
- const listingsRetained = listings.size;
711
- if (!profileSnapshot.exists) {
712
- return {
713
- handles,
714
- profile: 'absent',
715
- listingsRetained
716
- };
717
- }
718
- if (dryRun) {
719
- return {
720
- handles,
721
- profile: listingsRetained ? 'tombstoned' : 'deleted',
722
- listingsRetained
723
- };
724
- }
725
- await firestore.recursiveDelete(profileRef);
726
- if (!listingsRetained) return {
727
- handles,
728
- profile: 'deleted',
729
- listingsRetained
730
- };
731
- await profileRef.set({
732
- erased: true,
733
- erasedAt: FieldValue.serverTimestamp()
734
- });
735
- return {
736
- handles,
737
- profile: 'tombstoned',
738
- listingsRetained
739
- };
740
- }
741
617
  /**
742
618
  * Destroy every support ticket the org opened, and the messages under each
743
619
  * (AGL-1971).
@@ -864,15 +740,18 @@ export async function eraseHost(hostId, options = {}) {
864
740
  * 2. Nothing is written. This step used to persist a final JSON export of
865
741
  * the org and host trees to `erasures/{orgId}/…` and abort if it could
866
742
  * not — see the note below for why it is gone (AGL-1443).
867
- * 3. Revoke the org's API credentials (AGL-1444), its public routing — SSO
868
- * domains and custom console domains (AGL-1448) — and its public
869
- * marketplace identity: the publisher profile and every handle it
870
- * reserved (AGL-1970); and destroy the OAuth grants behind its reps'
871
- * Outreach mailboxes (AGL-2974). All of them are top-level collections
872
- * keyed by something other than a path under the org, so the org-tree
873
- * delete cannot reach them; doing them before the content delete also
874
- * closes the mid-erasure window, in which a credential, a domain or a
875
- * public publisher page still resolves to a half-deleted workspace.
743
+ * 3. The plugins' erasers first (AGL-2978), while everything they may need
744
+ * to act through still exists — a REQUIRED one (the marketplace's, which
745
+ * erases the org's public publisher identity, AGL-1970) fails the step
746
+ * rather than recording its share as undone. Then revoke the org's API
747
+ * credentials (AGL-1444), its public routing — SSO domains and custom
748
+ * console domains (AGL-1448) — and sweep every plugin collection keyed
749
+ * by the org that its owner declared (a connected mailbox's OAuth grant,
750
+ * AGL-2974). All of them are top-level collections keyed by something
751
+ * other than a path under the org, so the org-tree delete cannot reach
752
+ * them; doing them before the content delete also closes the mid-erasure
753
+ * window, in which a credential, a domain or a public publisher page
754
+ * still resolves to a half-deleted workspace.
876
755
  * 4. Delete each host (eraseHost), org-level Storage, the Stripe customer
877
756
  * AND its local reverse index, member back-references, then the org tree
878
757
  * and every slug the org ever held — tombstones included (AGL-1448).
@@ -986,20 +865,16 @@ export async function eraseHost(hostId, options = {}) {
986
865
  // pass the org gate and reach a half-deleted workspace. Revoking first
987
866
  // closes that window as well as the permanent one.
988
867
  progress.apiKeys = await eraseOrgApiKeys(orgId, dryRun);
989
- progress.outreachMailboxCredentials = await eraseOrgOutreachMailboxCredentials(orgId, dryRun);
990
- progress.outreachLinks = await deleteDocsByOrgId(OUTREACH_LINKS_COLLECTION, orgId, dryRun);
868
+ // The plugins' top-level collections keyed by this org (AGL-3080) — a
869
+ // connected mailbox's OAuth grant, a sequence email's tracking links.
870
+ // Declared by the owning plugin and swept HERE, statically, not by its
871
+ // eraser: an erasure cannot rely on a plugin being loaded, and a grant
872
+ // that can still send as a person must not outlive the workspace it was
873
+ // given to because an erasing process never loaded that plugin's bundle.
874
+ progress.pluginCollections = await erasePluginOrgKeyedCollections(orgId, dryRun);
991
875
  progress.ssoDomains = await eraseOrgSsoDomains(orgId, dryRun);
992
876
  progress.consoleDomains = await releaseOrgConsoleDomains(orgId, dryRun);
993
877
  progress.apiIdempotency = await eraseOrgIdempotencyKeys(orgId, dryRun);
994
- // The org's PUBLIC marketplace identity (AGL-1970). Here rather than with
995
- // the Stripe step, though it carries a payout id, because `publisherHandles`
996
- // is a live name reservation and belongs with the other reservations this
997
- // step releases — and because the profile is world-readable, so the sooner
998
- // it stops resolving the smaller the mid-erasure window.
999
- const publisher = await eraseOrgPublisherIdentity(orgId, dryRun);
1000
- progress.publisherHandles = publisher.handles;
1001
- progress.publisherProfile = publisher.profile;
1002
- progress.listingsRetained = publisher.listingsRetained;
1003
878
  // Support threads (AGL-1971). Its own step because it is the only sweep
1004
879
  // here that walks a subtree per row, so it is the one most likely to be
1005
880
  // where a large workspace's erasure runs out of time — and a failure that