@aglyn/tenant-data-admin 1.0.0-beta.199 → 1.0.0-beta.201
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 +8 -7
- package/src/lib/server/admin-audit-write.d.ts +28 -0
- package/src/lib/server/admin-audit-write.js +46 -0
- package/src/lib/server/admin-audit-write.js.map +1 -0
- package/src/lib/server/admin-audit.d.ts +4 -9
- package/src/lib/server/admin-audit.js +12 -31
- package/src/lib/server/admin-audit.js.map +1 -1
- package/src/lib/server/consent-group-carry.js +2 -0
- package/src/lib/server/consent-group-carry.js.map +1 -1
- package/src/lib/server/contact-company-link.js +3 -0
- package/src/lib/server/contact-company-link.js.map +1 -1
- package/src/lib/server/contact-lifecycle-floor.js +3 -0
- package/src/lib/server/contact-lifecycle-floor.js.map +1 -1
- package/src/lib/server/contact-merge.js +4 -0
- package/src/lib/server/contact-merge.js.map +1 -1
- package/src/lib/server/crm-booking-activity.js +3 -2
- package/src/lib/server/crm-booking-activity.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +15 -0
- package/src/lib/server/crm-records.js +52 -0
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/csp-aggregate.d.ts +16 -0
- package/src/lib/server/csp-aggregate.js +36 -0
- package/src/lib/server/csp-aggregate.js.map +1 -1
- package/src/lib/server/dns-probe.d.ts +7 -0
- package/src/lib/server/dns-probe.js +52 -0
- package/src/lib/server/dns-probe.js.map +1 -1
- package/src/lib/server/duplicate-resource.js +12 -5
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/email-deliverability.d.ts +150 -0
- package/src/lib/server/email-deliverability.js +538 -0
- package/src/lib/server/email-deliverability.js.map +1 -0
- package/src/lib/server/email-delivery-log.d.ts +33 -0
- package/src/lib/server/email-delivery-log.js +43 -2
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/email-sender-reputation.d.ts +2 -11
- package/src/lib/server/email-sender-reputation.js +8 -25
- package/src/lib/server/email-sender-reputation.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +21 -15
- package/src/lib/server/email-suppression.js +18 -34
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/erase-person.js +53 -6
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/erase.js +4 -3
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-memberships.d.ts +34 -0
- package/src/lib/server/host-memberships.js +89 -1
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +16 -2
- package/src/lib/server/host-visitor-records.js +52 -7
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +92 -0
- package/src/lib/server/hosted-page-review.js +342 -0
- package/src/lib/server/hosted-page-review.js.map +1 -0
- package/src/lib/server/list-members.d.ts +11 -0
- package/src/lib/server/list-members.js +28 -1
- package/src/lib/server/list-members.js.map +1 -1
- package/src/lib/server/list-query.d.ts +36 -0
- package/src/lib/server/list-query.js +49 -0
- package/src/lib/server/list-query.js.map +1 -0
- package/src/lib/server/media-tombstone.js +8 -2
- package/src/lib/server/media-tombstone.js.map +1 -1
- package/src/lib/server/org-age.d.ts +27 -0
- package/src/lib/server/org-age.js +44 -0
- package/src/lib/server/org-age.js.map +1 -0
- package/src/lib/server/org-member-list-fields.d.ts +31 -0
- package/src/lib/server/org-member-list-fields.js +38 -0
- package/src/lib/server/org-member-list-fields.js.map +1 -0
- package/src/lib/server/organizations.js +37 -7
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/outbound-send-review.d.ts +222 -0
- package/src/lib/server/outbound-send-review.js +479 -0
- package/src/lib/server/outbound-send-review.js.map +1 -0
- package/src/lib/server/payment-fraud-signal.d.ts +156 -0
- package/src/lib/server/payment-fraud-signal.js +368 -0
- package/src/lib/server/payment-fraud-signal.js.map +1 -0
- package/src/lib/server/payment-risk-record.d.ts +59 -0
- package/src/lib/server/payment-risk-record.js +76 -0
- package/src/lib/server/payment-risk-record.js.map +1 -0
- package/src/lib/server/replay-erasures.js +2 -1
- package/src/lib/server/replay-erasures.js.map +1 -1
- package/src/lib/server/sending-domains.d.ts +6 -0
- package/src/lib/server/sending-domains.js +98 -6
- package/src/lib/server/sending-domains.js.map +1 -1
- package/src/lib/server/sso-enforcement.js +2 -1
- package/src/lib/server/sso-enforcement.js.map +1 -1
- package/src/lib/server/stripe-card-authentication.d.ts +35 -0
- package/src/lib/server/stripe-card-authentication.js +63 -0
- package/src/lib/server/stripe-card-authentication.js.map +1 -0
- package/src/lib/server/tenant-write-lockdown.d.ts +32 -2
- package/src/lib/server/tenant-write-lockdown.js +33 -3
- package/src/lib/server/tenant-write-lockdown.js.map +1 -1
- package/src/lib/server/upsert-contact.js +8 -4
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +3 -0
- package/src/tenancy.js +8 -0
- package/src/tenancy.js.map +1 -1
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type PaymentRiskSignal } from '@aglyn/aglyn/app-utils/payment-risk';
|
|
18
|
+
import type { AglynNotificationType } from '@aglyn/aglyn/app-utils/notifications';
|
|
19
|
+
/** The notifier's shape: `notifyHostManagers` from the admin barrel. */
|
|
20
|
+
export type PaymentRiskNotifier = (hostId: string, payload: {
|
|
21
|
+
type: AglynNotificationType;
|
|
22
|
+
title: string;
|
|
23
|
+
body: string;
|
|
24
|
+
link: string;
|
|
25
|
+
}) => Promise<void>;
|
|
26
|
+
export interface RecordPaymentRiskInput {
|
|
27
|
+
ref: FirebaseFirestore.DocumentReference;
|
|
28
|
+
signal: PaymentRiskSignal;
|
|
29
|
+
/** The site whose managers are told. Empty tells nobody. */
|
|
30
|
+
hostId: string;
|
|
31
|
+
/** What the record is, as the merchant calls it: `Order 1042`, `Booking`. */
|
|
32
|
+
subjectLabel: string;
|
|
33
|
+
/** The console path the notification opens. */
|
|
34
|
+
link: string;
|
|
35
|
+
/** The plugin's own notification type: `content.order`, `content.booking`. */
|
|
36
|
+
notificationType: AglynNotificationType;
|
|
37
|
+
/**
|
|
38
|
+
* More fields to write in the same transaction, from the record as read —
|
|
39
|
+
* a plugin's own activity line (an order's timeline) rides here.
|
|
40
|
+
*/
|
|
41
|
+
extraUpdate?: (data: Record<string, unknown>) => Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Stamp `signal` on `input.ref` and notify, once. Returns true when this
|
|
45
|
+
* delivery wrote it; false for a redelivery or a record that is gone.
|
|
46
|
+
* Throws only when the write throws, which the webhook answers with a 500 and
|
|
47
|
+
* Stripe's redelivery.
|
|
48
|
+
*/
|
|
49
|
+
export declare function recordPaymentRiskOnRecord(input: RecordPaymentRiskInput, deps: {
|
|
50
|
+
firestore: FirebaseFirestore.Firestore;
|
|
51
|
+
notify: PaymentRiskNotifier;
|
|
52
|
+
}): Promise<boolean>;
|
|
53
|
+
/**
|
|
54
|
+
* Record a dispute's outcome on the signal already stamped for it. Silent:
|
|
55
|
+
* the plugin that owns money movement on a lost dispute says so itself.
|
|
56
|
+
*/
|
|
57
|
+
export declare function settlePaymentRiskDisputeOnRecord(ref: FirebaseFirestore.DocumentReference, disputeId: string, outcome: string, deps: {
|
|
58
|
+
firestore: FirebaseFirestore.Firestore;
|
|
59
|
+
}): Promise<boolean>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ /*==========================================
|
|
18
|
+
* STAMP A STRIPE FRAUD SIGNAL ON A MERCHANT'S RECORD (AGL-3360).
|
|
19
|
+
*
|
|
20
|
+
* The server half of `@aglyn/aglyn/app-utils/payment-risk`. A plugin that
|
|
21
|
+
* finds its own record behind a charge — an order, a booking — hands the
|
|
22
|
+
* document here, and this writes the `paymentRisk` field once per signal and
|
|
23
|
+
* tells the site's managers once. Any plugin that sells through Stripe uses
|
|
24
|
+
* the same two calls; nothing about the record's kind is known here.
|
|
25
|
+
*
|
|
26
|
+
* NOTHING IS REFUNDED OR CANCELED. The merchant decides for their own
|
|
27
|
+
* customer; the notification says so.
|
|
28
|
+
*
|
|
29
|
+
* Dependencies are passed in — the Firestore handle and the notifier — so a
|
|
30
|
+
* plugin's spec that substitutes the admin barrel substitutes these too.
|
|
31
|
+
*=========================================*/ import { addPaymentRiskSignal, NOTHING_MOVED, PAYMENT_RISK_FIELD, paymentRiskAdvice, paymentRiskTitle, settlePaymentRiskDispute } from "@aglyn/aglyn/app-utils/payment-risk";
|
|
32
|
+
/**
|
|
33
|
+
* Stamp `signal` on `input.ref` and notify, once. Returns true when this
|
|
34
|
+
* delivery wrote it; false for a redelivery or a record that is gone.
|
|
35
|
+
* Throws only when the write throws, which the webhook answers with a 500 and
|
|
36
|
+
* Stripe's redelivery.
|
|
37
|
+
*/ export async function recordPaymentRiskOnRecord(input, deps) {
|
|
38
|
+
const written = await deps.firestore.runTransaction(async (transaction)=>{
|
|
39
|
+
var _fresh_data;
|
|
40
|
+
const fresh = await transaction.get(input.ref);
|
|
41
|
+
if (!fresh.exists) return false;
|
|
42
|
+
const data = (_fresh_data = fresh.data()) != null ? _fresh_data : {};
|
|
43
|
+
const next = addPaymentRiskSignal(data[PAYMENT_RISK_FIELD], input.signal);
|
|
44
|
+
if (!next) return false;
|
|
45
|
+
transaction.update(input.ref, _extends({}, input.extraUpdate ? input.extraUpdate(data) : {}, {
|
|
46
|
+
[PAYMENT_RISK_FIELD]: next
|
|
47
|
+
}));
|
|
48
|
+
return true;
|
|
49
|
+
});
|
|
50
|
+
if (written && input.hostId) {
|
|
51
|
+
await deps.notify(input.hostId, {
|
|
52
|
+
type: input.notificationType,
|
|
53
|
+
title: paymentRiskTitle(input.signal.kind),
|
|
54
|
+
body: `${input.subjectLabel}. ${paymentRiskAdvice(input.signal.kind)}` + (input.signal.detail ? ` Stripe says: ${input.signal.detail.replace(/_/g, ' ')}.` : '') + ` ${NOTHING_MOVED}`,
|
|
55
|
+
link: input.link
|
|
56
|
+
}).catch(()=>undefined);
|
|
57
|
+
}
|
|
58
|
+
return written;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Record a dispute's outcome on the signal already stamped for it. Silent:
|
|
62
|
+
* the plugin that owns money movement on a lost dispute says so itself.
|
|
63
|
+
*/ export async function settlePaymentRiskDisputeOnRecord(ref, disputeId, outcome, deps) {
|
|
64
|
+
return deps.firestore.runTransaction(async (transaction)=>{
|
|
65
|
+
const fresh = await transaction.get(ref);
|
|
66
|
+
if (!fresh.exists) return false;
|
|
67
|
+
const next = settlePaymentRiskDispute(fresh.get(PAYMENT_RISK_FIELD), disputeId, outcome);
|
|
68
|
+
if (!next) return false;
|
|
69
|
+
transaction.update(ref, {
|
|
70
|
+
[PAYMENT_RISK_FIELD]: next
|
|
71
|
+
});
|
|
72
|
+
return true;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
//# sourceMappingURL=payment-risk-record.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/payment-risk-record.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 * STAMP A STRIPE FRAUD SIGNAL ON A MERCHANT'S RECORD (AGL-3360).\n *\n * The server half of `@aglyn/aglyn/app-utils/payment-risk`. A plugin that\n * finds its own record behind a charge — an order, a booking — hands the\n * document here, and this writes the `paymentRisk` field once per signal and\n * tells the site's managers once. Any plugin that sells through Stripe uses\n * the same two calls; nothing about the record's kind is known here.\n *\n * NOTHING IS REFUNDED OR CANCELED. The merchant decides for their own\n * customer; the notification says so.\n *\n * Dependencies are passed in — the Firestore handle and the notifier — so a\n * plugin's spec that substitutes the admin barrel substitutes these too.\n *=========================================*/\n\nimport {\n addPaymentRiskSignal,\n NOTHING_MOVED,\n PAYMENT_RISK_FIELD,\n type PaymentRisk,\n type PaymentRiskSignal,\n paymentRiskAdvice,\n paymentRiskTitle,\n settlePaymentRiskDispute,\n} from '@aglyn/aglyn/app-utils/payment-risk'\nimport type { AglynNotificationType } from '@aglyn/aglyn/app-utils/notifications'\n\n/** The notifier's shape: `notifyHostManagers` from the admin barrel. */\nexport type PaymentRiskNotifier = (\n hostId: string,\n payload: {\n type: AglynNotificationType\n title: string\n body: string\n link: string\n },\n) => Promise<void>\n\nexport interface RecordPaymentRiskInput {\n ref: FirebaseFirestore.DocumentReference\n signal: PaymentRiskSignal\n /** The site whose managers are told. Empty tells nobody. */\n hostId: string\n /** What the record is, as the merchant calls it: `Order 1042`, `Booking`. */\n subjectLabel: string\n /** The console path the notification opens. */\n link: string\n /** The plugin's own notification type: `content.order`, `content.booking`. */\n notificationType: AglynNotificationType\n /**\n * More fields to write in the same transaction, from the record as read —\n * a plugin's own activity line (an order's timeline) rides here.\n */\n extraUpdate?: (data: Record<string, unknown>) => Record<string, unknown>\n}\n\n/**\n * Stamp `signal` on `input.ref` and notify, once. Returns true when this\n * delivery wrote it; false for a redelivery or a record that is gone.\n * Throws only when the write throws, which the webhook answers with a 500 and\n * Stripe's redelivery.\n */\nexport async function recordPaymentRiskOnRecord(\n input: RecordPaymentRiskInput,\n deps: {\n firestore: FirebaseFirestore.Firestore\n notify: PaymentRiskNotifier\n },\n): Promise<boolean> {\n const written = await deps.firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(input.ref)\n if (!fresh.exists) return false\n const data = (fresh.data() ?? {}) as Record<string, unknown>\n const next = addPaymentRiskSignal(\n data[PAYMENT_RISK_FIELD] as PaymentRisk | undefined,\n input.signal,\n )\n if (!next) return false\n transaction.update(input.ref, {\n ...(input.extraUpdate ? input.extraUpdate(data) : {}),\n [PAYMENT_RISK_FIELD]: next,\n })\n return true\n })\n if (written && input.hostId) {\n await deps\n .notify(input.hostId, {\n type: input.notificationType,\n title: paymentRiskTitle(input.signal.kind),\n body:\n `${input.subjectLabel}. ${paymentRiskAdvice(input.signal.kind)}` +\n (input.signal.detail\n ? ` Stripe says: ${input.signal.detail.replace(/_/g, ' ')}.`\n : '') +\n ` ${NOTHING_MOVED}`,\n link: input.link,\n })\n .catch(() => undefined)\n }\n return written\n}\n\n/**\n * Record a dispute's outcome on the signal already stamped for it. Silent:\n * the plugin that owns money movement on a lost dispute says so itself.\n */\nexport async function settlePaymentRiskDisputeOnRecord(\n ref: FirebaseFirestore.DocumentReference,\n disputeId: string,\n outcome: string,\n deps: { firestore: FirebaseFirestore.Firestore },\n): Promise<boolean> {\n return deps.firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(ref)\n if (!fresh.exists) return false\n const next = settlePaymentRiskDispute(\n fresh.get(PAYMENT_RISK_FIELD) as PaymentRisk | undefined,\n disputeId,\n outcome,\n )\n if (!next) return false\n transaction.update(ref, { [PAYMENT_RISK_FIELD]: next })\n return true\n })\n}\n"],"names":["addPaymentRiskSignal","NOTHING_MOVED","PAYMENT_RISK_FIELD","paymentRiskAdvice","paymentRiskTitle","settlePaymentRiskDispute","recordPaymentRiskOnRecord","input","deps","written","firestore","runTransaction","transaction","fresh","get","ref","exists","data","next","signal","update","extraUpdate","hostId","notify","type","notificationType","title","kind","body","subjectLabel","detail","replace","link","catch","undefined","settlePaymentRiskDisputeOnRecord","disputeId","outcome"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;2CAc2C,GAE3C,SACEA,oBAAoB,EACpBC,aAAa,EACbC,kBAAkB,EAGlBC,iBAAiB,EACjBC,gBAAgB,EAChBC,wBAAwB,QACnB,sCAAqC;AAgC5C;;;;;CAKC,GACD,OAAO,eAAeC,0BACpBC,KAA6B,EAC7BC,IAGC;IAED,MAAMC,UAAU,MAAMD,KAAKE,SAAS,CAACC,cAAc,CAAC,OAAOC;YAG3CC;QAFd,MAAMA,QAAQ,MAAMD,YAAYE,GAAG,CAACP,MAAMQ,GAAG;QAC7C,IAAI,CAACF,MAAMG,MAAM,EAAE,OAAO;QAC1B,MAAMC,QAAQJ,cAAAA,MAAMI,IAAI,cAAVJ,cAAgB,CAAC;QAC/B,MAAMK,OAAOlB,qBACXiB,IAAI,CAACf,mBAAmB,EACxBK,MAAMY,MAAM;QAEd,IAAI,CAACD,MAAM,OAAO;QAClBN,YAAYQ,MAAM,CAACb,MAAMQ,GAAG,EAAE,aACxBR,MAAMc,WAAW,GAAGd,MAAMc,WAAW,CAACJ,QAAQ,CAAC;YACnD,CAACf,mBAAmB,EAAEgB;;QAExB,OAAO;IACT;IACA,IAAIT,WAAWF,MAAMe,MAAM,EAAE;QAC3B,MAAMd,KACHe,MAAM,CAAChB,MAAMe,MAAM,EAAE;YACpBE,MAAMjB,MAAMkB,gBAAgB;YAC5BC,OAAOtB,iBAAiBG,MAAMY,MAAM,CAACQ,IAAI;YACzCC,MACE,GAAGrB,MAAMsB,YAAY,CAAC,EAAE,EAAE1B,kBAAkBI,MAAMY,MAAM,CAACQ,IAAI,GAAG,GAC/DpB,CAAAA,MAAMY,MAAM,CAACW,MAAM,GAChB,CAAC,cAAc,EAAEvB,MAAMY,MAAM,CAACW,MAAM,CAACC,OAAO,CAAC,MAAM,KAAK,CAAC,CAAC,GAC1D,EAAC,IACL,CAAC,CAAC,EAAE9B,eAAe;YACrB+B,MAAMzB,MAAMyB,IAAI;QAClB,GACCC,KAAK,CAAC,IAAMC;IACjB;IACA,OAAOzB;AACT;AAEA;;;CAGC,GACD,OAAO,eAAe0B,iCACpBpB,GAAwC,EACxCqB,SAAiB,EACjBC,OAAe,EACf7B,IAAgD;IAEhD,OAAOA,KAAKE,SAAS,CAACC,cAAc,CAAC,OAAOC;QAC1C,MAAMC,QAAQ,MAAMD,YAAYE,GAAG,CAACC;QACpC,IAAI,CAACF,MAAMG,MAAM,EAAE,OAAO;QAC1B,MAAME,OAAOb,yBACXQ,MAAMC,GAAG,CAACZ,qBACVkC,WACAC;QAEF,IAAI,CAACnB,MAAM,OAAO;QAClBN,YAAYQ,MAAM,CAACL,KAAK;YAAE,CAACb,mBAAmB,EAAEgB;QAAK;QACrD,OAAO;IACT;AACF"}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/ import { FieldValue, Timestamp } from "firebase-admin/firestore";
|
|
17
17
|
import { firebaseAdmin } from "./firebase-admin.js";
|
|
18
18
|
import { eraseOrg, eraseUser } from "./erase.js";
|
|
19
|
+
import { addAdminAudit } from "./admin-audit-write.js";
|
|
19
20
|
/**
|
|
20
21
|
* How long `adminAudit` rows stay queryable before `/api/admin/audit-archive`
|
|
21
22
|
* moves them to Storage. A replay window older than this cannot be answered
|
|
@@ -187,7 +188,7 @@ import { eraseOrg, eraseUser } from "./erase.js";
|
|
|
187
188
|
// a real run only: a plan did not replay anything, and an audit row saying
|
|
188
189
|
// it did is the mistake this shape has to make impossible.
|
|
189
190
|
if (!dryRun) {
|
|
190
|
-
await firestore
|
|
191
|
+
await addAdminAudit(firestore, {
|
|
191
192
|
actorUid,
|
|
192
193
|
action: 'erasures.replayed',
|
|
193
194
|
target: `restore/${new Date(sinceMs).toISOString()}`,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/replay-erasures.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\nimport { FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport { firebaseAdmin } from './firebase-admin'\nimport { eraseOrg, eraseUser } from './erase'\n\n/**\n * How long `adminAudit` rows stay queryable before `/api/admin/audit-archive`\n * moves them to Storage. A replay window older than this cannot be answered\n * from Firestore at all, which is the one failure mode that must not look\n * like \"nothing to replay\".\n */\nexport const AUDIT_HOT_WINDOW_MS = 90 * 24 * 60 * 60 * 1000\n\n/** What happened to one erasure that had to be re-applied. */\nexport interface ReplayedErasure {\n kind: 'org' | 'user'\n /** The org id or uid the original erasure destroyed. */\n id: string\n /**\n * - `replayed` — it had come back and has been erased again.\n * - `absent` — nothing was resurrected; the restore did not reach it.\n * - `blocked` — it came back and could NOT be erased again. The reason is\n * on `detail`, and this is the outcome a human has to act on.\n */\n outcome: 'replayed' | 'absent' | 'blocked'\n detail?: string\n}\n\nexport interface ReplayErasuresResult {\n /** Snapshot instant the restore was taken from — the replay's lower bound. */\n sinceMs: number\n /** True when nothing was written: a plan, not a replay. */\n dryRun: boolean\n /**\n * Set when the answer cannot be trusted. Today the only value is\n * `'audit-window'`: `sinceMs` predates the hot `adminAudit` window, so rows\n * naming erasures inside the restored span may already be in the Storage\n * archive and this run cannot see them. **An empty list under this warning\n * is not evidence that there was nothing to replay.**\n */\n incomplete?: 'audit-window'\n /** Audit rows examined. */\n examined: number\n entries: ReplayedErasure[]\n /** Convenience for the exit code — any `blocked` entry, or `incomplete`. */\n ok: boolean\n}\n\n/**\n * Re-apply every erasure a restore resurrected (AGL-1975).\n *\n * Live DPA §11 promises, in terms: *\"a deletion instruction survives any\n * restoration — data deleted at Customer's instruction and later restored from\n * a backup will be deleted again.\"* Nothing implemented it. This does.\n *\n * **The reason it is needed is specific, not theoretical.** A Firestore import\n * is **merge-by-id, not replace** (`docs/DISASTER_RECOVERY.md` Procedures C\n * and D both say so), so importing a pre-erasure export into `(default)`\n * silently reinstates every document an erasure deleted — the whole org tree,\n * the profile, the support threads — alongside the recovery it was actually\n * run for. It happens during an incident, when nobody is reading a DPA, and\n * nothing anywhere says it happened.\n *\n * **It calls `eraseOrg`/`eraseUser` and implements no cascade of its own.**\n * That is the AGL-1481 rule and this is exactly the shape it was written for:\n * a replay tool with its own copy of the sweep list would be a second\n * implementation of a cascade delete, and the last one drifted within a week —\n * so a replay would faithfully re-erase the six collections somebody\n * remembered and quietly leave the four they did not.\n *\n * ## The two things that make a naive replay fail\n *\n * 1. **`eraseOrg` refuses without a live request.** It re-reads\n * `erasureRequestedAt` and the 7-day hold, deliberately, so a stale or\n * cancelled request can never delete. A restored org may carry no request\n * at all: if the customer asked AFTER the snapshot was taken, the\n * resurrected document predates the ask. The audit row is what closes\n * that — `after.requestedAt` records which request the original run\n * fulfilled — so the request is reinstated at its original instant before\n * the erasure is re-run. The hold is satisfied by construction: that\n * instant is in the past by at least the hold, or the first erasure would\n * not have executed either.\n * 2. **`eraseUser` refuses an org owner.** A restore can bring back both a\n * person and the workspace they owned, and `userErasureBlockers` then\n * correctly refuses to cascade. That is reported as `blocked`, never\n * forced: cascading here would delete a workspace as a side effect of\n * replaying a personal-account erasure, which is precisely the consent\n * boundary `eraseUser` exists to hold. Erase the org first — usually it\n * has its own audit row in the same list — and run this again.\n *\n * ## Why the audit window is a first-class result and not a log line\n *\n * The rows this reads are hot for 90 days before the archiver moves them to\n * Storage. That comfortably covers the 7-day PITR window and the ≤7-day usable\n * managed backup. It does **not** cover a 90-day-old GCS export, where the\n * erasure record and the export can age out within days of each other. A\n * replay over a window that old returns an empty list for two\n * indistinguishable reasons — nothing was erased, or nothing is left to read —\n * so it says `incomplete: 'audit-window'` and reports `ok: false`. An empty\n * list is not the same as a clean bill, and this is the one place that\n * distinction decides whether a published promise was kept.\n *\n * The query is a range on `at` alone, with the action filtered in memory\n * rather than in the query. `where('action','in',[…]).where('at','>=',…)`\n * needs a composite index that does not exist, and an erasure replay is not\n * where anybody should discover a `FAILED_PRECONDITION` — a single-field range\n * is auto-indexed everywhere, including a freshly restored database whose\n * composite indexes are still building.\n */\nexport async function replayErasuresSince(options: {\n /** The instant the restored snapshot was taken. */\n sinceMs: number\n /** Report what WOULD be re-erased and change nothing. Default true. */\n dryRun?: boolean\n /** Who is answerable, for the new audit rows. */\n actorUid?: string\n now?: number\n}): Promise<ReplayErasuresResult> {\n const {\n sinceMs,\n dryRun = true,\n actorUid = 'script:replay-erasures',\n now = Date.now(),\n } = options\n const firestore = firebaseAdmin.app().firestore()\n\n const incomplete =\n now - sinceMs > AUDIT_HOT_WINDOW_MS ? ('audit-window' as const) : undefined\n\n const rows = await firestore\n .collection('adminAudit')\n .where('at', '>=', Timestamp.fromMillis(sinceMs))\n .orderBy('at', 'asc')\n .get()\n\n const entries: ReplayedErasure[] = []\n for (const row of rows.docs) {\n const action = row.get('action') as string | undefined\n if (action !== 'org.erased' && action !== 'user.erased') continue\n const target = String(row.get('target') ?? '')\n const kind = action === 'org.erased' ? 'org' : 'user'\n const id = target.split('/')[1] ?? ''\n if (!id) continue\n\n if (kind === 'org') {\n const orgRef = firestore.collection('orgs').doc(id)\n const org = await orgRef.get()\n if (!org.exists) {\n entries.push({ kind, id, outcome: 'absent' })\n continue\n }\n // Which request the original run fulfilled. Without it a restored org\n // that was never asked to be erased before the snapshot is refused with\n // `no-request` — the erasure would silently not replay.\n const requestedAt = Number(row.get('after')?.requestedAt ?? 0)\n if (dryRun) {\n entries.push({\n kind,\n id,\n outcome: 'replayed',\n detail: 'would re-erase (plan)',\n })\n continue\n }\n if (requestedAt) {\n await orgRef.set(\n { erasureRequestedAt: Timestamp.fromMillis(requestedAt) },\n { merge: true },\n )\n }\n const result = await eraseOrg(id, { actorUid })\n entries.push(\n result.ok\n ? { kind, id, outcome: 'replayed' }\n : { kind, id, outcome: 'blocked', detail: result.skippedReason },\n )\n continue\n }\n\n // A person. `profiles/{uid}` is checked as well as `users/{uid}`: the\n // public identity is a separate top-level document (AGL-1970), so a\n // restore can bring it back on its own.\n const [user, profile] = await Promise.all([\n firestore.collection('users').doc(id).get(),\n firestore.collection('profiles').doc(id).get(),\n ])\n if (!user.exists && !profile.exists) {\n entries.push({ kind, id, outcome: 'absent' })\n continue\n }\n if (dryRun) {\n entries.push({\n kind,\n id,\n outcome: 'replayed',\n detail: 'would re-erase (plan)',\n })\n continue\n }\n const result = await eraseUser(id)\n entries.push(\n result.ok\n ? { kind, id, outcome: 'replayed' }\n : {\n kind,\n id,\n outcome: 'blocked',\n // `owns-orgs` is the one a human has to act on: erase the\n // workspace first, then run this again.\n detail:\n result.skippedReason === 'owns-orgs'\n ? `owns-orgs (${(result.blockers ?? [])\n .map((blocker) => blocker.orgId)\n .join(', ')})`\n : result.skippedReason,\n },\n )\n }\n\n const blocked = entries.some((entry) => entry.outcome === 'blocked')\n\n // A record that the promise was kept — or that it could not be. Written on\n // a real run only: a plan did not replay anything, and an audit row saying\n // it did is the mistake this shape has to make impossible.\n if (!dryRun) {\n await firestore\n .collection('adminAudit')\n .add({\n actorUid,\n action: 'erasures.replayed',\n target: `restore/${new Date(sinceMs).toISOString()}`,\n before: { examined: rows.size },\n after: {\n sinceMs,\n incomplete: incomplete ?? null,\n replayed: entries.filter((e) => e.outcome === 'replayed').length,\n absent: entries.filter((e) => e.outcome === 'absent').length,\n blocked: entries.filter((e) => e.outcome === 'blocked').length,\n },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n }\n\n return {\n sinceMs,\n dryRun,\n incomplete,\n examined: rows.size,\n entries,\n ok: !blocked && !incomplete,\n }\n}\n"],"names":["FieldValue","Timestamp","firebaseAdmin","eraseOrg","eraseUser","AUDIT_HOT_WINDOW_MS","replayErasuresSince","options","sinceMs","dryRun","actorUid","now","Date","firestore","app","incomplete","undefined","rows","collection","where","fromMillis","orderBy","get","entries","row","docs","target","result","action","String","kind","id","split","orgRef","doc","org","exists","push","outcome","requestedAt","Number","detail","set","erasureRequestedAt","merge","ok","skippedReason","user","profile","Promise","all","blockers","map","blocker","orgId","join","blocked","some","entry","add","toISOString","before","examined","size","after","replayed","filter","e","length","absent","at","serverTimestamp","catch"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/replay-erasures.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\nimport { FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport { firebaseAdmin } from './firebase-admin'\nimport { eraseOrg, eraseUser } from './erase'\nimport { addAdminAudit } from './admin-audit-write'\n\n/**\n * How long `adminAudit` rows stay queryable before `/api/admin/audit-archive`\n * moves them to Storage. A replay window older than this cannot be answered\n * from Firestore at all, which is the one failure mode that must not look\n * like \"nothing to replay\".\n */\nexport const AUDIT_HOT_WINDOW_MS = 90 * 24 * 60 * 60 * 1000\n\n/** What happened to one erasure that had to be re-applied. */\nexport interface ReplayedErasure {\n kind: 'org' | 'user'\n /** The org id or uid the original erasure destroyed. */\n id: string\n /**\n * - `replayed` — it had come back and has been erased again.\n * - `absent` — nothing was resurrected; the restore did not reach it.\n * - `blocked` — it came back and could NOT be erased again. The reason is\n * on `detail`, and this is the outcome a human has to act on.\n */\n outcome: 'replayed' | 'absent' | 'blocked'\n detail?: string\n}\n\nexport interface ReplayErasuresResult {\n /** Snapshot instant the restore was taken from — the replay's lower bound. */\n sinceMs: number\n /** True when nothing was written: a plan, not a replay. */\n dryRun: boolean\n /**\n * Set when the answer cannot be trusted. Today the only value is\n * `'audit-window'`: `sinceMs` predates the hot `adminAudit` window, so rows\n * naming erasures inside the restored span may already be in the Storage\n * archive and this run cannot see them. **An empty list under this warning\n * is not evidence that there was nothing to replay.**\n */\n incomplete?: 'audit-window'\n /** Audit rows examined. */\n examined: number\n entries: ReplayedErasure[]\n /** Convenience for the exit code — any `blocked` entry, or `incomplete`. */\n ok: boolean\n}\n\n/**\n * Re-apply every erasure a restore resurrected (AGL-1975).\n *\n * Live DPA §11 promises, in terms: *\"a deletion instruction survives any\n * restoration — data deleted at Customer's instruction and later restored from\n * a backup will be deleted again.\"* Nothing implemented it. This does.\n *\n * **The reason it is needed is specific, not theoretical.** A Firestore import\n * is **merge-by-id, not replace** (`docs/DISASTER_RECOVERY.md` Procedures C\n * and D both say so), so importing a pre-erasure export into `(default)`\n * silently reinstates every document an erasure deleted — the whole org tree,\n * the profile, the support threads — alongside the recovery it was actually\n * run for. It happens during an incident, when nobody is reading a DPA, and\n * nothing anywhere says it happened.\n *\n * **It calls `eraseOrg`/`eraseUser` and implements no cascade of its own.**\n * That is the AGL-1481 rule and this is exactly the shape it was written for:\n * a replay tool with its own copy of the sweep list would be a second\n * implementation of a cascade delete, and the last one drifted within a week —\n * so a replay would faithfully re-erase the six collections somebody\n * remembered and quietly leave the four they did not.\n *\n * ## The two things that make a naive replay fail\n *\n * 1. **`eraseOrg` refuses without a live request.** It re-reads\n * `erasureRequestedAt` and the 7-day hold, deliberately, so a stale or\n * cancelled request can never delete. A restored org may carry no request\n * at all: if the customer asked AFTER the snapshot was taken, the\n * resurrected document predates the ask. The audit row is what closes\n * that — `after.requestedAt` records which request the original run\n * fulfilled — so the request is reinstated at its original instant before\n * the erasure is re-run. The hold is satisfied by construction: that\n * instant is in the past by at least the hold, or the first erasure would\n * not have executed either.\n * 2. **`eraseUser` refuses an org owner.** A restore can bring back both a\n * person and the workspace they owned, and `userErasureBlockers` then\n * correctly refuses to cascade. That is reported as `blocked`, never\n * forced: cascading here would delete a workspace as a side effect of\n * replaying a personal-account erasure, which is precisely the consent\n * boundary `eraseUser` exists to hold. Erase the org first — usually it\n * has its own audit row in the same list — and run this again.\n *\n * ## Why the audit window is a first-class result and not a log line\n *\n * The rows this reads are hot for 90 days before the archiver moves them to\n * Storage. That comfortably covers the 7-day PITR window and the ≤7-day usable\n * managed backup. It does **not** cover a 90-day-old GCS export, where the\n * erasure record and the export can age out within days of each other. A\n * replay over a window that old returns an empty list for two\n * indistinguishable reasons — nothing was erased, or nothing is left to read —\n * so it says `incomplete: 'audit-window'` and reports `ok: false`. An empty\n * list is not the same as a clean bill, and this is the one place that\n * distinction decides whether a published promise was kept.\n *\n * The query is a range on `at` alone, with the action filtered in memory\n * rather than in the query. `where('action','in',[…]).where('at','>=',…)`\n * needs a composite index that does not exist, and an erasure replay is not\n * where anybody should discover a `FAILED_PRECONDITION` — a single-field range\n * is auto-indexed everywhere, including a freshly restored database whose\n * composite indexes are still building.\n */\nexport async function replayErasuresSince(options: {\n /** The instant the restored snapshot was taken. */\n sinceMs: number\n /** Report what WOULD be re-erased and change nothing. Default true. */\n dryRun?: boolean\n /** Who is answerable, for the new audit rows. */\n actorUid?: string\n now?: number\n}): Promise<ReplayErasuresResult> {\n const {\n sinceMs,\n dryRun = true,\n actorUid = 'script:replay-erasures',\n now = Date.now(),\n } = options\n const firestore = firebaseAdmin.app().firestore()\n\n const incomplete =\n now - sinceMs > AUDIT_HOT_WINDOW_MS ? ('audit-window' as const) : undefined\n\n const rows = await firestore\n .collection('adminAudit')\n .where('at', '>=', Timestamp.fromMillis(sinceMs))\n .orderBy('at', 'asc')\n .get()\n\n const entries: ReplayedErasure[] = []\n for (const row of rows.docs) {\n const action = row.get('action') as string | undefined\n if (action !== 'org.erased' && action !== 'user.erased') continue\n const target = String(row.get('target') ?? '')\n const kind = action === 'org.erased' ? 'org' : 'user'\n const id = target.split('/')[1] ?? ''\n if (!id) continue\n\n if (kind === 'org') {\n const orgRef = firestore.collection('orgs').doc(id)\n const org = await orgRef.get()\n if (!org.exists) {\n entries.push({ kind, id, outcome: 'absent' })\n continue\n }\n // Which request the original run fulfilled. Without it a restored org\n // that was never asked to be erased before the snapshot is refused with\n // `no-request` — the erasure would silently not replay.\n const requestedAt = Number(row.get('after')?.requestedAt ?? 0)\n if (dryRun) {\n entries.push({\n kind,\n id,\n outcome: 'replayed',\n detail: 'would re-erase (plan)',\n })\n continue\n }\n if (requestedAt) {\n await orgRef.set(\n { erasureRequestedAt: Timestamp.fromMillis(requestedAt) },\n { merge: true },\n )\n }\n const result = await eraseOrg(id, { actorUid })\n entries.push(\n result.ok\n ? { kind, id, outcome: 'replayed' }\n : { kind, id, outcome: 'blocked', detail: result.skippedReason },\n )\n continue\n }\n\n // A person. `profiles/{uid}` is checked as well as `users/{uid}`: the\n // public identity is a separate top-level document (AGL-1970), so a\n // restore can bring it back on its own.\n const [user, profile] = await Promise.all([\n firestore.collection('users').doc(id).get(),\n firestore.collection('profiles').doc(id).get(),\n ])\n if (!user.exists && !profile.exists) {\n entries.push({ kind, id, outcome: 'absent' })\n continue\n }\n if (dryRun) {\n entries.push({\n kind,\n id,\n outcome: 'replayed',\n detail: 'would re-erase (plan)',\n })\n continue\n }\n const result = await eraseUser(id)\n entries.push(\n result.ok\n ? { kind, id, outcome: 'replayed' }\n : {\n kind,\n id,\n outcome: 'blocked',\n // `owns-orgs` is the one a human has to act on: erase the\n // workspace first, then run this again.\n detail:\n result.skippedReason === 'owns-orgs'\n ? `owns-orgs (${(result.blockers ?? [])\n .map((blocker) => blocker.orgId)\n .join(', ')})`\n : result.skippedReason,\n },\n )\n }\n\n const blocked = entries.some((entry) => entry.outcome === 'blocked')\n\n // A record that the promise was kept — or that it could not be. Written on\n // a real run only: a plan did not replay anything, and an audit row saying\n // it did is the mistake this shape has to make impossible.\n if (!dryRun) {\n await addAdminAudit(firestore, {\n actorUid,\n action: 'erasures.replayed',\n target: `restore/${new Date(sinceMs).toISOString()}`,\n before: { examined: rows.size },\n after: {\n sinceMs,\n incomplete: incomplete ?? null,\n replayed: entries.filter((e) => e.outcome === 'replayed').length,\n absent: entries.filter((e) => e.outcome === 'absent').length,\n blocked: entries.filter((e) => e.outcome === 'blocked').length,\n },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n }\n\n return {\n sinceMs,\n dryRun,\n incomplete,\n examined: rows.size,\n entries,\n ok: !blocked && !incomplete,\n }\n}\n"],"names":["FieldValue","Timestamp","firebaseAdmin","eraseOrg","eraseUser","addAdminAudit","AUDIT_HOT_WINDOW_MS","replayErasuresSince","options","sinceMs","dryRun","actorUid","now","Date","firestore","app","incomplete","undefined","rows","collection","where","fromMillis","orderBy","get","entries","row","docs","target","result","action","String","kind","id","split","orgRef","doc","org","exists","push","outcome","requestedAt","Number","detail","set","erasureRequestedAt","merge","ok","skippedReason","user","profile","Promise","all","blockers","map","blocker","orgId","join","blocked","some","entry","toISOString","before","examined","size","after","replayed","filter","e","length","absent","at","serverTimestamp","catch"],"mappings":"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"}
|
|
@@ -360,5 +360,11 @@ export declare function resolveHostSendingIdentity(options: {
|
|
|
360
360
|
* declares a cache has also declared how long it may be trusted.
|
|
361
361
|
*/
|
|
362
362
|
export declare function hostSendingIdentity(hostId: string | null | undefined, cache?: Map<string, SendingIdentityVerdict>): Promise<SendingIdentityVerdict>;
|
|
363
|
+
/**
|
|
364
|
+
* The verdict a suspended site's mail gets: a refusal, whatever identity the
|
|
365
|
+
* site would otherwise have sent on. `sendEmail` reads it and answers
|
|
366
|
+
* `suspended` without calling the provider.
|
|
367
|
+
*/
|
|
368
|
+
export declare function suspendedSendingIdentity(scope: string | null | undefined): SendingIdentityVerdict;
|
|
363
369
|
/** The record keys a surface highlights as outstanding. */
|
|
364
370
|
export declare function outstandingSendingRecords(record: SendingDomainRecord): SendingDnsRecord[];
|
|
@@ -65,9 +65,16 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
65
65
|
* stopped there refuses sends, which is the correct behavior for a domain that
|
|
66
66
|
* has no signing key.
|
|
67
67
|
*/ import { assessDmarc, assessSendingRecords, isPlatformSendingDomain, isSharedSendingDomain, normalizeLocalPart, normalizeSendingDomain, resolveSendingIdentity, safeProviderDetail, SENDING_SUBDOMAIN, sendingDnsRecords, sendingTrackingHost, sendingDomainRequiredRecords, sendingRecordKey, sharedTenantSendingFrom, validateSendingDomain } from "@aglyn/shared-util-email";
|
|
68
|
+
import { lockdownMode } from "@aglyn/aglyn/server";
|
|
68
69
|
import firebaseAdmin from "./firebase-admin.js";
|
|
69
70
|
import { lookupCaa, lookupMx, lookupTxt } from "./dns-probe.js";
|
|
70
71
|
import { getOrgForHost } from "./organizations.js";
|
|
72
|
+
import { getSiteLockdown } from "./tenant-write-lockdown.js";
|
|
73
|
+
// Also INSTALLS the outbound phishing screen's review store on `sendEmail`
|
|
74
|
+
// (AGL-3356): every sender whose identity comes from `hostSendingIdentity`
|
|
75
|
+
// has therefore installed the gate the workspace stamped below is read by.
|
|
76
|
+
import { sendingWorkspaceFor } from "./outbound-send-review.js";
|
|
77
|
+
import { flagLookalikeDomain } from "./hosted-page-review.js";
|
|
71
78
|
const firestore = ()=>firebaseAdmin.app().firestore();
|
|
72
79
|
/** Subcollection under the owning org. */ export const SENDING_DOMAINS_COLLECTION = 'sendingDomains';
|
|
73
80
|
const domainRef = (orgId, domain)=>firestore().collection('orgs').doc(orgId).collection(SENDING_DOMAINS_COLLECTION).doc(domain);
|
|
@@ -150,6 +157,15 @@ const domainRef = (orgId, domain)=>firestore().collection('orgs').doc(orgId).col
|
|
|
150
157
|
await ref.set(record, {
|
|
151
158
|
merge: true
|
|
152
159
|
});
|
|
160
|
+
// A domain that wears a brand (`paypa1.com`) goes to staff in the abuse
|
|
161
|
+
// queue the moment it is claimed — flagged, not refused (AGL-3362). Mail
|
|
162
|
+
// from it is held at the send seam whatever staff decide. Never throws.
|
|
163
|
+
await flagLookalikeDomain({
|
|
164
|
+
kind: 'sending',
|
|
165
|
+
hostId: null,
|
|
166
|
+
orgId: options.orgId,
|
|
167
|
+
domain
|
|
168
|
+
});
|
|
153
169
|
return {
|
|
154
170
|
record,
|
|
155
171
|
error: null,
|
|
@@ -489,6 +505,17 @@ export async function listSendingDomains(orgId) {
|
|
|
489
505
|
}), {
|
|
490
506
|
merge: true
|
|
491
507
|
});
|
|
508
|
+
// Again on verification, the moment it can actually send (AGL-3362): a
|
|
509
|
+
// claim added before this check existed is caught here. One row per
|
|
510
|
+
// domain, so this counts rather than re-alerts.
|
|
511
|
+
if (verified) {
|
|
512
|
+
await flagLookalikeDomain({
|
|
513
|
+
kind: 'sending',
|
|
514
|
+
hostId: null,
|
|
515
|
+
orgId,
|
|
516
|
+
domain
|
|
517
|
+
});
|
|
518
|
+
}
|
|
492
519
|
return {
|
|
493
520
|
record: readSendingDomainRecord(await ref.get()),
|
|
494
521
|
missing: verdict.missing,
|
|
@@ -643,7 +670,7 @@ export async function listSendingDomains(orgId) {
|
|
|
643
670
|
* a site sending on a domain that no longer authenticates. A caller that
|
|
644
671
|
* declares a cache has also declared how long it may be trusted.
|
|
645
672
|
*/ export async function hostSendingIdentity(hostId, cache) {
|
|
646
|
-
var _ref, _ref1, _ref2, _ref3;
|
|
673
|
+
var _ref, _snapshot_data, _ref1, _ref2, _ref3, _ref4, _ref5, _ref6, _snapshot_data1;
|
|
647
674
|
const id = String(hostId != null ? hostId : '').trim();
|
|
648
675
|
if (!id) {
|
|
649
676
|
/*
|
|
@@ -663,16 +690,81 @@ export async function listSendingDomains(orgId) {
|
|
|
663
690
|
if (hit) return hit;
|
|
664
691
|
const snapshot = await firestore().collection('hosts').doc(id).get().catch(()=>null);
|
|
665
692
|
const owner = await getOrgForHost(id).catch(()=>null);
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
693
|
+
/*
|
|
694
|
+
* A SUSPENDED SITE SENDS NOTHING (AGL-3356).
|
|
695
|
+
*
|
|
696
|
+
* This is the door every tenant sender but the campaign core uses, so it
|
|
697
|
+
* is where one check covers them all — workflow and automation steps, the
|
|
698
|
+
* CRM's one-off mail, inbox replies, member posts, receipts, reminders.
|
|
699
|
+
* Refusing here rather than at each caller is the same argument this
|
|
700
|
+
* function's own docblock makes about identities: nineteen call sites
|
|
701
|
+
* asked to remember produce a twentieth that does not, and the incident
|
|
702
|
+
* behind this was exactly that — a locked workspace whose workflow could
|
|
703
|
+
* still mail for it.
|
|
704
|
+
*
|
|
705
|
+
* FULL locks only. A read-only lock is a maintenance window whose point
|
|
706
|
+
* is that the site keeps serving and earning, and a receipt for an order
|
|
707
|
+
* taken before it began must still arrive. Staff suspensions for abuse,
|
|
708
|
+
* fraud and billing are full locks, which is the case this closes.
|
|
709
|
+
*
|
|
710
|
+
* The documents read above are handed to the verdict, so the check costs
|
|
711
|
+
* the TTL-cached platform read and nothing else. It inherits the
|
|
712
|
+
* verdict's fail-open posture and its takedown ratchet.
|
|
713
|
+
*/ const lock = await getSiteLockdown(id, Date.now(), {
|
|
714
|
+
org: (_ref = owner == null ? void 0 : owner.org) != null ? _ref : null,
|
|
715
|
+
host: (snapshot == null ? void 0 : snapshot.exists) ? (_snapshot_data = snapshot.data()) != null ? _snapshot_data : null : null
|
|
716
|
+
});
|
|
717
|
+
if (lock && lockdownMode(lock) === 'full') {
|
|
718
|
+
const suspended = suspendedSendingIdentity(lock.scope);
|
|
719
|
+
cache == null ? void 0 : cache.set(id, suspended);
|
|
720
|
+
return suspended;
|
|
721
|
+
}
|
|
722
|
+
const resolved = await resolveHostSendingIdentity({
|
|
723
|
+
orgId: (_ref1 = owner == null ? void 0 : owner.orgId) != null ? _ref1 : null,
|
|
724
|
+
selectedDomain: (_ref2 = snapshot == null ? void 0 : snapshot.get('sendingDomain')) != null ? _ref2 : '',
|
|
725
|
+
selectedLocalPart: (_ref3 = snapshot == null ? void 0 : snapshot.get('sendingLocalPart')) != null ? _ref3 : '',
|
|
670
726
|
hostId: id,
|
|
671
|
-
poolMember: (
|
|
727
|
+
poolMember: (_ref4 = snapshot == null ? void 0 : snapshot.get('sendingPoolMember')) != null ? _ref4 : ''
|
|
728
|
+
});
|
|
729
|
+
/*
|
|
730
|
+
* THE WORKSPACE, STAMPED FOR THE PHISHING SCREEN (AGL-3356).
|
|
731
|
+
*
|
|
732
|
+
* `sendEmail` screens every tenant message whose identity carries this,
|
|
733
|
+
* so stamping it here is what puts CRM mail, inbox replies, newsletters,
|
|
734
|
+
* member posts, sweeps, flow steps and receipts behind one screen without
|
|
735
|
+
* any of them passing anything. Built from the two documents read above:
|
|
736
|
+
* the screen costs no read of its own.
|
|
737
|
+
*/ const verdict = _extends({}, resolved, {
|
|
738
|
+
workspace: sendingWorkspaceFor({
|
|
739
|
+
hostId: id,
|
|
740
|
+
orgId: (_ref5 = owner == null ? void 0 : owner.orgId) != null ? _ref5 : null,
|
|
741
|
+
org: (_ref6 = owner == null ? void 0 : owner.org) != null ? _ref6 : null,
|
|
742
|
+
host: (snapshot == null ? void 0 : snapshot.exists) ? (_snapshot_data1 = snapshot.data()) != null ? _snapshot_data1 : null : null
|
|
743
|
+
})
|
|
672
744
|
});
|
|
673
745
|
cache == null ? void 0 : cache.set(id, verdict);
|
|
674
746
|
return verdict;
|
|
675
747
|
}
|
|
748
|
+
/**
|
|
749
|
+
* The verdict a suspended site's mail gets: a refusal, whatever identity the
|
|
750
|
+
* site would otherwise have sent on. `sendEmail` reads it and answers
|
|
751
|
+
* `suspended` without calling the provider.
|
|
752
|
+
*/ export function suspendedSendingIdentity(scope) {
|
|
753
|
+
const what = scope === 'platform' ? 'Sending is paused platform-wide' : scope === 'org' ? 'This workspace is suspended' : 'This site is suspended';
|
|
754
|
+
const message = `${what}, so no email is sent on its behalf until the suspension is lifted.`;
|
|
755
|
+
return {
|
|
756
|
+
from: null,
|
|
757
|
+
source: null,
|
|
758
|
+
domain: null,
|
|
759
|
+
summary: message,
|
|
760
|
+
refusal: {
|
|
761
|
+
code: 'workspace-suspended',
|
|
762
|
+
domain: null,
|
|
763
|
+
message,
|
|
764
|
+
missing: []
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
}
|
|
676
768
|
/** The record keys a surface highlights as outstanding. */ export function outstandingSendingRecords(record) {
|
|
677
769
|
var _ref;
|
|
678
770
|
const missing = new Set((_ref = record == null ? void 0 : record.lastMissing) != null ? _ref : []);
|