@aglyn/tenant-data-admin 1.0.0-beta.217 → 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 (122) hide show
  1. package/package.json +9 -9
  2. package/src/lib/server/account-lock-mail.d.ts +74 -0
  3. package/src/lib/server/account-lock-mail.js +139 -0
  4. package/src/lib/server/account-lock-mail.js.map +1 -0
  5. package/src/lib/server/account-mail.d.ts +41 -0
  6. package/src/lib/server/account-mail.js +52 -0
  7. package/src/lib/server/account-mail.js.map +1 -0
  8. package/src/lib/server/api-v1-description.d.ts +112 -0
  9. package/src/lib/server/api-v1-description.js +126 -0
  10. package/src/lib/server/api-v1-description.js.map +1 -0
  11. package/src/lib/server/api-v1-kit.d.ts +156 -0
  12. package/src/lib/server/api-v1-kit.js +191 -0
  13. package/src/lib/server/api-v1-kit.js.map +1 -0
  14. package/src/lib/server/api-v1-resources.d.ts +144 -0
  15. package/src/lib/server/api-v1-resources.js +126 -0
  16. package/src/lib/server/api-v1-resources.js.map +1 -0
  17. package/src/lib/server/consent-group-change.d.ts +2 -1
  18. package/src/lib/server/consent-group-change.js +41 -16
  19. package/src/lib/server/consent-group-change.js.map +1 -1
  20. package/src/lib/server/contact-email-index.d.ts +22 -0
  21. package/src/lib/server/contact-email-index.js +108 -0
  22. package/src/lib/server/contact-email-index.js.map +1 -1
  23. package/src/lib/server/crm-email-activity.d.ts +1 -1
  24. package/src/lib/server/crm-email-activity.js +1 -1
  25. package/src/lib/server/crm-email-activity.js.map +1 -1
  26. package/src/lib/server/crm-inbound-email.d.ts +23 -0
  27. package/src/lib/server/crm-inbound-email.js +1 -16
  28. package/src/lib/server/crm-inbound-email.js.map +1 -1
  29. package/src/lib/server/crm-records.d.ts +9 -1
  30. package/src/lib/server/crm-records.js +19 -2
  31. package/src/lib/server/crm-records.js.map +1 -1
  32. package/src/lib/server/cron-auth.d.ts +44 -0
  33. package/src/lib/server/cron-auth.js +76 -0
  34. package/src/lib/server/cron-auth.js.map +1 -0
  35. package/src/lib/server/dataset-live-pages.js +6 -6
  36. package/src/lib/server/dataset-live-pages.js.map +1 -1
  37. package/src/lib/server/duplicate-resource.d.ts +26 -0
  38. package/src/lib/server/duplicate-resource.js +47 -25
  39. package/src/lib/server/duplicate-resource.js.map +1 -1
  40. package/src/lib/server/dynamic-list-materialize.js +4 -1
  41. package/src/lib/server/dynamic-list-materialize.js.map +1 -1
  42. package/src/lib/server/email-deliverability.js +21 -6
  43. package/src/lib/server/email-deliverability.js.map +1 -1
  44. package/src/lib/server/email-suppression.d.ts +83 -3
  45. package/src/lib/server/email-suppression.js +229 -3
  46. package/src/lib/server/email-suppression.js.map +1 -1
  47. package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
  48. package/src/lib/server/email-unsubscribe-link.js +7 -7
  49. package/src/lib/server/email-unsubscribe-link.js.map +1 -1
  50. package/src/lib/server/erase.d.ts +19 -33
  51. package/src/lib/server/erase.js +37 -162
  52. package/src/lib/server/erase.js.map +1 -1
  53. package/src/lib/server/host-email-tokens.js +1 -1
  54. package/src/lib/server/host-email-tokens.js.map +1 -1
  55. package/src/lib/server/host-memberships.js +3 -3
  56. package/src/lib/server/host-memberships.js.map +1 -1
  57. package/src/lib/server/lockdown.js +2 -0
  58. package/src/lib/server/lockdown.js.map +1 -1
  59. package/src/lib/server/media-download-tokens.js +2 -1
  60. package/src/lib/server/media-download-tokens.js.map +1 -1
  61. package/src/lib/server/member-email-aliases.d.ts +1 -1
  62. package/src/lib/server/member-email-aliases.js +1 -1
  63. package/src/lib/server/member-email-aliases.js.map +1 -1
  64. package/src/lib/server/new-account-announcement.d.ts +62 -0
  65. package/src/lib/server/new-account-announcement.js +77 -0
  66. package/src/lib/server/new-account-announcement.js.map +1 -0
  67. package/src/lib/server/notifications.d.ts +8 -2
  68. package/src/lib/server/notifications.js +18 -4
  69. package/src/lib/server/notifications.js.map +1 -1
  70. package/src/lib/server/org-member-notice.js +5 -1
  71. package/src/lib/server/org-member-notice.js.map +1 -1
  72. package/src/lib/server/organizations.js +4 -0
  73. package/src/lib/server/organizations.js.map +1 -1
  74. package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
  75. package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +1 -1
  76. package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
  77. package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
  78. package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
  79. package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
  80. package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
  81. package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
  82. package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
  83. package/src/lib/server/payment-provider.d.ts +173 -0
  84. package/src/lib/server/payment-provider.js +70 -0
  85. package/src/lib/server/payment-provider.js.map +1 -0
  86. package/src/lib/server/personal-data-export.js +1 -1
  87. package/src/lib/server/personal-data-export.js.map +1 -1
  88. package/src/lib/server/rate-limit-store.js +13 -3
  89. package/src/lib/server/rate-limit-store.js.map +1 -1
  90. package/src/lib/server/realm-plugins.js +8 -3
  91. package/src/lib/server/realm-plugins.js.map +1 -1
  92. package/src/lib/server/retained-refusals.d.ts +142 -0
  93. package/src/lib/server/retained-refusals.js +325 -0
  94. package/src/lib/server/retained-refusals.js.map +1 -0
  95. package/src/lib/server/risk-notice.js +21 -3
  96. package/src/lib/server/risk-notice.js.map +1 -1
  97. package/src/lib/server/serve-media-cdn.js +2 -2
  98. package/src/lib/server/serve-media-cdn.js.map +1 -1
  99. package/src/lib/server/serve-plugin-fetch.d.ts +17 -7
  100. package/src/lib/server/serve-plugin-fetch.js +19 -8
  101. package/src/lib/server/serve-plugin-fetch.js.map +1 -1
  102. package/src/lib/server/test-firestore-queries.d.ts +7 -0
  103. package/src/lib/server/test-firestore-queries.js +70 -27
  104. package/src/lib/server/test-firestore-queries.js.map +1 -1
  105. package/src/lib/server/upsert-contact.d.ts +46 -1
  106. package/src/lib/server/upsert-contact.js +85 -18
  107. package/src/lib/server/upsert-contact.js.map +1 -1
  108. package/src/tenancy.d.ts +4 -5
  109. package/src/tenancy.js +11 -6
  110. package/src/tenancy.js.map +1 -1
  111. package/src/lib/server/connect-account-status.js.map +0 -1
  112. package/src/lib/server/connect-payout-failure.js.map +0 -1
  113. package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
  114. package/src/lib/server/contact-lifecycle-floor.js +0 -91
  115. package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
  116. package/src/lib/server/contact-merge.d.ts +0 -55
  117. package/src/lib/server/contact-merge.js +0 -288
  118. package/src/lib/server/contact-merge.js.map +0 -1
  119. package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
  120. package/src/lib/server/form-dataset-binding-token.js +0 -107
  121. package/src/lib/server/form-dataset-binding-token.js.map +0 -1
  122. package/src/lib/server/stripe-account-mode.js.map +0 -1
