@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.
Files changed (96) hide show
  1. package/package.json +8 -7
  2. package/src/lib/server/admin-audit-write.d.ts +28 -0
  3. package/src/lib/server/admin-audit-write.js +46 -0
  4. package/src/lib/server/admin-audit-write.js.map +1 -0
  5. package/src/lib/server/admin-audit.d.ts +4 -9
  6. package/src/lib/server/admin-audit.js +12 -31
  7. package/src/lib/server/admin-audit.js.map +1 -1
  8. package/src/lib/server/consent-group-carry.js +2 -0
  9. package/src/lib/server/consent-group-carry.js.map +1 -1
  10. package/src/lib/server/contact-company-link.js +3 -0
  11. package/src/lib/server/contact-company-link.js.map +1 -1
  12. package/src/lib/server/contact-lifecycle-floor.js +3 -0
  13. package/src/lib/server/contact-lifecycle-floor.js.map +1 -1
  14. package/src/lib/server/contact-merge.js +4 -0
  15. package/src/lib/server/contact-merge.js.map +1 -1
  16. package/src/lib/server/crm-booking-activity.js +3 -2
  17. package/src/lib/server/crm-booking-activity.js.map +1 -1
  18. package/src/lib/server/crm-records.d.ts +15 -0
  19. package/src/lib/server/crm-records.js +52 -0
  20. package/src/lib/server/crm-records.js.map +1 -1
  21. package/src/lib/server/csp-aggregate.d.ts +16 -0
  22. package/src/lib/server/csp-aggregate.js +36 -0
  23. package/src/lib/server/csp-aggregate.js.map +1 -1
  24. package/src/lib/server/dns-probe.d.ts +7 -0
  25. package/src/lib/server/dns-probe.js +52 -0
  26. package/src/lib/server/dns-probe.js.map +1 -1
  27. package/src/lib/server/duplicate-resource.js +12 -5
  28. package/src/lib/server/duplicate-resource.js.map +1 -1
  29. package/src/lib/server/email-deliverability.d.ts +150 -0
  30. package/src/lib/server/email-deliverability.js +538 -0
  31. package/src/lib/server/email-deliverability.js.map +1 -0
  32. package/src/lib/server/email-delivery-log.d.ts +33 -0
  33. package/src/lib/server/email-delivery-log.js +43 -2
  34. package/src/lib/server/email-delivery-log.js.map +1 -1
  35. package/src/lib/server/email-sender-reputation.d.ts +2 -11
  36. package/src/lib/server/email-sender-reputation.js +8 -25
  37. package/src/lib/server/email-sender-reputation.js.map +1 -1
  38. package/src/lib/server/email-suppression.d.ts +21 -15
  39. package/src/lib/server/email-suppression.js +18 -34
  40. package/src/lib/server/email-suppression.js.map +1 -1
  41. package/src/lib/server/erase-person.js +53 -6
  42. package/src/lib/server/erase-person.js.map +1 -1
  43. package/src/lib/server/erase.js +4 -3
  44. package/src/lib/server/erase.js.map +1 -1
  45. package/src/lib/server/host-memberships.d.ts +34 -0
  46. package/src/lib/server/host-memberships.js +89 -1
  47. package/src/lib/server/host-memberships.js.map +1 -1
  48. package/src/lib/server/host-visitor-records.d.ts +16 -2
  49. package/src/lib/server/host-visitor-records.js +52 -7
  50. package/src/lib/server/host-visitor-records.js.map +1 -1
  51. package/src/lib/server/hosted-page-review.d.ts +92 -0
  52. package/src/lib/server/hosted-page-review.js +342 -0
  53. package/src/lib/server/hosted-page-review.js.map +1 -0
  54. package/src/lib/server/list-members.d.ts +11 -0
  55. package/src/lib/server/list-members.js +28 -1
  56. package/src/lib/server/list-members.js.map +1 -1
  57. package/src/lib/server/list-query.d.ts +36 -0
  58. package/src/lib/server/list-query.js +49 -0
  59. package/src/lib/server/list-query.js.map +1 -0
  60. package/src/lib/server/media-tombstone.js +8 -2
  61. package/src/lib/server/media-tombstone.js.map +1 -1
  62. package/src/lib/server/org-age.d.ts +27 -0
  63. package/src/lib/server/org-age.js +44 -0
  64. package/src/lib/server/org-age.js.map +1 -0
  65. package/src/lib/server/org-member-list-fields.d.ts +31 -0
  66. package/src/lib/server/org-member-list-fields.js +38 -0
  67. package/src/lib/server/org-member-list-fields.js.map +1 -0
  68. package/src/lib/server/organizations.js +37 -7
  69. package/src/lib/server/organizations.js.map +1 -1
  70. package/src/lib/server/outbound-send-review.d.ts +222 -0
  71. package/src/lib/server/outbound-send-review.js +479 -0
  72. package/src/lib/server/outbound-send-review.js.map +1 -0
  73. package/src/lib/server/payment-fraud-signal.d.ts +156 -0
  74. package/src/lib/server/payment-fraud-signal.js +368 -0
  75. package/src/lib/server/payment-fraud-signal.js.map +1 -0
  76. package/src/lib/server/payment-risk-record.d.ts +59 -0
  77. package/src/lib/server/payment-risk-record.js +76 -0
  78. package/src/lib/server/payment-risk-record.js.map +1 -0
  79. package/src/lib/server/replay-erasures.js +2 -1
  80. package/src/lib/server/replay-erasures.js.map +1 -1
  81. package/src/lib/server/sending-domains.d.ts +6 -0
  82. package/src/lib/server/sending-domains.js +98 -6
  83. package/src/lib/server/sending-domains.js.map +1 -1
  84. package/src/lib/server/sso-enforcement.js +2 -1
  85. package/src/lib/server/sso-enforcement.js.map +1 -1
  86. package/src/lib/server/stripe-card-authentication.d.ts +35 -0
  87. package/src/lib/server/stripe-card-authentication.js +63 -0
  88. package/src/lib/server/stripe-card-authentication.js.map +1 -0
  89. package/src/lib/server/tenant-write-lockdown.d.ts +32 -2
  90. package/src/lib/server/tenant-write-lockdown.js +33 -3
  91. package/src/lib/server/tenant-write-lockdown.js.map +1 -1
  92. package/src/lib/server/upsert-contact.js +8 -4
  93. package/src/lib/server/upsert-contact.js.map +1 -1
  94. package/src/tenancy.d.ts +3 -0
  95. package/src/tenancy.js +8 -0
  96. 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.collection('adminAudit').add({
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":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,EAAEC,SAAS,QAAQ,2BAA0B;AAChE,SAASC,aAAa,QAAQ,sBAAkB;AAChD,SAASC,QAAQ,EAAEC,SAAS,QAAQ,aAAS;AAE7C;;;;;CAKC,GACD,OAAO,MAAMC,sBAAsB,KAAK,KAAK,KAAK,KAAK,KAAI;AAqC3D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4DC,GACD,OAAO,eAAeC,oBAAoBC,OAQzC;IACC,MAAM,EACJC,OAAO,EACPC,SAAS,IAAI,EACbC,WAAW,wBAAwB,EACnCC,MAAMC,KAAKD,GAAG,EAAE,EACjB,GAAGJ;IACJ,MAAMM,YAAYX,cAAcY,GAAG,GAAGD,SAAS;IAE/C,MAAME,aACJJ,MAAMH,UAAUH,sBAAuB,iBAA2BW;IAEpE,MAAMC,OAAO,MAAMJ,UAChBK,UAAU,CAAC,cACXC,KAAK,CAAC,MAAM,MAAMlB,UAAUmB,UAAU,CAACZ,UACvCa,OAAO,CAAC,MAAM,OACdC,GAAG;IAEN,MAAMC,UAA6B,EAAE;IACrC,KAAK,MAAMC,OAAOP,KAAKQ,IAAI,CAAE;YAGLD,UAEXE,gBAsEkBC;QA1E7B,MAAMC,SAASJ,IAAIF,GAAG,CAAC;QACvB,IAAIM,WAAW,gBAAgBA,WAAW,eAAe;QACzD,MAAMF,SAASG,QAAOL,WAAAA,IAAIF,GAAG,CAAC,qBAARE,WAAqB;QAC3C,MAAMM,OAAOF,WAAW,eAAe,QAAQ;QAC/C,MAAMG,MAAKL,iBAAAA,OAAOM,KAAK,CAAC,IAAI,CAAC,EAAE,YAApBN,iBAAwB;QACnC,IAAI,CAACK,IAAI;QAET,IAAID,SAAS,OAAO;;gBAUSN;YAT3B,MAAMS,SAASpB,UAAUK,UAAU,CAAC,QAAQgB,GAAG,CAACH;YAChD,MAAMI,MAAM,MAAMF,OAAOX,GAAG;YAC5B,IAAI,CAACa,IAAIC,MAAM,EAAE;gBACfb,QAAQc,IAAI,CAAC;oBAAEP;oBAAMC;oBAAIO,SAAS;gBAAS;gBAC3C;YACF;YACA,sEAAsE;YACtE,wEAAwE;YACxE,wDAAwD;YACxD,MAAMC,cAAcC,gBAAOhB,YAAAA,IAAIF,GAAG,CAAC,6BAARE,UAAkBe,WAAW,mBAAI;YAC5D,IAAI9B,QAAQ;gBACVc,QAAQc,IAAI,CAAC;oBACXP;oBACAC;oBACAO,SAAS;oBACTG,QAAQ;gBACV;gBACA;YACF;YACA,IAAIF,aAAa;gBACf,MAAMN,OAAOS,GAAG,CACd;oBAAEC,oBAAoB1C,UAAUmB,UAAU,CAACmB;gBAAa,GACxD;oBAAEK,OAAO;gBAAK;YAElB;YACA,MAAMjB,SAAS,MAAMxB,SAAS4B,IAAI;gBAAErB;YAAS;YAC7Ca,QAAQc,IAAI,CACVV,OAAOkB,EAAE,GACL;gBAAEf;gBAAMC;gBAAIO,SAAS;YAAW,IAChC;gBAAER;gBAAMC;gBAAIO,SAAS;gBAAWG,QAAQd,OAAOmB,aAAa;YAAC;YAEnE;QACF;QAEA,sEAAsE;QACtE,oEAAoE;QACpE,wCAAwC;QACxC,MAAM,CAACC,MAAMC,QAAQ,GAAG,MAAMC,QAAQC,GAAG,CAAC;YACxCrC,UAAUK,UAAU,CAAC,SAASgB,GAAG,CAACH,IAAIT,GAAG;YACzCT,UAAUK,UAAU,CAAC,YAAYgB,GAAG,CAACH,IAAIT,GAAG;SAC7C;QACD,IAAI,CAACyB,KAAKX,MAAM,IAAI,CAACY,QAAQZ,MAAM,EAAE;YACnCb,QAAQc,IAAI,CAAC;gBAAEP;gBAAMC;gBAAIO,SAAS;YAAS;YAC3C;QACF;QACA,IAAI7B,QAAQ;YACVc,QAAQc,IAAI,CAAC;gBACXP;gBACAC;gBACAO,SAAS;gBACTG,QAAQ;YACV;YACA;QACF;QACA,MAAMd,SAAS,MAAMvB,UAAU2B;QAC/BR,QAAQc,IAAI,CACVV,OAAOkB,EAAE,GACL;YAAEf;YAAMC;YAAIO,SAAS;QAAW,IAChC;YACER;YACAC;YACAO,SAAS;YACT,0DAA0D;YAC1D,wCAAwC;YACxCG,QACEd,OAAOmB,aAAa,KAAK,cACrB,CAAC,WAAW,EAAE,EAACnB,mBAAAA,OAAOwB,QAAQ,YAAfxB,mBAAmB,EAAE,EACjCyB,GAAG,CAAC,CAACC,UAAYA,QAAQC,KAAK,EAC9BC,IAAI,CAAC,MAAM,CAAC,CAAC,GAChB5B,OAAOmB,aAAa;QAC5B;IAER;IAEA,MAAMU,UAAUjC,QAAQkC,IAAI,CAAC,CAACC,QAAUA,MAAMpB,OAAO,KAAK;IAE1D,2EAA2E;IAC3E,2EAA2E;IAC3E,2DAA2D;IAC3D,IAAI,CAAC7B,QAAQ;QACX,MAAMI,UACHK,UAAU,CAAC,cACXyC,GAAG,CAAC;YACHjD;YACAkB,QAAQ;YACRF,QAAQ,CAAC,QAAQ,EAAE,IAAId,KAAKJ,SAASoD,WAAW,IAAI;YACpDC,QAAQ;gBAAEC,UAAU7C,KAAK8C,IAAI;YAAC;YAC9BC,OAAO;gBACLxD;gBACAO,UAAU,EAAEA,qBAAAA,aAAc;gBAC1BkD,UAAU1C,QAAQ2C,MAAM,CAAC,CAACC,IAAMA,EAAE7B,OAAO,KAAK,YAAY8B,MAAM;gBAChEC,QAAQ9C,QAAQ2C,MAAM,CAAC,CAACC,IAAMA,EAAE7B,OAAO,KAAK,UAAU8B,MAAM;gBAC5DZ,SAASjC,QAAQ2C,MAAM,CAAC,CAACC,IAAMA,EAAE7B,OAAO,KAAK,WAAW8B,MAAM;YAChE;YACAE,IAAItE,WAAWuE,eAAe;QAChC,GACCC,KAAK,CAAC,IAAMxD;IACjB;IAEA,OAAO;QACLR;QACAC;QACAM;QACA+C,UAAU7C,KAAK8C,IAAI;QACnBxC;QACAsB,IAAI,CAACW,WAAW,CAACzC;IACnB;AACF"}
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
- const verdict = await resolveHostSendingIdentity({
667
- orgId: (_ref = owner == null ? void 0 : owner.orgId) != null ? _ref : null,
668
- selectedDomain: (_ref1 = snapshot == null ? void 0 : snapshot.get('sendingDomain')) != null ? _ref1 : '',
669
- selectedLocalPart: (_ref2 = snapshot == null ? void 0 : snapshot.get('sendingLocalPart')) != null ? _ref2 : '',
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: (_ref3 = snapshot == null ? void 0 : snapshot.get('sendingPoolMember')) != null ? _ref3 : ''
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 : []);