@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.
- package/package.json +9 -9
- package/src/lib/server/account-lock-mail.d.ts +74 -0
- package/src/lib/server/account-lock-mail.js +139 -0
- package/src/lib/server/account-lock-mail.js.map +1 -0
- package/src/lib/server/account-mail.d.ts +41 -0
- package/src/lib/server/account-mail.js +52 -0
- package/src/lib/server/account-mail.js.map +1 -0
- package/src/lib/server/api-v1-description.d.ts +112 -0
- package/src/lib/server/api-v1-description.js +126 -0
- package/src/lib/server/api-v1-description.js.map +1 -0
- package/src/lib/server/api-v1-kit.d.ts +156 -0
- package/src/lib/server/api-v1-kit.js +191 -0
- package/src/lib/server/api-v1-kit.js.map +1 -0
- package/src/lib/server/api-v1-resources.d.ts +144 -0
- package/src/lib/server/api-v1-resources.js +126 -0
- package/src/lib/server/api-v1-resources.js.map +1 -0
- package/src/lib/server/consent-group-change.d.ts +2 -1
- package/src/lib/server/consent-group-change.js +41 -16
- package/src/lib/server/consent-group-change.js.map +1 -1
- package/src/lib/server/contact-email-index.d.ts +22 -0
- package/src/lib/server/contact-email-index.js +108 -0
- package/src/lib/server/contact-email-index.js.map +1 -1
- package/src/lib/server/crm-email-activity.d.ts +1 -1
- package/src/lib/server/crm-email-activity.js +1 -1
- package/src/lib/server/crm-email-activity.js.map +1 -1
- package/src/lib/server/crm-inbound-email.d.ts +23 -0
- package/src/lib/server/crm-inbound-email.js +1 -16
- package/src/lib/server/crm-inbound-email.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +9 -1
- package/src/lib/server/crm-records.js +19 -2
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/cron-auth.d.ts +44 -0
- package/src/lib/server/cron-auth.js +76 -0
- package/src/lib/server/cron-auth.js.map +1 -0
- package/src/lib/server/dataset-live-pages.js +6 -6
- package/src/lib/server/dataset-live-pages.js.map +1 -1
- package/src/lib/server/duplicate-resource.d.ts +26 -0
- package/src/lib/server/duplicate-resource.js +47 -25
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/dynamic-list-materialize.js +4 -1
- package/src/lib/server/dynamic-list-materialize.js.map +1 -1
- package/src/lib/server/email-deliverability.js +21 -6
- package/src/lib/server/email-deliverability.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +83 -3
- package/src/lib/server/email-suppression.js +229 -3
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
- package/src/lib/server/email-unsubscribe-link.js +7 -7
- package/src/lib/server/email-unsubscribe-link.js.map +1 -1
- package/src/lib/server/erase.d.ts +19 -33
- package/src/lib/server/erase.js +37 -162
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-email-tokens.js +1 -1
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/host-memberships.js +3 -3
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/lockdown.js +2 -0
- package/src/lib/server/lockdown.js.map +1 -1
- package/src/lib/server/media-download-tokens.js +2 -1
- package/src/lib/server/media-download-tokens.js.map +1 -1
- package/src/lib/server/member-email-aliases.d.ts +1 -1
- package/src/lib/server/member-email-aliases.js +1 -1
- package/src/lib/server/member-email-aliases.js.map +1 -1
- package/src/lib/server/new-account-announcement.d.ts +62 -0
- package/src/lib/server/new-account-announcement.js +77 -0
- package/src/lib/server/new-account-announcement.js.map +1 -0
- package/src/lib/server/notifications.d.ts +8 -2
- package/src/lib/server/notifications.js +18 -4
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/org-member-notice.js +5 -1
- package/src/lib/server/org-member-notice.js.map +1 -1
- package/src/lib/server/organizations.js +4 -0
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
- package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
- package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
- package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
- package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
- package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
- package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
- package/src/lib/server/payment-provider.d.ts +173 -0
- package/src/lib/server/payment-provider.js +70 -0
- package/src/lib/server/payment-provider.js.map +1 -0
- package/src/lib/server/personal-data-export.js +1 -1
- package/src/lib/server/personal-data-export.js.map +1 -1
- package/src/lib/server/rate-limit-store.js +13 -3
- package/src/lib/server/rate-limit-store.js.map +1 -1
- package/src/lib/server/realm-plugins.js +8 -3
- package/src/lib/server/realm-plugins.js.map +1 -1
- package/src/lib/server/retained-refusals.d.ts +142 -0
- package/src/lib/server/retained-refusals.js +325 -0
- package/src/lib/server/retained-refusals.js.map +1 -0
- package/src/lib/server/risk-notice.js +21 -3
- package/src/lib/server/risk-notice.js.map +1 -1
- package/src/lib/server/serve-media-cdn.js +2 -2
- package/src/lib/server/serve-media-cdn.js.map +1 -1
- package/src/lib/server/serve-plugin-fetch.d.ts +17 -7
- package/src/lib/server/serve-plugin-fetch.js +19 -8
- package/src/lib/server/serve-plugin-fetch.js.map +1 -1
- package/src/lib/server/test-firestore-queries.d.ts +7 -0
- package/src/lib/server/test-firestore-queries.js +70 -27
- package/src/lib/server/test-firestore-queries.js.map +1 -1
- package/src/lib/server/upsert-contact.d.ts +46 -1
- package/src/lib/server/upsert-contact.js +85 -18
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +4 -5
- package/src/tenancy.js +11 -6
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/connect-account-status.js.map +0 -1
- package/src/lib/server/connect-payout-failure.js.map +0 -1
- package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
- package/src/lib/server/contact-lifecycle-floor.js +0 -91
- package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
- package/src/lib/server/contact-merge.d.ts +0 -55
- package/src/lib/server/contact-merge.js +0 -288
- package/src/lib/server/contact-merge.js.map +0 -1
- package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
- package/src/lib/server/form-dataset-binding-token.js +0 -107
- package/src/lib/server/form-dataset-binding-token.js.map +0 -1
- 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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
/**
|
|
142
|
-
|
|
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,
|
|
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(
|
|
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,
|
|
88
|
-
const subject = unsubscribeSignatureSubject(hostId, email,
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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.
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
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).
|
package/src/lib/server/erase.js
CHANGED
|
@@ -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
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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(
|
|
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.
|
|
868
|
-
*
|
|
869
|
-
*
|
|
870
|
-
*
|
|
871
|
-
*
|
|
872
|
-
*
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
-
*
|
|
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
|
-
|
|
990
|
-
|
|
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
|