@@ -39,9 +39,9 @@ export declare function unsubscribeLinkSecret(): string;
39
39
  * liked — a small forgery, and a completely gratuitous one, since the
40
40
  * campaign is already known at the moment the link is minted.
41
41
  */
42
- export declare function unsubscribeSignatureSubject(hostId: string, email: string, campaignId?: string, topicId?: string): string;
42
+ export declare function unsubscribeSignatureSubject(hostId: string, email: string, sendId?: string, topicId?: string): string;
43
43
  /** HMAC for unsubscribe links; env-gated on the shared secret. */
44
- export declare function unsubscribeSignature(hostId: string, email: string, secret: string, campaignId?: string, topicId?: string): string;
44
+ export declare function unsubscribeSignature(hostId: string, email: string, secret: string, sendId?: string, topicId?: string): string;
45
45
  /**
46
46
  * Whether a signature is this link's.
47
47
  *
@@ -60,7 +60,7 @@ export declare function unsubscribeSignature(hostId: string, email: string, secr
60
60
  export declare function unsubscribeSignatureMatches(args: {
61
61
  hostId: string;
62
62
  email: string;
63
- campaignId?: string;
63
+ sendId?: string;
64
64
  signature: string;
65
65
  secret: string;
66
66
  }): boolean;
@@ -138,8 +138,11 @@ export declare function buildUnsubscribeUrl(input: {
138
138
  siteBase: string;
139
139
  hostId: string;
140
140
  email: string;
141
- /** The campaign this link rides in, when there is one. */
142
- campaignId?: string;
141
+ /**
142
+ * The bulk send this link rides in, when there is one (`cid`): the id of
143
+ * the send document an unsubscribe is counted against.
144
+ */
145
+ sendId?: string;
143
146
  /** The topic the message belonged to, when it belonged to one. */
144
147
  topicId?: string;
145
148
  /**
@@ -63,7 +63,7 @@
63
63
  * attribution anybody holding one valid link could point at any campaign they
64
64
  * liked — a small forgery, and a completely gratuitous one, since the
65
65
  * campaign is already known at the moment the link is minted.
66
- */ export function unsubscribeSignatureSubject(hostId, email, campaignId, topicId) {
66
+ */ export function unsubscribeSignatureSubject(hostId, email, sendId, topicId) {
67
67
  const address = String(email != null ? email : '').trim().toLowerCase();
68
68
  /*
69
69
  * A colon in either id is refused outright, because the forms are joined
@@ -77,15 +77,15 @@
77
77
  * four-part subject with an empty middle, which would make
78
78
  * `host:email::t` and a campaign id of `:t` the same string. The sender
79
79
  * always has a campaign, so this is a guard rather than a path.
80
- */ const campaign = String(campaignId != null ? campaignId : '');
80
+ */ const campaign = String(sendId != null ? sendId : '');
81
81
  const topic = String(topicId != null ? topicId : '');
82
82
  if (campaign.includes(':') || topic.includes(':')) return '';
83
83
  if (topic && campaign) return `${hostId}:${address}:${campaign}:${topic}`;
84
84
  if (campaign) return `${hostId}:${address}:${campaign}`;
85
85
  return `${hostId}:${address}`;
86
86
  }
87
- /** HMAC for unsubscribe links; env-gated on the shared secret. */ export function unsubscribeSignature(hostId, email, secret, campaignId, topicId) {
88
- const subject = unsubscribeSignatureSubject(hostId, email, campaignId, topicId);
87
+ /** HMAC for unsubscribe links; env-gated on the shared secret. */ export function unsubscribeSignature(hostId, email, secret, sendId, topicId) {
88
+ const subject = unsubscribeSignatureSubject(hostId, email, sendId, topicId);
89
89
  // An unsignable subject yields no signature rather than a signature over the
90
90
  // empty string, which would verify for every caller that also passed one.
91
91
  if (!subject) return '';
@@ -106,7 +106,7 @@
106
106
  * is not a secret, both digests are fixed-width hex, and the call throws on a
107
107
  * mismatch rather than returning false.
108
108
  */ export function unsubscribeSignatureMatches(args) {
109
- const expected = unsubscribeSignature(args.hostId, args.email, args.secret, args.campaignId || undefined);
109
+ const expected = unsubscribeSignature(args.hostId, args.email, args.secret, args.sendId || undefined);
110
110
  return expected.length === args.signature.length && timingSafeEqual(new Uint8Array(Buffer.from(expected)), new Uint8Array(Buffer.from(args.signature)));
111
111
  }
112
112
  /**
@@ -193,10 +193,10 @@
193
193
  const siteBase = String((_input_siteBase = input.siteBase) != null ? _input_siteBase : '').replace(/\/+$/, '');
194
194
  const address = String((_input_email = input.email) != null ? _input_email : '').trim().toLowerCase();
195
195
  if (!secret || !siteBase || !input.hostId || !address) return '';
196
- const signature = unsubscribeSignature(input.hostId, address, secret, input.campaignId, input.topicId);
196
+ const signature = unsubscribeSignature(input.hostId, address, secret, input.sendId, input.topicId);
197
197
  if (!signature) return '';
198
198
  const route = input.surface === 'preferences' ? '/api/email/preferences' : '/api/email/unsubscribe';
199
- return `${siteBase}${route}` + `?hostId=${encodeURIComponent(input.hostId)}` + `&email=${encodeURIComponent(address)}` + `&sig=${signature}` + (input.campaignId ? `&cid=${encodeURIComponent(input.campaignId)}` : '') + (input.topicId ? `&tid=${encodeURIComponent(input.topicId)}` : '');
199
+ return `${siteBase}${route}` + `?hostId=${encodeURIComponent(input.hostId)}` + `&email=${encodeURIComponent(address)}` + `&sig=${signature}` + (input.sendId ? `&cid=${encodeURIComponent(input.sendId)}` : '') + (input.topicId ? `&tid=${encodeURIComponent(input.topicId)}` : '');
200
200
  }
201
201
  /*==========================================
202
202
  * A SIGNED LINK OF A SENDER'S OWN (AGL-2981).
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-unsubscribe-link.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE UNSUBSCRIBE LINK — one signer, one URL shape, one verifier.\n *\n * A signed `/api/email/unsubscribe` URL was minted in exactly one place, the\n * campaign sender, and verified in another, the unsubscribe handler. Two\n * copies of an HMAC subject is a shape that only stays correct while nobody\n * adds a third caller — and the marketing gate is the third caller, so the\n * subject moved here and both existing sides now read it from one place.\n *\n * ## The address is lowercased in the subject AND in the URL\n *\n * This is the property that makes one derivation work rather than two that\n * agree by luck. The campaign sender lowercases every address far upstream,\n * so its links have always carried a lowercase address and its signature has\n * always covered one; the handler's verifier does not lowercase, and did not\n * need to. Marketing mail reaches addresses that no upstream step\n * normalized — a checkout's `customerEmail`, a form payload's `email` — so a\n * link minted over `Bob@Example.com` would sign the lowercase form and put\n * the mixed-case form on the URL, and the verifier would answer 403 to a\n * recipient pressing Unsubscribe.\n *\n * So {@link buildUnsubscribeUrl} writes the lowercased address into the query\n * as well as into the subject, and the two agree by construction rather than\n * by every caller remembering.\n */\n\nimport { createHmac, timingSafeEqual } from 'crypto'\n\n/**\n * The shared secret, or empty when the deployment has none.\n *\n * `CRON_SECRET` is the documented fallback, matching what the campaign sender\n * and the unsubscribe handler already resolve. Read per call rather than at\n * module load: these run in serverless handlers where the module may be\n * evaluated during a build, long before the runtime env exists.\n */\nexport function unsubscribeLinkSecret(): string {\n return process.env.EMAIL_UNSUBSCRIBE_SECRET || process.env.CRON_SECRET || ''\n}\n\n/**\n * The signed subject for an unsubscribe link.\n *\n * ## Two signed forms, and why the campaign id is additive\n *\n * Every email already sitting in an inbox carries a two-part signature over\n * `hostId:email`, and those links must go on working forever — an unsubscribe\n * link that stops honoring itself is the one bug in this area with a legal\n * edge on it. So the campaign is appended only when it is present, and the\n * verifier chooses which form to check by whether the link carries a `cid`.\n *\n * SIGNED rather than passed alongside. An unsigned `cid` would be an\n * attribution anybody holding one valid link could point at any campaign they\n * liked — a small forgery, and a completely gratuitous one, since the\n * campaign is already known at the moment the link is minted.\n */\nexport function unsubscribeSignatureSubject(\n hostId: string,\n email: string,\n campaignId?: string,\n topicId?: string,\n): string {\n const address = String(email ?? '')\n .trim()\n .toLowerCase()\n /*\n * A colon in either id is refused outright, because the forms are joined\n * with one. A four-part `host:email:c:t` is byte-identical to a three-part\n * subject whose campaign id is `c:t`, so one signature would verify two\n * different parameter tuples and a topic link could be re-presented as a\n * campaign link with the topic spliced in. It costs nothing real: ids come\n * from `createResourceUid()`, whose alphabet has no colon.\n *\n * A topic with no campaign DROPS the topic rather than producing a\n * four-part subject with an empty middle, which would make\n * `host:email::t` and a campaign id of `:t` the same string. The sender\n * always has a campaign, so this is a guard rather than a path.\n */\n const campaign = String(campaignId ?? '')\n const topic = String(topicId ?? '')\n if (campaign.includes(':') || topic.includes(':')) return ''\n if (topic && campaign) return `${hostId}:${address}:${campaign}:${topic}`\n if (campaign) return `${hostId}:${address}:${campaign}`\n return `${hostId}:${address}`\n}\n\n/** HMAC for unsubscribe links; env-gated on the shared secret. */\nexport function unsubscribeSignature(\n hostId: string,\n email: string,\n secret: string,\n campaignId?: string,\n topicId?: string,\n): string {\n const subject = unsubscribeSignatureSubject(hostId, email, campaignId, topicId)\n // An unsignable subject yields no signature rather than a signature over the\n // empty string, which would verify for every caller that also passed one.\n if (!subject) return ''\n return createHmac('sha256', secret).update(subject).digest('hex')\n}\n\n/**\n * Whether a signature is this link's.\n *\n * Which form is checked is decided by the LINK, not by the signature: a link\n * carrying no `cid` is checked against the two-part form and a link carrying\n * one against the three-part form. There is no fallback between them, and\n * that is what stops this being a downgrade — an attacker cannot take a\n * three-part link, drop the `cid` and have it verify, because the two-part\n * check over the same `hostId:email` produces a different digest. Nor can\n * they bolt a `cid` onto a two-part link: the three-part check then fails.\n *\n * `timingSafeEqual` needs equal lengths, so the length is compared first — it\n * is not a secret, both digests are fixed-width hex, and the call throws on a\n * mismatch rather than returning false.\n */\nexport function unsubscribeSignatureMatches(args: {\n hostId: string\n email: string\n campaignId?: string\n signature: string\n secret: string\n}): boolean {\n const expected = unsubscribeSignature(\n args.hostId,\n args.email,\n args.secret,\n args.campaignId || undefined,\n )\n return (\n expected.length === args.signature.length &&\n timingSafeEqual(\n new Uint8Array(Buffer.from(expected)),\n new Uint8Array(Buffer.from(args.signature)),\n )\n )\n}\n\n/**\n * The purpose component that distinguishes a CONFIRMATION subject.\n *\n * See {@link confirmSignatureSubject}. Exported because the plugin's verifier\n * imports it rather than restating the literal.\n */\nexport const CONFIRM_SUBJECT_PREFIX = 'confirm'\n\n/**\n * The signed subject for a double opt-in confirmation link.\n *\n * ## Why it is not one of the three forms above\n *\n * A confirmation names a host, an address and a topic, and it has NO\n * campaign — nobody is unsubscribing from a message, they are joining a\n * stream. The forms above refuse exactly that combination: a topic with no\n * campaign would leave an empty middle component, and `host:email::t` is the\n * same string as a three-part subject whose campaign id is `:t`.\n *\n * So it gets a leading PURPOSE component. That is a fourth form of the same\n * scheme, not a second scheme: the digest, the secret and the comparison are\n * unchanged.\n *\n * ## The one collision, and the guard for it\n *\n * `confirm:H:E:T` is byte-identical to the four-part unsubscribe subject\n * `A:B:C:D` when the site's id is literally `confirm`, which would let one\n * signature verify as both. Ids are not ours to constrain after the fact, so\n * the subject is refused for that host rather than the collision being\n * reasoned about: the cost is one unusable document id, and the alternative\n * is a signature that means two things.\n *\n * @returns the subject, or `''` for a combination it cannot sign\n * unambiguously. Empty rather than a partial subject, so\n * {@link confirmSignature} yields no signature rather than one over\n * the empty string — which would verify for every other caller that\n * also produced one.\n */\nexport function confirmSignatureSubject(\n hostId: string,\n email: string,\n topicId: string,\n): string {\n const address = String(email ?? '')\n .trim()\n .toLowerCase()\n const host = String(hostId ?? '')\n const topic = String(topicId ?? '')\n if (!host || !address || !topic) return ''\n if (host === CONFIRM_SUBJECT_PREFIX) return ''\n if (host.includes(':') || topic.includes(':')) return ''\n return `${CONFIRM_SUBJECT_PREFIX}:${host}:${address}:${topic}`\n}\n\n/** HMAC for a confirmation link; empty for an unsignable subject. */\nexport function confirmSignature(\n hostId: string,\n email: string,\n topicId: string,\n secret: string,\n): string {\n const subject = confirmSignatureSubject(hostId, email, topicId)\n if (!subject || !secret) return ''\n return createHmac('sha256', secret).update(subject).digest('hex')\n}\n\n/**\n * The absolute confirmation URL for one address and one topic.\n *\n * Minted where the signup happens, because the message carrying it is sent\n * from there — and that message is TRANSACTIONAL, not marketing: the person\n * just asked for this, so asking them to confirm it is the transaction they\n * started. It carries no unsubscribe header for the same reason a receipt\n * does not.\n *\n * Empty when there is no secret or no origin, for the reason\n * {@link buildUnsubscribeUrl} gives: a link pointing at nothing is worse than\n * no link, because the recipient believes they have confirmed.\n */\nexport function buildConfirmUrl(input: {\n siteBase: string\n hostId: string\n email: string\n topicId: string\n /** Defaults to {@link unsubscribeLinkSecret} — the same signing secret. */\n secret?: string\n}): string {\n const secret = input.secret ?? unsubscribeLinkSecret()\n const siteBase = String(input.siteBase ?? '').replace(/\\/+$/, '')\n const address = String(input.email ?? '')\n .trim()\n .toLowerCase()\n const signature = confirmSignature(\n input.hostId,\n address,\n input.topicId,\n secret,\n )\n if (!siteBase || !signature) return ''\n return (\n `${siteBase}/api/email/confirm` +\n `?hostId=${encodeURIComponent(input.hostId)}` +\n `&email=${encodeURIComponent(address)}` +\n `&tid=${encodeURIComponent(input.topicId)}` +\n `&sig=${signature}`\n )\n}\n\n/**\n * The absolute unsubscribe URL for one recipient of one site's mail.\n *\n * @returns the URL, or empty string when there is no secret to sign with or\n * no origin to resolve against. Empty rather than a half-built URL:\n * a caller can tell it does not have a link, and a link pointing at\n * nothing is worse than an absent header, because the recipient\n * believes they have unsubscribed.\n */\nexport function buildUnsubscribeUrl(input: {\n siteBase: string\n hostId: string\n email: string\n /** The campaign this link rides in, when there is one. */\n campaignId?: string\n /** The topic the message belonged to, when it belonged to one. */\n topicId?: string\n /**\n * Which of the two URLs over this one signature to build.\n *\n * `one-click` is what the `List-Unsubscribe` header names: a mailbox\n * provider POSTs it with no human present and expects the act to have\n * happened when it reads the 200, so it points at the route whose POST\n * writes immediately and must never point at a page somebody has to submit.\n * `preferences` is the link a PERSON clicks in the footer, where the topic\n * this message belonged to is one of the things they can stop instead of\n * all of it. Defaults to `one-click`, which is what a sender with no topic\n * — every non-campaign marketing path — wants.\n */\n surface?: 'one-click' | 'preferences'\n /** Defaults to {@link unsubscribeLinkSecret}. */\n secret?: string\n}): string {\n const secret = input.secret ?? unsubscribeLinkSecret()\n const siteBase = String(input.siteBase ?? '').replace(/\\/+$/, '')\n const address = String(input.email ?? '')\n .trim()\n .toLowerCase()\n if (!secret || !siteBase || !input.hostId || !address) return ''\n const signature = unsubscribeSignature(\n input.hostId,\n address,\n secret,\n input.campaignId,\n input.topicId,\n )\n if (!signature) return ''\n const route =\n input.surface === 'preferences'\n ? '/api/email/preferences'\n : '/api/email/unsubscribe'\n return (\n `${siteBase}${route}` +\n `?hostId=${encodeURIComponent(input.hostId)}` +\n `&email=${encodeURIComponent(address)}` +\n `&sig=${signature}` +\n (input.campaignId ? `&cid=${encodeURIComponent(input.campaignId)}` : '') +\n (input.topicId ? `&tid=${encodeURIComponent(input.topicId)}` : '')\n )\n}\n\n/*==========================================\n * A SIGNED LINK OF A SENDER'S OWN (AGL-2981).\n *\n * The forms above are the platform's own unsubscribe and confirmation\n * links, each a subject over a site and an address. A sender that mails\n * from somewhere else — a plugin sending from a person's own mailbox, whose\n * link carries its own facts — signs its link here instead, over a payload\n * of its own and under a PURPOSE of its own.\n *\n * The purpose is not a prefix of the subject, which is how the confirmation\n * form had to guard a collision by hand. It selects the KEY: every purpose's\n * links are signed with an HMAC key derived from the shared secret and the\n * purpose, so a signature minted for one purpose verifies for no other and\n * for none of the platform's own forms above, whatever the payloads spell.\n * The secret, the digest and the constant-time comparison are the same.\n *==========================================*/\n\n/** The key one purpose's links are signed with: derived from the secret, bound to the purpose. */\nfunction signedLinkKey(purpose: string, secret: string): Buffer {\n return createHmac('sha256', secret).update(`aglyn-signed-link:${purpose}`).digest()\n}\n\n/**\n * The signature of a payload for one purpose, base64url — or `''` when there\n * is no secret, no purpose or no payload to sign, so a caller can tell it has\n * no link rather than one that verifies for anybody.\n */\nexport function signedLinkSignature(\n purpose: string,\n payload: string,\n secret: string = unsubscribeLinkSecret(),\n): string {\n const name = String(purpose ?? '').trim()\n const body = String(payload ?? '')\n if (!secret || !name || !body) return ''\n return createHmac('sha256', signedLinkKey(name, secret)).update(body).digest('base64url')\n}\n\n/** Whether a signature is this payload's, for this purpose, in constant time. */\nexport function signedLinkSignatureMatches(args: {\n purpose: string\n payload: string\n signature: string\n /** Defaults to {@link unsubscribeLinkSecret}. */\n secret?: string\n}): boolean {\n const expected = signedLinkSignature(args.purpose, args.payload, args.secret ?? unsubscribeLinkSecret())\n const presented = String(args.signature ?? '')\n return (\n expected.length > 0 &&\n expected.length === presented.length &&\n timingSafeEqual(new Uint8Array(Buffer.from(expected)), new Uint8Array(Buffer.from(presented)))\n )\n}\n"],"names":["createHmac","timingSafeEqual","unsubscribeLinkSecret","process","env","EMAIL_UNSUBSCRIBE_SECRET","CRON_SECRET","unsubscribeSignatureSubject","hostId","email","campaignId","topicId","address","String","trim","toLowerCase","campaign","topic","includes","unsubscribeSignature","secret","subject","update","digest","unsubscribeSignatureMatches","args","expected","undefined","length","signature","Uint8Array","Buffer","from","CONFIRM_SUBJECT_PREFIX","confirmSignatureSubject","host","confirmSignature","buildConfirmUrl","input","siteBase","replace","encodeURIComponent","buildUnsubscribeUrl","route","surface","signedLinkKey","purpose","signedLinkSignature","payload","name","body","signedLinkSignatureMatches","presented"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-unsubscribe-link.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE UNSUBSCRIBE LINK — one signer, one URL shape, one verifier.\n *\n * A signed `/api/email/unsubscribe` URL was minted in exactly one place, the\n * campaign sender, and verified in another, the unsubscribe handler. Two\n * copies of an HMAC subject is a shape that only stays correct while nobody\n * adds a third caller — and the marketing gate is the third caller, so the\n * subject moved here and both existing sides now read it from one place.\n *\n * ## The address is lowercased in the subject AND in the URL\n *\n * This is the property that makes one derivation work rather than two that\n * agree by luck. The campaign sender lowercases every address far upstream,\n * so its links have always carried a lowercase address and its signature has\n * always covered one; the handler's verifier does not lowercase, and did not\n * need to. Marketing mail reaches addresses that no upstream step\n * normalized — a checkout's `customerEmail`, a form payload's `email` — so a\n * link minted over `Bob@Example.com` would sign the lowercase form and put\n * the mixed-case form on the URL, and the verifier would answer 403 to a\n * recipient pressing Unsubscribe.\n *\n * So {@link buildUnsubscribeUrl} writes the lowercased address into the query\n * as well as into the subject, and the two agree by construction rather than\n * by every caller remembering.\n */\n\nimport { createHmac, timingSafeEqual } from 'crypto'\n\n/**\n * The shared secret, or empty when the deployment has none.\n *\n * `CRON_SECRET` is the documented fallback, matching what the campaign sender\n * and the unsubscribe handler already resolve. Read per call rather than at\n * module load: these run in serverless handlers where the module may be\n * evaluated during a build, long before the runtime env exists.\n */\nexport function unsubscribeLinkSecret(): string {\n return process.env.EMAIL_UNSUBSCRIBE_SECRET || process.env.CRON_SECRET || ''\n}\n\n/**\n * The signed subject for an unsubscribe link.\n *\n * ## Two signed forms, and why the campaign id is additive\n *\n * Every email already sitting in an inbox carries a two-part signature over\n * `hostId:email`, and those links must go on working forever — an unsubscribe\n * link that stops honoring itself is the one bug in this area with a legal\n * edge on it. So the campaign is appended only when it is present, and the\n * verifier chooses which form to check by whether the link carries a `cid`.\n *\n * SIGNED rather than passed alongside. An unsigned `cid` would be an\n * attribution anybody holding one valid link could point at any campaign they\n * liked — a small forgery, and a completely gratuitous one, since the\n * campaign is already known at the moment the link is minted.\n */\nexport function unsubscribeSignatureSubject(\n hostId: string,\n email: string,\n sendId?: string,\n topicId?: string,\n): string {\n const address = String(email ?? '')\n .trim()\n .toLowerCase()\n /*\n * A colon in either id is refused outright, because the forms are joined\n * with one. A four-part `host:email:c:t` is byte-identical to a three-part\n * subject whose campaign id is `c:t`, so one signature would verify two\n * different parameter tuples and a topic link could be re-presented as a\n * campaign link with the topic spliced in. It costs nothing real: ids come\n * from `createResourceUid()`, whose alphabet has no colon.\n *\n * A topic with no campaign DROPS the topic rather than producing a\n * four-part subject with an empty middle, which would make\n * `host:email::t` and a campaign id of `:t` the same string. The sender\n * always has a campaign, so this is a guard rather than a path.\n */\n const campaign = String(sendId ?? '')\n const topic = String(topicId ?? '')\n if (campaign.includes(':') || topic.includes(':')) return ''\n if (topic && campaign) return `${hostId}:${address}:${campaign}:${topic}`\n if (campaign) return `${hostId}:${address}:${campaign}`\n return `${hostId}:${address}`\n}\n\n/** HMAC for unsubscribe links; env-gated on the shared secret. */\nexport function unsubscribeSignature(\n hostId: string,\n email: string,\n secret: string,\n sendId?: string,\n topicId?: string,\n): string {\n const subject = unsubscribeSignatureSubject(hostId, email, sendId, topicId)\n // An unsignable subject yields no signature rather than a signature over the\n // empty string, which would verify for every caller that also passed one.\n if (!subject) return ''\n return createHmac('sha256', secret).update(subject).digest('hex')\n}\n\n/**\n * Whether a signature is this link's.\n *\n * Which form is checked is decided by the LINK, not by the signature: a link\n * carrying no `cid` is checked against the two-part form and a link carrying\n * one against the three-part form. There is no fallback between them, and\n * that is what stops this being a downgrade — an attacker cannot take a\n * three-part link, drop the `cid` and have it verify, because the two-part\n * check over the same `hostId:email` produces a different digest. Nor can\n * they bolt a `cid` onto a two-part link: the three-part check then fails.\n *\n * `timingSafeEqual` needs equal lengths, so the length is compared first — it\n * is not a secret, both digests are fixed-width hex, and the call throws on a\n * mismatch rather than returning false.\n */\nexport function unsubscribeSignatureMatches(args: {\n hostId: string\n email: string\n sendId?: string\n signature: string\n secret: string\n}): boolean {\n const expected = unsubscribeSignature(\n args.hostId,\n args.email,\n args.secret,\n args.sendId || undefined,\n )\n return (\n expected.length === args.signature.length &&\n timingSafeEqual(\n new Uint8Array(Buffer.from(expected)),\n new Uint8Array(Buffer.from(args.signature)),\n )\n )\n}\n\n/**\n * The purpose component that distinguishes a CONFIRMATION subject.\n *\n * See {@link confirmSignatureSubject}. Exported because the plugin's verifier\n * imports it rather than restating the literal.\n */\nexport const CONFIRM_SUBJECT_PREFIX = 'confirm'\n\n/**\n * The signed subject for a double opt-in confirmation link.\n *\n * ## Why it is not one of the three forms above\n *\n * A confirmation names a host, an address and a topic, and it has NO\n * campaign — nobody is unsubscribing from a message, they are joining a\n * stream. The forms above refuse exactly that combination: a topic with no\n * campaign would leave an empty middle component, and `host:email::t` is the\n * same string as a three-part subject whose campaign id is `:t`.\n *\n * So it gets a leading PURPOSE component. That is a fourth form of the same\n * scheme, not a second scheme: the digest, the secret and the comparison are\n * unchanged.\n *\n * ## The one collision, and the guard for it\n *\n * `confirm:H:E:T` is byte-identical to the four-part unsubscribe subject\n * `A:B:C:D` when the site's id is literally `confirm`, which would let one\n * signature verify as both. Ids are not ours to constrain after the fact, so\n * the subject is refused for that host rather than the collision being\n * reasoned about: the cost is one unusable document id, and the alternative\n * is a signature that means two things.\n *\n * @returns the subject, or `''` for a combination it cannot sign\n * unambiguously. Empty rather than a partial subject, so\n * {@link confirmSignature} yields no signature rather than one over\n * the empty string — which would verify for every other caller that\n * also produced one.\n */\nexport function confirmSignatureSubject(\n hostId: string,\n email: string,\n topicId: string,\n): string {\n const address = String(email ?? '')\n .trim()\n .toLowerCase()\n const host = String(hostId ?? '')\n const topic = String(topicId ?? '')\n if (!host || !address || !topic) return ''\n if (host === CONFIRM_SUBJECT_PREFIX) return ''\n if (host.includes(':') || topic.includes(':')) return ''\n return `${CONFIRM_SUBJECT_PREFIX}:${host}:${address}:${topic}`\n}\n\n/** HMAC for a confirmation link; empty for an unsignable subject. */\nexport function confirmSignature(\n hostId: string,\n email: string,\n topicId: string,\n secret: string,\n): string {\n const subject = confirmSignatureSubject(hostId, email, topicId)\n if (!subject || !secret) return ''\n return createHmac('sha256', secret).update(subject).digest('hex')\n}\n\n/**\n * The absolute confirmation URL for one address and one topic.\n *\n * Minted where the signup happens, because the message carrying it is sent\n * from there — and that message is TRANSACTIONAL, not marketing: the person\n * just asked for this, so asking them to confirm it is the transaction they\n * started. It carries no unsubscribe header for the same reason a receipt\n * does not.\n *\n * Empty when there is no secret or no origin, for the reason\n * {@link buildUnsubscribeUrl} gives: a link pointing at nothing is worse than\n * no link, because the recipient believes they have confirmed.\n */\nexport function buildConfirmUrl(input: {\n siteBase: string\n hostId: string\n email: string\n topicId: string\n /** Defaults to {@link unsubscribeLinkSecret} — the same signing secret. */\n secret?: string\n}): string {\n const secret = input.secret ?? unsubscribeLinkSecret()\n const siteBase = String(input.siteBase ?? '').replace(/\\/+$/, '')\n const address = String(input.email ?? '')\n .trim()\n .toLowerCase()\n const signature = confirmSignature(\n input.hostId,\n address,\n input.topicId,\n secret,\n )\n if (!siteBase || !signature) return ''\n return (\n `${siteBase}/api/email/confirm` +\n `?hostId=${encodeURIComponent(input.hostId)}` +\n `&email=${encodeURIComponent(address)}` +\n `&tid=${encodeURIComponent(input.topicId)}` +\n `&sig=${signature}`\n )\n}\n\n/**\n * The absolute unsubscribe URL for one recipient of one site's mail.\n *\n * @returns the URL, or empty string when there is no secret to sign with or\n * no origin to resolve against. Empty rather than a half-built URL:\n * a caller can tell it does not have a link, and a link pointing at\n * nothing is worse than an absent header, because the recipient\n * believes they have unsubscribed.\n */\nexport function buildUnsubscribeUrl(input: {\n siteBase: string\n hostId: string\n email: string\n /**\n * The bulk send this link rides in, when there is one (`cid`): the id of\n * the send document an unsubscribe is counted against.\n */\n sendId?: string\n /** The topic the message belonged to, when it belonged to one. */\n topicId?: string\n /**\n * Which of the two URLs over this one signature to build.\n *\n * `one-click` is what the `List-Unsubscribe` header names: a mailbox\n * provider POSTs it with no human present and expects the act to have\n * happened when it reads the 200, so it points at the route whose POST\n * writes immediately and must never point at a page somebody has to submit.\n * `preferences` is the link a PERSON clicks in the footer, where the topic\n * this message belonged to is one of the things they can stop instead of\n * all of it. Defaults to `one-click`, which is what a sender with no topic\n * — every non-campaign marketing path — wants.\n */\n surface?: 'one-click' | 'preferences'\n /** Defaults to {@link unsubscribeLinkSecret}. */\n secret?: string\n}): string {\n const secret = input.secret ?? unsubscribeLinkSecret()\n const siteBase = String(input.siteBase ?? '').replace(/\\/+$/, '')\n const address = String(input.email ?? '')\n .trim()\n .toLowerCase()\n if (!secret || !siteBase || !input.hostId || !address) return ''\n const signature = unsubscribeSignature(\n input.hostId,\n address,\n secret,\n input.sendId,\n input.topicId,\n )\n if (!signature) return ''\n const route =\n input.surface === 'preferences'\n ? '/api/email/preferences'\n : '/api/email/unsubscribe'\n return (\n `${siteBase}${route}` +\n `?hostId=${encodeURIComponent(input.hostId)}` +\n `&email=${encodeURIComponent(address)}` +\n `&sig=${signature}` +\n (input.sendId ? `&cid=${encodeURIComponent(input.sendId)}` : '') +\n (input.topicId ? `&tid=${encodeURIComponent(input.topicId)}` : '')\n )\n}\n\n/*==========================================\n * A SIGNED LINK OF A SENDER'S OWN (AGL-2981).\n *\n * The forms above are the platform's own unsubscribe and confirmation\n * links, each a subject over a site and an address. A sender that mails\n * from somewhere else — a plugin sending from a person's own mailbox, whose\n * link carries its own facts — signs its link here instead, over a payload\n * of its own and under a PURPOSE of its own.\n *\n * The purpose is not a prefix of the subject, which is how the confirmation\n * form had to guard a collision by hand. It selects the KEY: every purpose's\n * links are signed with an HMAC key derived from the shared secret and the\n * purpose, so a signature minted for one purpose verifies for no other and\n * for none of the platform's own forms above, whatever the payloads spell.\n * The secret, the digest and the constant-time comparison are the same.\n *==========================================*/\n\n/** The key one purpose's links are signed with: derived from the secret, bound to the purpose. */\nfunction signedLinkKey(purpose: string, secret: string): Buffer {\n return createHmac('sha256', secret).update(`aglyn-signed-link:${purpose}`).digest()\n}\n\n/**\n * The signature of a payload for one purpose, base64url — or `''` when there\n * is no secret, no purpose or no payload to sign, so a caller can tell it has\n * no link rather than one that verifies for anybody.\n */\nexport function signedLinkSignature(\n purpose: string,\n payload: string,\n secret: string = unsubscribeLinkSecret(),\n): string {\n const name = String(purpose ?? '').trim()\n const body = String(payload ?? '')\n if (!secret || !name || !body) return ''\n return createHmac('sha256', signedLinkKey(name, secret)).update(body).digest('base64url')\n}\n\n/** Whether a signature is this payload's, for this purpose, in constant time. */\nexport function signedLinkSignatureMatches(args: {\n purpose: string\n payload: string\n signature: string\n /** Defaults to {@link unsubscribeLinkSecret}. */\n secret?: string\n}): boolean {\n const expected = signedLinkSignature(args.purpose, args.payload, args.secret ?? unsubscribeLinkSecret())\n const presented = String(args.signature ?? '')\n return (\n expected.length > 0 &&\n expected.length === presented.length &&\n timingSafeEqual(new Uint8Array(Buffer.from(expected)), new Uint8Array(Buffer.from(presented)))\n )\n}\n"],"names":["createHmac","timingSafeEqual","unsubscribeLinkSecret","process","env","EMAIL_UNSUBSCRIBE_SECRET","CRON_SECRET","unsubscribeSignatureSubject","hostId","email","sendId","topicId","address","String","trim","toLowerCase","campaign","topic","includes","unsubscribeSignature","secret","subject","update","digest","unsubscribeSignatureMatches","args","expected","undefined","length","signature","Uint8Array","Buffer","from","CONFIRM_SUBJECT_PREFIX","confirmSignatureSubject","host","confirmSignature","buildConfirmUrl","input","siteBase","replace","encodeURIComponent","buildUnsubscribeUrl","route","surface","signedLinkKey","purpose","signedLinkSignature","payload","name","body","signedLinkSignatureMatches","presented"],"mappings":"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"}
@@ -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