@aglyn/tenant-data-admin 1.0.0-beta.221 → 1.0.0-beta.222

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 (104) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/api-keys.js +3 -3
  3. package/src/lib/server/api-keys.js.map +1 -1
  4. package/src/lib/server/api-v1-resources.d.ts +44 -3
  5. package/src/lib/server/api-v1-resources.js +82 -5
  6. package/src/lib/server/api-v1-resources.js.map +1 -1
  7. package/src/lib/server/collection-live-pages.d.ts +2 -2
  8. package/src/lib/server/collection-live-pages.js +1 -1
  9. package/src/lib/server/collection-live-pages.js.map +1 -1
  10. package/src/lib/server/email-delivery-log.d.ts +17 -73
  11. package/src/lib/server/email-delivery-log.js +44 -222
  12. package/src/lib/server/email-delivery-log.js.map +1 -1
  13. package/src/lib/server/email-flow-gate.js +27 -38
  14. package/src/lib/server/email-flow-gate.js.map +1 -1
  15. package/src/lib/server/erase-person.d.ts +34 -54
  16. package/src/lib/server/erase-person.js +44 -239
  17. package/src/lib/server/erase-person.js.map +1 -1
  18. package/src/lib/server/host-visitor-records.d.ts +12 -4
  19. package/src/lib/server/host-visitor-records.js +20 -10
  20. package/src/lib/server/host-visitor-records.js.map +1 -1
  21. package/src/lib/server/hosted-page-review.js +46 -33
  22. package/src/lib/server/hosted-page-review.js.map +1 -1
  23. package/src/lib/server/link-reputation-review.d.ts +6 -2
  24. package/src/lib/server/link-reputation-review.js +61 -20
  25. package/src/lib/server/link-reputation-review.js.map +1 -1
  26. package/src/lib/server/live-page-drops.d.ts +67 -0
  27. package/src/lib/server/live-page-drops.js +57 -0
  28. package/src/lib/server/live-page-drops.js.map +1 -0
  29. package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
  30. package/src/lib/server/media-bandwidth-cap.js +180 -0
  31. package/src/lib/server/media-bandwidth-cap.js.map +1 -0
  32. package/src/lib/server/media-cdn-icon.d.ts +77 -0
  33. package/src/lib/server/media-cdn-icon.js +140 -0
  34. package/src/lib/server/media-cdn-icon.js.map +1 -0
  35. package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
  36. package/src/lib/server/media-delivery-regeneration.js +216 -0
  37. package/src/lib/server/media-delivery-regeneration.js.map +1 -0
  38. package/src/lib/server/media-delivery.d.ts +2 -0
  39. package/src/lib/server/media-delivery.js +2 -1
  40. package/src/lib/server/media-delivery.js.map +1 -1
  41. package/src/lib/server/media-serve-count.d.ts +50 -0
  42. package/src/lib/server/media-serve-count.js +62 -0
  43. package/src/lib/server/media-serve-count.js.map +1 -0
  44. package/src/lib/server/media-signing.d.ts +13 -2
  45. package/src/lib/server/media-signing.js +12 -8
  46. package/src/lib/server/media-signing.js.map +1 -1
  47. package/src/lib/server/media-tombstone.js +7 -5
  48. package/src/lib/server/media-tombstone.js.map +1 -1
  49. package/src/lib/server/media-variants.d.ts +151 -20
  50. package/src/lib/server/media-variants.js +204 -27
  51. package/src/lib/server/media-variants.js.map +1 -1
  52. package/src/lib/server/organizations.d.ts +102 -2
  53. package/src/lib/server/organizations.js +388 -58
  54. package/src/lib/server/organizations.js.map +1 -1
  55. package/src/lib/server/outbound-send-review.js +5 -4
  56. package/src/lib/server/outbound-send-review.js.map +1 -1
  57. package/src/lib/server/paged-sweeps.d.ts +51 -0
  58. package/src/lib/server/paged-sweeps.js +79 -0
  59. package/src/lib/server/paged-sweeps.js.map +1 -0
  60. package/src/lib/server/platform-marketing-consent.d.ts +0 -49
  61. package/src/lib/server/platform-marketing-consent.js +27 -11
  62. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  63. package/src/lib/server/serve-media-cdn.d.ts +69 -1
  64. package/src/lib/server/serve-media-cdn.js +424 -70
  65. package/src/lib/server/serve-media-cdn.js.map +1 -1
  66. package/src/lib/server/site-icon-render.d.ts +68 -0
  67. package/src/lib/server/site-icon-render.js +157 -0
  68. package/src/lib/server/site-icon-render.js.map +1 -0
  69. package/src/lib/server/staff-seat.d.ts +50 -0
  70. package/src/lib/server/staff-seat.js +118 -0
  71. package/src/lib/server/staff-seat.js.map +1 -0
  72. package/src/lib/server/transfer-export.d.ts +46 -0
  73. package/src/lib/server/transfer-export.js +221 -0
  74. package/src/lib/server/transfer-export.js.map +1 -0
  75. package/src/lib/server/transfer-jobs.d.ts +385 -0
  76. package/src/lib/server/transfer-jobs.js +2368 -0
  77. package/src/lib/server/transfer-jobs.js.map +1 -0
  78. package/src/lib/server/transfer-packages.d.ts +127 -0
  79. package/src/lib/server/transfer-packages.js +1036 -0
  80. package/src/lib/server/transfer-packages.js.map +1 -0
  81. package/src/lib/server/upgrade-proposal.d.ts +97 -0
  82. package/src/lib/server/upgrade-proposal.js +312 -0
  83. package/src/lib/server/upgrade-proposal.js.map +1 -0
  84. package/src/lib/server/upsert-contact.d.ts +17 -8
  85. package/src/lib/server/upsert-contact.js +82 -16
  86. package/src/lib/server/upsert-contact.js.map +1 -1
  87. package/src/lib/server/web-risk.d.ts +48 -9
  88. package/src/lib/server/web-risk.js +281 -143
  89. package/src/lib/server/web-risk.js.map +1 -1
  90. package/src/tenancy.d.ts +3 -1
  91. package/src/tenancy.js +5 -1
  92. package/src/tenancy.js.map +1 -1
  93. package/src/lib/server/campaign-attribution-store.d.ts +0 -55
  94. package/src/lib/server/campaign-attribution-store.js +0 -95
  95. package/src/lib/server/campaign-attribution-store.js.map +0 -1
  96. package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
  97. package/src/lib/server/campaign-conversion-attribution.js +0 -450
  98. package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
  99. package/src/lib/server/dataset-live-pages.d.ts +0 -179
  100. package/src/lib/server/dataset-live-pages.js +0 -421
  101. package/src/lib/server/dataset-live-pages.js.map +0 -1
  102. package/src/lib/server/email-revenue-attribution.d.ts +0 -185
  103. package/src/lib/server/email-revenue-attribution.js +0 -321
  104. package/src/lib/server/email-revenue-attribution.js.map +0 -1
@@ -54,7 +54,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
54
54
  import { ABUSE_REPORT_COLLECTION } from "@aglyn/aglyn/app-utils/abuse-report";
55
55
  import { TENANT_APEX } from "@aglyn/aglyn/app-utils/host-naming";
56
56
  import { linkReputationSignals } from "@aglyn/shared-util-email/link-reputation";
57
- import { describePhishingScreenSignals, isYoungWorkspaceAge, linkHostsIn, OUTBOUND_REVIEW_YOUNG_DAYS, screenOutboundEmail, signalsThatHold } from "@aglyn/shared-util-email/outbound-phishing-screen";
57
+ import { describePhishingScreenSignals, isYoungWorkspaceAge, linkUrlsIn, OUTBOUND_REVIEW_YOUNG_DAYS, screenOutboundEmail, signalsThatHold } from "@aglyn/shared-util-email/outbound-phishing-screen";
58
58
  import { setOutboundScreenGate } from "@aglyn/shared-util-email/outbound-screen-gate";
59
59
  import { FieldValue } from "firebase-admin/firestore";
60
60
  import { orgAgeDays } from "./org-age.js";
@@ -306,9 +306,10 @@ import { installLinkReputationLookup } from "./web-risk.js";
306
306
  preheader: (_request_preheader = request.preheader) != null ? _request_preheader : null,
307
307
  bodies: request.bodies
308
308
  }, identity));
309
- // Every foreign host it links to, against the reputation list (AGL-3451):
310
- // a listed host holds for every workspace, a failed lookup holds nothing.
311
- const reputation = await linkReputationSignals(linkHostsIn([
309
+ // Every foreign link it carries, against the reputation list (AGL-3451):
310
+ // its host, and in 'url' mode its address (AGL-3459). A listing holds for
311
+ // every workspace, a failed lookup holds nothing.
312
+ const reputation = await linkReputationSignals(linkUrlsIn([
312
313
  request.subject,
313
314
  request.preheader,
314
315
  ...request.bodies
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/outbound-send-review.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 * HELD FOR REVIEW: outbound mail from a young workspace (AGL-3356).\n *\n * The phishing screen (`screenOutboundEmail`, shared-util-email) decides\n * whether a message carries a phishing signal, and `signalsThatHold` beside\n * it decides which signals hold for whom. This module is what happens next,\n * for every surface that screens — the send seam every tenant message\n * crosses ({@link installOutboundScreenGate}), the campaign core and the\n * automation step before they commit, and a site publish\n * (`hosted-page-review.ts`) — so none restates it:\n *\n * - WHO IS SCREENED. Every workspace, in two tiers. A lookalike link holds\n * whatever the workspace's age. The soft rules (a brand in the sender\n * name, the three-part lure) hold only for a workspace younger than\n * {@link OUTBOUND_REVIEW_YOUNG_DAYS}: the incident's workspace was days\n * old, and an established customer's clumsy copy never waits on them. An\n * org whose creation date cannot be read is an existing customer (the same\n * reading `orgAgeDays` gives the sending ramp) and is not young.\n * - WHERE A HOLD GOES. Into the abuse queue at `/admin/abuse-reports` — the\n * staff surface that already exists to answer \"is this phishing\", with its\n * audit trail, its urgent count and its staff notification — as a\n * `phishing` row whose `heldSend` names the message. Not a new queue: a\n * second place to look is a place nobody looks.\n * - HOW STAFF DECIDE. By the row's existing status. `dismissed` (a false\n * positive) RELEASES the send; `actioned` REJECTS it. The admin route\n * calls {@link decideHeldOutboundSend} in the same act as the status\n * write, so the decision and its audit row cannot come apart.\n * - WHAT A RELEASE COVERS. Exactly the content that was held: the review\n * row's id is derived from the message's source and a hash of its\n * content, so an edited message is screened afresh rather than riding a\n * release granted to different words.\n *\n * A hold is never a drop. A held campaign is parked as a scheduled send that\n * a release puts back on the clock; a held automation step fails that one\n * run with a reason the merchant can read, or — after a wait — stays queued\n * until the decision.\n *=========================================*/\n\nimport { createHash } from 'crypto'\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport { TENANT_APEX } from '@aglyn/aglyn/app-utils/host-naming'\nimport { linkReputationSignals } from '@aglyn/shared-util-email/link-reputation'\nimport {\n describePhishingScreenSignals,\n isYoungWorkspaceAge,\n linkHostsIn,\n OUTBOUND_REVIEW_YOUNG_DAYS,\n type PhishingScreenInput,\n type PhishingScreenSignal,\n screenOutboundEmail,\n signalsThatHold,\n} from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport {\n type OutboundScreenGateRequest,\n type OutboundScreenGateVerdict,\n type SendingWorkspace,\n setOutboundScreenGate,\n} from '@aglyn/shared-util-email/outbound-screen-gate'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { orgAgeDays } from './org-age'\nimport firebaseAdmin from './firebase-admin'\nimport { notifyRiskEvent, type RiskEventInput, type RiskEventItem } from './risk-notice'\nimport { installLinkReputationLookup } from './web-risk'\n\n/** A workspace younger than this many days has the soft rules applied. */\nexport { OUTBOUND_REVIEW_YOUNG_DAYS }\n\n/**\n * Where a held campaign is parked: a send time no processor run reaches.\n * A release replaces it with \"now\"; nothing else ever does, so a hold that\n * nobody decides stays a hold rather than lapsing into a send.\n */\nexport const HELD_SEND_AT_MS = 253402300799000\n\n/**\n * Which surface held it: a campaign, an automation step, any other tenant\n * message at the send seam (`message`), a site publish (`page`), or a\n * submission to a public catalog a plugin keeps — a marketplace listing, its\n * publisher profile (`listing`, AGL-3365).\n */\nexport type HeldOutboundSendKind =\n | 'campaign'\n | 'action'\n | 'workflow'\n | 'orgAutomation'\n | 'message'\n | 'page'\n | 'listing'\n\n/** The decision state a held send carries on its review row. */\nexport type HeldOutboundSendState = 'held' | 'released' | 'rejected'\n\n/** What the review row records about the message it holds. */\nexport interface HeldOutboundSend {\n kind: HeldOutboundSendKind\n /** The document the message came from — a send, an action, a workflow. */\n path: string\n hostId: string\n orgId: string | null\n contentHash: string\n subject: string\n fromName: string | null\n signals: PhishingScreenSignal[]\n state: HeldOutboundSendState\n heldAtMs: number\n /** Workspace age in days when it was held, for the reviewer. */\n ageDays: number | null\n}\n\n/**\n * Is this workspace young enough to screen?\n *\n * Age alone, deliberately. Payment history was the other candidate and the\n * incident is the argument against it: the actor PAID, on a card that never\n * met 3-D Secure, within days of signing up. A paid invoice proves a card\n * worked, not that its holder is who they say.\n */\nexport function isYoungWorkspace(\n org: { createdAt?: unknown } | null | undefined,\n nowMs: number = Date.now(),\n): boolean {\n return isYoungWorkspaceAge(orgAgeDays(org?.createdAt, nowMs))\n}\n\n/**\n * The workspace a host's mail belongs to, as the send seam's screen reads it\n * — stamped on the sending identity by `hostSendingIdentity` from the host\n * and org documents it has already read.\n */\nexport function sendingWorkspaceFor(input: {\n hostId: string\n orgId: string | null\n org?: Record<string, unknown> | null\n host?: Record<string, unknown> | null\n nowMs?: number\n}): SendingWorkspace {\n const identity = workspaceScreenIdentity({ org: input.org, host: input.host })\n return {\n hostId: input.hostId,\n orgId: input.orgId,\n ageDays: orgAgeDays(\n (input.org as { createdAt?: unknown } | null | undefined)?.createdAt,\n input.nowMs ?? Date.now(),\n ),\n ownNames: (identity.ownNames ?? []).filter((name): name is string => Boolean(name)),\n ownDomains: (identity.ownDomains ?? []).filter((domain): domain is string => Boolean(domain)),\n }\n}\n\n/**\n * The workspace's own names and hosts, as the screen reads them: a brand in\n * its own name is its own, and a link to its own site is not \"elsewhere\".\n */\nexport function workspaceScreenIdentity(input: {\n org?: Record<string, unknown> | null\n host?: Record<string, unknown> | null\n}): Pick<PhishingScreenInput, 'ownNames' | 'ownDomains'> {\n const org = input.org ?? {}\n const host = input.host ?? {}\n const text = (value: unknown) => (typeof value === 'string' ? value : '')\n const subdomain = text(host['subdomain'])\n return {\n ownNames: [text(org['name']), text(host['name']), subdomain],\n ownDomains: [\n subdomain ? `${subdomain}.${TENANT_APEX}` : '',\n text(host['cname']),\n text(host['sendingDomain']),\n ],\n }\n}\n\n/**\n * JSON with every object's keys sorted, so the same design serializes to the\n * same string however it was read. Firestore hands back a map's keys in an\n * order that differs by reader, and a release keyed on a hash of an unsorted\n * serialization would not match the very content it released.\n */\nexport function canonicalJson(value: unknown): string {\n return JSON.stringify(value, (_key, entry) =>\n entry && typeof entry === 'object' && !Array.isArray(entry)\n ? Object.fromEntries(\n Object.keys(entry as Record<string, unknown>)\n .sort()\n .map((key) => [key, (entry as Record<string, unknown>)[key]]),\n )\n : entry,\n )\n}\n\n/** A stable hash of what a message says, so a release covers these words only. */\nexport function outboundContentHash(parts: readonly unknown[]): string {\n return createHash('sha256').update(canonicalJson(parts)).digest('hex')\n}\n\n/**\n * The review row's id: one row per (source, content). Hex, so the admin\n * route's report-id pattern addresses it like any intake row.\n */\nexport function heldOutboundReviewId(path: string, contentHash: string): string {\n return createHash('sha256')\n .update(`outbound-hold:${path}:${contentHash}`)\n .digest('hex')\n .slice(0, 40)\n}\n\n/** The staff-facing reference, in the intake's `AR-` style. */\nexport function heldOutboundReference(reviewId: string): string {\n return `HS-${reviewId.slice(0, 10).toUpperCase()}`\n}\n\nexport interface OutboundScreenRequest {\n kind: HeldOutboundSendKind\n path: string\n hostId: string\n orgId: string | null\n org: Record<string, unknown> | null\n host: Record<string, unknown> | null\n subject: string\n fromName?: string | null\n /**\n * The address it leaves from (AGL-3362). Read by the lookalike rule only,\n * and kept out of the content hash so a release granted before this was\n * read still names the same row.\n */\n fromAddress?: string | null\n replyTo?: string | readonly string[] | null\n preheader?: string | null\n bodies: readonly (string | null | undefined)[]\n nowMs?: number\n}\n\n/** What a screen answers for one piece of content. */\nexport type OutboundScreenOutcome =\n | {\n outcome: 'send'\n /** The review row staff released this content under, when there is one. */\n releasedReviewId?: string\n }\n | { outcome: 'held' | 'rejected'; reviewId: string; reference: string; contentHash: string }\n\n/** What a surface files when its screen holds, for {@link fileOutboundHold}. */\nexport interface OutboundHoldFiling {\n reviewId: string\n heldSend: HeldOutboundSend\n /** Extra fields a surface records beside `heldSend` (a page's version). */\n extra?: Record<string, unknown>\n /** The first line of the row's details, naming what was held. */\n headline: string\n /** The staff alert's title and body, the first time. */\n alertTitle: string\n alertBody: string\n /** The URL the row reports: the flagged host, or the held page. */\n url: string | null\n reportedHostname: string | null\n /**\n * What the workspace's owners are told the first time (AGL-3368): the held\n * item in their words and its console page. The alert title and body above\n * are the STAFF half; the owners read the catalog's own words, which never\n * name the signals.\n */\n item?: RiskEventItem | null\n /** A held page, described (AGL-3374): stored on the owners' notice for the console. */\n page?: RiskEventInput['page']\n}\n\n/**\n * The held item as the owners see it, from the surface and the source path\n * (AGL-3368): the campaign's own email page, the automation list, or nothing\n * for a message the send seam held (the notice then leads to Holds & reviews).\n */\nexport function heldSendItem(input: {\n kind: HeldOutboundSendKind\n path: string\n hostId: string\n subject: string\n context?: string | null\n}): RiskEventItem {\n const subject = input.subject.slice(0, 120)\n const id = input.path.split('/').pop() ?? ''\n switch (input.kind) {\n case 'campaign':\n return {\n label: `the campaign \"${subject}\"`,\n path: id ? `/org/emails/messages/${encodeURIComponent(id)}` : null,\n }\n case 'orgAutomation':\n return { label: `the automated email \"${subject}\"`, path: '/org/automation' }\n case 'workflow':\n case 'action':\n return { label: `the automated email \"${subject}\"`, path: `/${input.hostId}/automation` }\n case 'page':\n // A page's subject is its label (`heldPageLabel`, AGL-3374); a row\n // filed before that carried its URL.\n return { label: /^https?:\\/\\//.test(subject) ? `the page ${subject}` : subject, path: null }\n case 'listing':\n // The publisher's own Listings section of the Marketplace hub.\n return { label: `the marketplace submission \"${subject}\"`, path: '/org/marketplace/listings' }\n default:\n return {\n label: input.context\n ? `the email \"${subject}\" (${input.context})`\n : `the email \"${subject}\"`,\n path: null,\n }\n }\n}\n\n/**\n * Write (or count) the review row for a hold, and tell staff the first time.\n * Returns the row's decision state — a row staff already decided answers\n * with that decision and is not written.\n *\n * One writer for every surface, so a campaign, a seam message and a page\n * land in the queue in the one shape the admin route and page already read.\n */\nexport async function fileOutboundHold(\n filing: OutboundHoldFiling,\n): Promise<HeldOutboundSendState> {\n const firestore = firebaseAdmin.app().firestore()\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(filing.reviewId)\n const reference = heldOutboundReference(filing.reviewId)\n let first = false\n let state = 'held' as HeldOutboundSendState\n await firestore.runTransaction(async (transaction) => {\n const existing = await transaction.get(ref)\n first = !existing.exists\n const stored = existing.exists\n ? (existing.get('heldSend') as Partial<HeldOutboundSend> | undefined)\n : undefined\n state = stored?.state === 'released' || stored?.state === 'rejected'\n ? stored.state\n : 'held'\n if (state !== 'held') return\n transaction.set(\n ref,\n {\n reference,\n category: 'phishing',\n severity: 'urgent',\n // Who filed it: the screen, not a person. The queue reads it to\n // say so, and a reporter-contact field stays honestly empty.\n source: 'outbound-screen',\n // The flagged host as TEXT — the page never renders a reported URL\n // as a link, and this one may be a phishing kit's.\n url: filing.url,\n reportedHostname: filing.reportedHostname,\n hostId: filing.heldSend.hostId,\n orgId: filing.heldSend.orgId,\n details: [\n filing.headline,\n ...describePhishingScreenSignals(filing.heldSend.signals),\n filing.heldSend.kind === 'page'\n ? 'Dismiss to release this publish; mark it actioned to reject it.'\n : filing.heldSend.kind === 'listing'\n ? 'Dismiss to release this submission (the publisher submits it again and it ' +\n 'goes through); mark it actioned to reject it.'\n : 'Dismiss to release this send; mark it actioned to reject it.',\n ]\n .join('\\n')\n .slice(0, 5000),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n // Every message the same content held counts here, so a workflow\n // firing on each new contact reads as the volume it is.\n reportCount: FieldValue.increment(1),\n heldSend: first\n ? filing.heldSend\n : { ...filing.heldSend, heldAtMs: stored?.heldAtMs ?? filing.heldSend.heldAtMs },\n ...(filing.extra ?? {}),\n updatedAt: FieldValue.serverTimestamp(),\n ...(first\n ? { status: 'open', createdAt: FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n })\n if (first && state === 'held') {\n // The owners learn what was held and how to ask for a review; staff get\n // the alert with the evidence and a link to this row. One seam, once.\n await notifyRiskEvent({\n kind:\n filing.heldSend.kind === 'page'\n ? 'page-held'\n : filing.heldSend.kind === 'listing'\n ? 'listing-held'\n : 'email-held',\n orgId: filing.heldSend.orgId,\n hostId: filing.heldSend.hostId,\n reviewId: filing.reviewId,\n reference,\n occurredAtMs: filing.heldSend.heldAtMs,\n item:\n filing.item ??\n heldSendItem({\n kind: filing.heldSend.kind,\n path: filing.heldSend.path,\n hostId: filing.heldSend.hostId,\n subject: filing.heldSend.subject,\n }),\n page: filing.page ?? null,\n staffEvidence: [\n filing.alertBody,\n ...describePhishingScreenSignals(filing.heldSend.signals),\n ].join(' '),\n })\n }\n return state\n}\n\n/** The first signal that names a host, for the row's reported URL. */\nexport function flaggedHostOf(signals: readonly PhishingScreenSignal[]): string | null {\n const flagged = signals.find(\n (signal): signal is Extract<PhishingScreenSignal, { host: string }> => 'host' in signal,\n )\n return flagged?.host ?? null\n}\n\n/**\n * The screen's answer for one message, and what to do with it.\n *\n * - `send` — clean under the tiers, or held earlier and RELEASED by staff\n * for exactly this content (then `releasedReviewId` names the row, and the\n * caller hands it to `sendEmail` so the seam's screen honors it too).\n * - `held` — hold it. The review row is written (or its count bumped) and\n * staff are told the first time.\n * - `rejected` — staff rejected exactly this content. Never sent.\n *\n * Every workspace is screened; `signalsThatHold` applies the tiers, so an\n * established workspace is held only by a lookalike link.\n *\n * Fails OPEN on a store error, the posture of every other control on the\n * send path: an outage on the review queue must not become an outage on\n * every workspace's mail. The screen itself cannot fail.\n */\nexport async function screenOutboundSend(\n request: OutboundScreenRequest,\n): Promise<OutboundScreenOutcome> {\n const nowMs = request.nowMs ?? Date.now()\n const ageDays = orgAgeDays(\n (request.org as { createdAt?: unknown } | null)?.createdAt,\n nowMs,\n )\n const identity = workspaceScreenIdentity({ org: request.org, host: request.host })\n const verdict = screenOutboundEmail({\n subject: request.subject,\n fromName: request.fromName ?? null,\n fromAddress: request.fromAddress ?? null,\n replyTo: request.replyTo ?? null,\n preheader: request.preheader ?? null,\n bodies: request.bodies,\n ...identity,\n })\n // Every foreign host it links to, against the reputation list (AGL-3451):\n // a listed host holds for every workspace, a failed lookup holds nothing.\n const reputation = await linkReputationSignals(\n linkHostsIn([request.subject, request.preheader, ...request.bodies].filter(Boolean).join('\\n')),\n { ownDomains: identity.ownDomains },\n )\n const signals = signalsThatHold([...verdict.signals, ...reputation], { ageDays })\n if (!signals.length) return { outcome: 'send' }\n\n const contentHash = outboundContentHash([\n request.subject,\n request.fromName ?? '',\n request.replyTo ?? '',\n request.preheader ?? '',\n ...request.bodies.map((body) => body ?? ''),\n ])\n const reviewId = heldOutboundReviewId(request.path, contentHash)\n const reference = heldOutboundReference(reviewId)\n try {\n const heldSend: HeldOutboundSend = {\n kind: request.kind,\n path: request.path,\n hostId: request.hostId,\n orgId: request.orgId,\n contentHash,\n subject: request.subject.slice(0, 300),\n fromName: request.fromName ? String(request.fromName).slice(0, 120) : null,\n signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays,\n }\n const flagged = flaggedHostOf(signals)\n const state = await fileOutboundHold({\n reviewId,\n heldSend,\n headline:\n `Held ${request.kind === 'campaign' ? 'campaign' : `${request.kind} email step`} ` +\n `\"${heldSend.subject}\" from a workspace ${ageDays ?? '?'} day(s) old.`,\n alertTitle: 'Outbound email held for review — possible phishing',\n alertBody:\n `A ${request.kind === 'campaign' ? 'campaign' : 'automated email'} ` +\n `was held: \"${request.subject.slice(0, 120)}\".`,\n url: flagged ? `https://${flagged}/` : null,\n reportedHostname: flagged,\n })\n if (state === 'released') return { outcome: 'send', releasedReviewId: reviewId }\n return { outcome: state === 'rejected' ? 'rejected' : 'held', reviewId, reference, contentHash }\n } catch (error) {\n console.error('[outbound-review] hold could not be recorded — allowing', error)\n return { outcome: 'send' }\n }\n}\n\n/**\n * How long this process trusts what it last learned about a seam row: that\n * it is held (so the next message carrying the same signals skips the\n * write), or how staff decided it. Short, because a decision must reach a\n * sender within a minute; long enough that a newsletter fanned out to a\n * thousand people costs one transaction rather than a thousand on one\n * document.\n */\nconst SEAM_MEMO_TTL_MS = 60_000\nconst seamMemo = new Map<string, { state: HeldOutboundSendState; atMs: number }>()\n\n/** Test seam: forget what this process learned about seam rows. */\nexport function resetOutboundSeamMemoForTests(): void {\n seamMemo.clear()\n}\n\n/**\n * The seam row's id for a site and the signals that held. Keyed on the\n * SIGNALS rather than the words: a message at the seam is already\n * personalized per recipient, so its words differ for every person while\n * what makes it phishing — the lookalike host, the brand and the lure — does\n * not. A release therefore covers this site's use of exactly these signals,\n * and a message carrying any other signal is screened afresh.\n */\nexport function seamReviewId(hostId: string, signals: readonly PhishingScreenSignal[]): string {\n const key = signals\n .map((signal) => canonicalJson(signal))\n .sort()\n .join('\\n')\n return heldOutboundReviewId(`hosts/${hostId}/outbound`, outboundContentHash([key]))\n}\n\n/** Is this row a staff RELEASE for this site? */\nasync function isReleaseForHost(reviewId: string, hostId: string): Promise<boolean> {\n const memoKey = `release:${hostId}:${reviewId}`\n const memo = seamMemo.get(memoKey)\n if (memo && Date.now() - memo.atMs < SEAM_MEMO_TTL_MS) return memo.state === 'released'\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection(ABUSE_REPORT_COLLECTION)\n .doc(reviewId)\n .get()\n const held = snapshot.get('heldSend') as Partial<HeldOutboundSend> | undefined\n const released = held?.state === 'released' && held?.hostId === hostId\n seamMemo.set(memoKey, { state: released ? 'released' : 'held', atMs: Date.now() })\n return released\n}\n\n/**\n * The send seam's gate: what `sendEmail` asks when a tenant message carries\n * a signal that holds (see `outbound-screen-gate.ts`). Installed at module\n * load by {@link installOutboundScreenGate}.\n *\n * A seam hold REFUSES that one message (`held-for-review`) — the sender's own\n * failure handling reports it, the way it reports a suppression — and files\n * one row per site and signal set. Once staff release it, the site's next\n * message carrying those signals sends; reject it, and none does.\n */\nexport async function screenSeamMessage(\n request: OutboundScreenGateRequest,\n): Promise<OutboundScreenGateVerdict> {\n const { workspace } = request\n if (\n request.releasedReviewId &&\n (await isReleaseForHost(request.releasedReviewId, workspace.hostId))\n ) {\n return { outcome: 'send' }\n }\n const reviewId = seamReviewId(workspace.hostId, request.signals)\n const reference = heldOutboundReference(reviewId)\n const memo = seamMemo.get(reviewId)\n if (memo && Date.now() - memo.atMs < SEAM_MEMO_TTL_MS) {\n return memo.state === 'released'\n ? { outcome: 'send' }\n : { outcome: memo.state === 'rejected' ? 'rejected' : 'held', reference }\n }\n const nowMs = Date.now()\n const subject = String(request.subject ?? '').slice(0, 300)\n const context = request.context ? String(request.context).slice(0, 80) : null\n const flagged = flaggedHostOf(request.signals)\n const state = await fileOutboundHold({\n reviewId,\n heldSend: {\n kind: 'message',\n path: `hosts/${workspace.hostId}`,\n hostId: workspace.hostId,\n orgId: workspace.orgId,\n contentHash: outboundContentHash([request.signals]),\n subject,\n fromName: request.fromName ? String(request.fromName).slice(0, 120) : null,\n signals: request.signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays: workspace.ageDays,\n },\n extra: { heldContext: context },\n item: heldSendItem({ kind: 'message', path: `hosts/${workspace.hostId}`, hostId: workspace.hostId, subject, context }),\n headline:\n `Held ${context ? `\"${context}\" ` : ''}email \"${subject}\" from a workspace ` +\n `${workspace.ageDays ?? '?'} day(s) old. Every later message from this site ` +\n 'carrying the same signals waits on this decision.',\n alertTitle: 'Outbound email held for review — possible phishing',\n alertBody: `An email from a site was held: \"${subject.slice(0, 120)}\".`,\n url: flagged ? `https://${flagged}/` : null,\n reportedHostname: flagged,\n })\n seamMemo.set(reviewId, { state, atMs: Date.now() })\n return state === 'released'\n ? { outcome: 'send' }\n : { outcome: state === 'rejected' ? 'rejected' : 'held', reference }\n}\n\n/**\n * Puts the review store on `sendEmail`'s phishing screen.\n *\n * **Called at module load**, from the bottom of this file, for the reason\n * `installEmailSendGovernor` is — and this module is also imported by\n * `sending-domains.ts`, so any sender that resolved its identity through\n * `hostSendingIdentity` (the only thing that stamps a workspace on it, and\n * so the only thing the seam screens) has installed it.\n */\nexport function installOutboundScreenGate(): void {\n if (typeof setOutboundScreenGate !== 'function') return\n setOutboundScreenGate((request) => screenSeamMessage(request))\n // And the link reputation lookup the same screen asks (AGL-3451).\n installLinkReputationLookup()\n}\n\n/**\n * Apply a staff decision to a held send (the admin abuse route, in the same\n * act as the row's status change).\n *\n * The row's `heldSend.state` is written first: it is what the screen reads,\n * so an automation step released here sends on its next run whatever\n * happens to the rest. A CAMPAIGN is also put back on the clock (released)\n * or canceled (rejected) — but only while it is still the parked send this\n * row held: a send that has since been edited, sent or canceled by the\n * merchant is left alone, and the answer says so.\n */\nexport async function decideHeldOutboundSend(input: {\n reviewId: string\n decision: 'release' | 'reject'\n actorUid: string\n actorEmail: string | null\n nowMs?: number\n firestore?: FirebaseFirestore.Firestore\n}): Promise<'released' | 'rejected' | 'not-held' | 'campaign-moved'> {\n const nowMs = input.nowMs ?? Date.now()\n const firestore = input.firestore ?? firebaseAdmin.app().firestore()\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(input.reviewId)\n const snapshot = await ref.get()\n const heldSend = snapshot.get('heldSend') as HeldOutboundSend | undefined\n if (!heldSend?.path || !heldSend.contentHash) return 'not-held'\n const state: HeldOutboundSendState =\n input.decision === 'release' ? 'released' : 'rejected'\n const decided = {\n state,\n decidedAtMs: nowMs,\n decidedByUid: input.actorUid,\n decidedByEmail: input.actorEmail,\n }\n await ref.set(\n { heldSend: { ...heldSend, ...decided } },\n { merge: true },\n )\n if (heldSend.kind !== 'campaign') return state\n\n const sendRef = firestore.doc(heldSend.path)\n const moved = await firestore.runTransaction(async (transaction) => {\n const send = await transaction.get(sendRef)\n const review = send.get('staffReview') as\n | { reviewId?: string; state?: string }\n | undefined\n const status = String(send.get('status') ?? '')\n if (\n !send.exists ||\n review?.reviewId !== input.reviewId ||\n (status !== 'scheduled' && status !== 'draft')\n ) {\n return true\n }\n transaction.set(\n sendRef,\n input.decision === 'release'\n ? {\n status: 'scheduled',\n // Due now: the next processor run sends it, and the screen\n // finds this row released for exactly this content.\n sendAtMs: nowMs,\n staffReview: { ...review, ...decided },\n }\n : {\n status: 'canceled',\n sendAtMs: FieldValue.delete(),\n staffReview: { ...review, ...decided },\n },\n { merge: true },\n )\n return false\n })\n return moved ? 'campaign-moved' : state\n}\n\ninstallOutboundScreenGate()\n"],"names":["createHash","ABUSE_REPORT_COLLECTION","TENANT_APEX","linkReputationSignals","describePhishingScreenSignals","isYoungWorkspaceAge","linkHostsIn","OUTBOUND_REVIEW_YOUNG_DAYS","screenOutboundEmail","signalsThatHold","setOutboundScreenGate","FieldValue","orgAgeDays","firebaseAdmin","notifyRiskEvent","installLinkReputationLookup","HELD_SEND_AT_MS","isYoungWorkspace","org","nowMs","Date","now","createdAt","sendingWorkspaceFor","input","identity","workspaceScreenIdentity","host","hostId","orgId","ageDays","ownNames","filter","name","Boolean","ownDomains","domain","text","value","subdomain","canonicalJson","JSON","stringify","_key","entry","Array","isArray","Object","fromEntries","keys","sort","map","key","outboundContentHash","parts","update","digest","heldOutboundReviewId","path","contentHash","slice","heldOutboundReference","reviewId","toUpperCase","heldSendItem","subject","id","split","pop","kind","label","encodeURIComponent","test","context","fileOutboundHold","filing","firestore","app","ref","collection","doc","reference","first","state","runTransaction","transaction","existing","get","exists","stored","undefined","set","category","severity","source","url","reportedHostname","heldSend","details","headline","signals","join","reporterEmail","reporterName","dmca","reportCount","increment","heldAtMs","extra","updatedAt","serverTimestamp","status","merge","occurredAtMs","item","page","staffEvidence","alertBody","flaggedHostOf","flagged","find","signal","screenOutboundSend","request","verdict","fromName","fromAddress","replyTo","preheader","bodies","reputation","length","outcome","body","String","alertTitle","releasedReviewId","error","console","SEAM_MEMO_TTL_MS","seamMemo","Map","resetOutboundSeamMemoForTests","clear","seamReviewId","isReleaseForHost","memoKey","memo","atMs","snapshot","held","released","screenSeamMessage","workspace","heldContext","installOutboundScreenGate","decideHeldOutboundSend","decision","decided","decidedAtMs","decidedByUid","actorUid","decidedByEmail","actorEmail","sendRef","moved","send","review","sendAtMs","staffReview","delete"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/outbound-send-review.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 * HELD FOR REVIEW: outbound mail from a young workspace (AGL-3356).\n *\n * The phishing screen (`screenOutboundEmail`, shared-util-email) decides\n * whether a message carries a phishing signal, and `signalsThatHold` beside\n * it decides which signals hold for whom. This module is what happens next,\n * for every surface that screens — the send seam every tenant message\n * crosses ({@link installOutboundScreenGate}), the campaign core and the\n * automation step before they commit, and a site publish\n * (`hosted-page-review.ts`) — so none restates it:\n *\n * - WHO IS SCREENED. Every workspace, in two tiers. A lookalike link holds\n * whatever the workspace's age. The soft rules (a brand in the sender\n * name, the three-part lure) hold only for a workspace younger than\n * {@link OUTBOUND_REVIEW_YOUNG_DAYS}: the incident's workspace was days\n * old, and an established customer's clumsy copy never waits on them. An\n * org whose creation date cannot be read is an existing customer (the same\n * reading `orgAgeDays` gives the sending ramp) and is not young.\n * - WHERE A HOLD GOES. Into the abuse queue at `/admin/abuse-reports` — the\n * staff surface that already exists to answer \"is this phishing\", with its\n * audit trail, its urgent count and its staff notification — as a\n * `phishing` row whose `heldSend` names the message. Not a new queue: a\n * second place to look is a place nobody looks.\n * - HOW STAFF DECIDE. By the row's existing status. `dismissed` (a false\n * positive) RELEASES the send; `actioned` REJECTS it. The admin route\n * calls {@link decideHeldOutboundSend} in the same act as the status\n * write, so the decision and its audit row cannot come apart.\n * - WHAT A RELEASE COVERS. Exactly the content that was held: the review\n * row's id is derived from the message's source and a hash of its\n * content, so an edited message is screened afresh rather than riding a\n * release granted to different words.\n *\n * A hold is never a drop. A held campaign is parked as a scheduled send that\n * a release puts back on the clock; a held automation step fails that one\n * run with a reason the merchant can read, or — after a wait — stays queued\n * until the decision.\n *=========================================*/\n\nimport { createHash } from 'crypto'\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport { TENANT_APEX } from '@aglyn/aglyn/app-utils/host-naming'\nimport { linkReputationSignals } from '@aglyn/shared-util-email/link-reputation'\nimport {\n describePhishingScreenSignals,\n isYoungWorkspaceAge,\n linkUrlsIn,\n OUTBOUND_REVIEW_YOUNG_DAYS,\n type PhishingScreenInput,\n type PhishingScreenSignal,\n screenOutboundEmail,\n signalsThatHold,\n} from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport {\n type OutboundScreenGateRequest,\n type OutboundScreenGateVerdict,\n type SendingWorkspace,\n setOutboundScreenGate,\n} from '@aglyn/shared-util-email/outbound-screen-gate'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { orgAgeDays } from './org-age'\nimport firebaseAdmin from './firebase-admin'\nimport { notifyRiskEvent, type RiskEventInput, type RiskEventItem } from './risk-notice'\nimport { installLinkReputationLookup } from './web-risk'\n\n/** A workspace younger than this many days has the soft rules applied. */\nexport { OUTBOUND_REVIEW_YOUNG_DAYS }\n\n/**\n * Where a held campaign is parked: a send time no processor run reaches.\n * A release replaces it with \"now\"; nothing else ever does, so a hold that\n * nobody decides stays a hold rather than lapsing into a send.\n */\nexport const HELD_SEND_AT_MS = 253402300799000\n\n/**\n * Which surface held it: a campaign, an automation step, any other tenant\n * message at the send seam (`message`), a site publish (`page`), or a\n * submission to a public catalog a plugin keeps — a marketplace listing, its\n * publisher profile (`listing`, AGL-3365).\n */\nexport type HeldOutboundSendKind =\n | 'campaign'\n | 'action'\n | 'workflow'\n | 'orgAutomation'\n | 'message'\n | 'page'\n | 'listing'\n\n/** The decision state a held send carries on its review row. */\nexport type HeldOutboundSendState = 'held' | 'released' | 'rejected'\n\n/** What the review row records about the message it holds. */\nexport interface HeldOutboundSend {\n kind: HeldOutboundSendKind\n /** The document the message came from — a send, an action, a workflow. */\n path: string\n hostId: string\n orgId: string | null\n contentHash: string\n subject: string\n fromName: string | null\n signals: PhishingScreenSignal[]\n state: HeldOutboundSendState\n heldAtMs: number\n /** Workspace age in days when it was held, for the reviewer. */\n ageDays: number | null\n}\n\n/**\n * Is this workspace young enough to screen?\n *\n * Age alone, deliberately. Payment history was the other candidate and the\n * incident is the argument against it: the actor PAID, on a card that never\n * met 3-D Secure, within days of signing up. A paid invoice proves a card\n * worked, not that its holder is who they say.\n */\nexport function isYoungWorkspace(\n org: { createdAt?: unknown } | null | undefined,\n nowMs: number = Date.now(),\n): boolean {\n return isYoungWorkspaceAge(orgAgeDays(org?.createdAt, nowMs))\n}\n\n/**\n * The workspace a host's mail belongs to, as the send seam's screen reads it\n * — stamped on the sending identity by `hostSendingIdentity` from the host\n * and org documents it has already read.\n */\nexport function sendingWorkspaceFor(input: {\n hostId: string\n orgId: string | null\n org?: Record<string, unknown> | null\n host?: Record<string, unknown> | null\n nowMs?: number\n}): SendingWorkspace {\n const identity = workspaceScreenIdentity({ org: input.org, host: input.host })\n return {\n hostId: input.hostId,\n orgId: input.orgId,\n ageDays: orgAgeDays(\n (input.org as { createdAt?: unknown } | null | undefined)?.createdAt,\n input.nowMs ?? Date.now(),\n ),\n ownNames: (identity.ownNames ?? []).filter((name): name is string => Boolean(name)),\n ownDomains: (identity.ownDomains ?? []).filter((domain): domain is string => Boolean(domain)),\n }\n}\n\n/**\n * The workspace's own names and hosts, as the screen reads them: a brand in\n * its own name is its own, and a link to its own site is not \"elsewhere\".\n */\nexport function workspaceScreenIdentity(input: {\n org?: Record<string, unknown> | null\n host?: Record<string, unknown> | null\n}): Pick<PhishingScreenInput, 'ownNames' | 'ownDomains'> {\n const org = input.org ?? {}\n const host = input.host ?? {}\n const text = (value: unknown) => (typeof value === 'string' ? value : '')\n const subdomain = text(host['subdomain'])\n return {\n ownNames: [text(org['name']), text(host['name']), subdomain],\n ownDomains: [\n subdomain ? `${subdomain}.${TENANT_APEX}` : '',\n text(host['cname']),\n text(host['sendingDomain']),\n ],\n }\n}\n\n/**\n * JSON with every object's keys sorted, so the same design serializes to the\n * same string however it was read. Firestore hands back a map's keys in an\n * order that differs by reader, and a release keyed on a hash of an unsorted\n * serialization would not match the very content it released.\n */\nexport function canonicalJson(value: unknown): string {\n return JSON.stringify(value, (_key, entry) =>\n entry && typeof entry === 'object' && !Array.isArray(entry)\n ? Object.fromEntries(\n Object.keys(entry as Record<string, unknown>)\n .sort()\n .map((key) => [key, (entry as Record<string, unknown>)[key]]),\n )\n : entry,\n )\n}\n\n/** A stable hash of what a message says, so a release covers these words only. */\nexport function outboundContentHash(parts: readonly unknown[]): string {\n return createHash('sha256').update(canonicalJson(parts)).digest('hex')\n}\n\n/**\n * The review row's id: one row per (source, content). Hex, so the admin\n * route's report-id pattern addresses it like any intake row.\n */\nexport function heldOutboundReviewId(path: string, contentHash: string): string {\n return createHash('sha256')\n .update(`outbound-hold:${path}:${contentHash}`)\n .digest('hex')\n .slice(0, 40)\n}\n\n/** The staff-facing reference, in the intake's `AR-` style. */\nexport function heldOutboundReference(reviewId: string): string {\n return `HS-${reviewId.slice(0, 10).toUpperCase()}`\n}\n\nexport interface OutboundScreenRequest {\n kind: HeldOutboundSendKind\n path: string\n hostId: string\n orgId: string | null\n org: Record<string, unknown> | null\n host: Record<string, unknown> | null\n subject: string\n fromName?: string | null\n /**\n * The address it leaves from (AGL-3362). Read by the lookalike rule only,\n * and kept out of the content hash so a release granted before this was\n * read still names the same row.\n */\n fromAddress?: string | null\n replyTo?: string | readonly string[] | null\n preheader?: string | null\n bodies: readonly (string | null | undefined)[]\n nowMs?: number\n}\n\n/** What a screen answers for one piece of content. */\nexport type OutboundScreenOutcome =\n | {\n outcome: 'send'\n /** The review row staff released this content under, when there is one. */\n releasedReviewId?: string\n }\n | { outcome: 'held' | 'rejected'; reviewId: string; reference: string; contentHash: string }\n\n/** What a surface files when its screen holds, for {@link fileOutboundHold}. */\nexport interface OutboundHoldFiling {\n reviewId: string\n heldSend: HeldOutboundSend\n /** Extra fields a surface records beside `heldSend` (a page's version). */\n extra?: Record<string, unknown>\n /** The first line of the row's details, naming what was held. */\n headline: string\n /** The staff alert's title and body, the first time. */\n alertTitle: string\n alertBody: string\n /** The URL the row reports: the flagged host, or the held page. */\n url: string | null\n reportedHostname: string | null\n /**\n * What the workspace's owners are told the first time (AGL-3368): the held\n * item in their words and its console page. The alert title and body above\n * are the STAFF half; the owners read the catalog's own words, which never\n * name the signals.\n */\n item?: RiskEventItem | null\n /** A held page, described (AGL-3374): stored on the owners' notice for the console. */\n page?: RiskEventInput['page']\n}\n\n/**\n * The held item as the owners see it, from the surface and the source path\n * (AGL-3368): the campaign's own email page, the automation list, or nothing\n * for a message the send seam held (the notice then leads to Holds & reviews).\n */\nexport function heldSendItem(input: {\n kind: HeldOutboundSendKind\n path: string\n hostId: string\n subject: string\n context?: string | null\n}): RiskEventItem {\n const subject = input.subject.slice(0, 120)\n const id = input.path.split('/').pop() ?? ''\n switch (input.kind) {\n case 'campaign':\n return {\n label: `the campaign \"${subject}\"`,\n path: id ? `/org/emails/messages/${encodeURIComponent(id)}` : null,\n }\n case 'orgAutomation':\n return { label: `the automated email \"${subject}\"`, path: '/org/automation' }\n case 'workflow':\n case 'action':\n return { label: `the automated email \"${subject}\"`, path: `/${input.hostId}/automation` }\n case 'page':\n // A page's subject is its label (`heldPageLabel`, AGL-3374); a row\n // filed before that carried its URL.\n return { label: /^https?:\\/\\//.test(subject) ? `the page ${subject}` : subject, path: null }\n case 'listing':\n // The publisher's own Listings section of the Marketplace hub.\n return { label: `the marketplace submission \"${subject}\"`, path: '/org/marketplace/listings' }\n default:\n return {\n label: input.context\n ? `the email \"${subject}\" (${input.context})`\n : `the email \"${subject}\"`,\n path: null,\n }\n }\n}\n\n/**\n * Write (or count) the review row for a hold, and tell staff the first time.\n * Returns the row's decision state — a row staff already decided answers\n * with that decision and is not written.\n *\n * One writer for every surface, so a campaign, a seam message and a page\n * land in the queue in the one shape the admin route and page already read.\n */\nexport async function fileOutboundHold(\n filing: OutboundHoldFiling,\n): Promise<HeldOutboundSendState> {\n const firestore = firebaseAdmin.app().firestore()\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(filing.reviewId)\n const reference = heldOutboundReference(filing.reviewId)\n let first = false\n let state = 'held' as HeldOutboundSendState\n await firestore.runTransaction(async (transaction) => {\n const existing = await transaction.get(ref)\n first = !existing.exists\n const stored = existing.exists\n ? (existing.get('heldSend') as Partial<HeldOutboundSend> | undefined)\n : undefined\n state = stored?.state === 'released' || stored?.state === 'rejected'\n ? stored.state\n : 'held'\n if (state !== 'held') return\n transaction.set(\n ref,\n {\n reference,\n category: 'phishing',\n severity: 'urgent',\n // Who filed it: the screen, not a person. The queue reads it to\n // say so, and a reporter-contact field stays honestly empty.\n source: 'outbound-screen',\n // The flagged host as TEXT — the page never renders a reported URL\n // as a link, and this one may be a phishing kit's.\n url: filing.url,\n reportedHostname: filing.reportedHostname,\n hostId: filing.heldSend.hostId,\n orgId: filing.heldSend.orgId,\n details: [\n filing.headline,\n ...describePhishingScreenSignals(filing.heldSend.signals),\n filing.heldSend.kind === 'page'\n ? 'Dismiss to release this publish; mark it actioned to reject it.'\n : filing.heldSend.kind === 'listing'\n ? 'Dismiss to release this submission (the publisher submits it again and it ' +\n 'goes through); mark it actioned to reject it.'\n : 'Dismiss to release this send; mark it actioned to reject it.',\n ]\n .join('\\n')\n .slice(0, 5000),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n // Every message the same content held counts here, so a workflow\n // firing on each new contact reads as the volume it is.\n reportCount: FieldValue.increment(1),\n heldSend: first\n ? filing.heldSend\n : { ...filing.heldSend, heldAtMs: stored?.heldAtMs ?? filing.heldSend.heldAtMs },\n ...(filing.extra ?? {}),\n updatedAt: FieldValue.serverTimestamp(),\n ...(first\n ? { status: 'open', createdAt: FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n })\n if (first && state === 'held') {\n // The owners learn what was held and how to ask for a review; staff get\n // the alert with the evidence and a link to this row. One seam, once.\n await notifyRiskEvent({\n kind:\n filing.heldSend.kind === 'page'\n ? 'page-held'\n : filing.heldSend.kind === 'listing'\n ? 'listing-held'\n : 'email-held',\n orgId: filing.heldSend.orgId,\n hostId: filing.heldSend.hostId,\n reviewId: filing.reviewId,\n reference,\n occurredAtMs: filing.heldSend.heldAtMs,\n item:\n filing.item ??\n heldSendItem({\n kind: filing.heldSend.kind,\n path: filing.heldSend.path,\n hostId: filing.heldSend.hostId,\n subject: filing.heldSend.subject,\n }),\n page: filing.page ?? null,\n staffEvidence: [\n filing.alertBody,\n ...describePhishingScreenSignals(filing.heldSend.signals),\n ].join(' '),\n })\n }\n return state\n}\n\n/** The first signal that names a host, for the row's reported URL. */\nexport function flaggedHostOf(signals: readonly PhishingScreenSignal[]): string | null {\n const flagged = signals.find(\n (signal): signal is Extract<PhishingScreenSignal, { host: string }> => 'host' in signal,\n )\n return flagged?.host ?? null\n}\n\n/**\n * The screen's answer for one message, and what to do with it.\n *\n * - `send` — clean under the tiers, or held earlier and RELEASED by staff\n * for exactly this content (then `releasedReviewId` names the row, and the\n * caller hands it to `sendEmail` so the seam's screen honors it too).\n * - `held` — hold it. The review row is written (or its count bumped) and\n * staff are told the first time.\n * - `rejected` — staff rejected exactly this content. Never sent.\n *\n * Every workspace is screened; `signalsThatHold` applies the tiers, so an\n * established workspace is held only by a lookalike link.\n *\n * Fails OPEN on a store error, the posture of every other control on the\n * send path: an outage on the review queue must not become an outage on\n * every workspace's mail. The screen itself cannot fail.\n */\nexport async function screenOutboundSend(\n request: OutboundScreenRequest,\n): Promise<OutboundScreenOutcome> {\n const nowMs = request.nowMs ?? Date.now()\n const ageDays = orgAgeDays(\n (request.org as { createdAt?: unknown } | null)?.createdAt,\n nowMs,\n )\n const identity = workspaceScreenIdentity({ org: request.org, host: request.host })\n const verdict = screenOutboundEmail({\n subject: request.subject,\n fromName: request.fromName ?? null,\n fromAddress: request.fromAddress ?? null,\n replyTo: request.replyTo ?? null,\n preheader: request.preheader ?? null,\n bodies: request.bodies,\n ...identity,\n })\n // Every foreign link it carries, against the reputation list (AGL-3451):\n // its host, and in 'url' mode its address (AGL-3459). A listing holds for\n // every workspace, a failed lookup holds nothing.\n const reputation = await linkReputationSignals(\n linkUrlsIn([request.subject, request.preheader, ...request.bodies].filter(Boolean).join('\\n')),\n { ownDomains: identity.ownDomains },\n )\n const signals = signalsThatHold([...verdict.signals, ...reputation], { ageDays })\n if (!signals.length) return { outcome: 'send' }\n\n const contentHash = outboundContentHash([\n request.subject,\n request.fromName ?? '',\n request.replyTo ?? '',\n request.preheader ?? '',\n ...request.bodies.map((body) => body ?? ''),\n ])\n const reviewId = heldOutboundReviewId(request.path, contentHash)\n const reference = heldOutboundReference(reviewId)\n try {\n const heldSend: HeldOutboundSend = {\n kind: request.kind,\n path: request.path,\n hostId: request.hostId,\n orgId: request.orgId,\n contentHash,\n subject: request.subject.slice(0, 300),\n fromName: request.fromName ? String(request.fromName).slice(0, 120) : null,\n signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays,\n }\n const flagged = flaggedHostOf(signals)\n const state = await fileOutboundHold({\n reviewId,\n heldSend,\n headline:\n `Held ${request.kind === 'campaign' ? 'campaign' : `${request.kind} email step`} ` +\n `\"${heldSend.subject}\" from a workspace ${ageDays ?? '?'} day(s) old.`,\n alertTitle: 'Outbound email held for review — possible phishing',\n alertBody:\n `A ${request.kind === 'campaign' ? 'campaign' : 'automated email'} ` +\n `was held: \"${request.subject.slice(0, 120)}\".`,\n url: flagged ? `https://${flagged}/` : null,\n reportedHostname: flagged,\n })\n if (state === 'released') return { outcome: 'send', releasedReviewId: reviewId }\n return { outcome: state === 'rejected' ? 'rejected' : 'held', reviewId, reference, contentHash }\n } catch (error) {\n console.error('[outbound-review] hold could not be recorded — allowing', error)\n return { outcome: 'send' }\n }\n}\n\n/**\n * How long this process trusts what it last learned about a seam row: that\n * it is held (so the next message carrying the same signals skips the\n * write), or how staff decided it. Short, because a decision must reach a\n * sender within a minute; long enough that a newsletter fanned out to a\n * thousand people costs one transaction rather than a thousand on one\n * document.\n */\nconst SEAM_MEMO_TTL_MS = 60_000\nconst seamMemo = new Map<string, { state: HeldOutboundSendState; atMs: number }>()\n\n/** Test seam: forget what this process learned about seam rows. */\nexport function resetOutboundSeamMemoForTests(): void {\n seamMemo.clear()\n}\n\n/**\n * The seam row's id for a site and the signals that held. Keyed on the\n * SIGNALS rather than the words: a message at the seam is already\n * personalized per recipient, so its words differ for every person while\n * what makes it phishing — the lookalike host, the brand and the lure — does\n * not. A release therefore covers this site's use of exactly these signals,\n * and a message carrying any other signal is screened afresh.\n */\nexport function seamReviewId(hostId: string, signals: readonly PhishingScreenSignal[]): string {\n const key = signals\n .map((signal) => canonicalJson(signal))\n .sort()\n .join('\\n')\n return heldOutboundReviewId(`hosts/${hostId}/outbound`, outboundContentHash([key]))\n}\n\n/** Is this row a staff RELEASE for this site? */\nasync function isReleaseForHost(reviewId: string, hostId: string): Promise<boolean> {\n const memoKey = `release:${hostId}:${reviewId}`\n const memo = seamMemo.get(memoKey)\n if (memo && Date.now() - memo.atMs < SEAM_MEMO_TTL_MS) return memo.state === 'released'\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection(ABUSE_REPORT_COLLECTION)\n .doc(reviewId)\n .get()\n const held = snapshot.get('heldSend') as Partial<HeldOutboundSend> | undefined\n const released = held?.state === 'released' && held?.hostId === hostId\n seamMemo.set(memoKey, { state: released ? 'released' : 'held', atMs: Date.now() })\n return released\n}\n\n/**\n * The send seam's gate: what `sendEmail` asks when a tenant message carries\n * a signal that holds (see `outbound-screen-gate.ts`). Installed at module\n * load by {@link installOutboundScreenGate}.\n *\n * A seam hold REFUSES that one message (`held-for-review`) — the sender's own\n * failure handling reports it, the way it reports a suppression — and files\n * one row per site and signal set. Once staff release it, the site's next\n * message carrying those signals sends; reject it, and none does.\n */\nexport async function screenSeamMessage(\n request: OutboundScreenGateRequest,\n): Promise<OutboundScreenGateVerdict> {\n const { workspace } = request\n if (\n request.releasedReviewId &&\n (await isReleaseForHost(request.releasedReviewId, workspace.hostId))\n ) {\n return { outcome: 'send' }\n }\n const reviewId = seamReviewId(workspace.hostId, request.signals)\n const reference = heldOutboundReference(reviewId)\n const memo = seamMemo.get(reviewId)\n if (memo && Date.now() - memo.atMs < SEAM_MEMO_TTL_MS) {\n return memo.state === 'released'\n ? { outcome: 'send' }\n : { outcome: memo.state === 'rejected' ? 'rejected' : 'held', reference }\n }\n const nowMs = Date.now()\n const subject = String(request.subject ?? '').slice(0, 300)\n const context = request.context ? String(request.context).slice(0, 80) : null\n const flagged = flaggedHostOf(request.signals)\n const state = await fileOutboundHold({\n reviewId,\n heldSend: {\n kind: 'message',\n path: `hosts/${workspace.hostId}`,\n hostId: workspace.hostId,\n orgId: workspace.orgId,\n contentHash: outboundContentHash([request.signals]),\n subject,\n fromName: request.fromName ? String(request.fromName).slice(0, 120) : null,\n signals: request.signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays: workspace.ageDays,\n },\n extra: { heldContext: context },\n item: heldSendItem({ kind: 'message', path: `hosts/${workspace.hostId}`, hostId: workspace.hostId, subject, context }),\n headline:\n `Held ${context ? `\"${context}\" ` : ''}email \"${subject}\" from a workspace ` +\n `${workspace.ageDays ?? '?'} day(s) old. Every later message from this site ` +\n 'carrying the same signals waits on this decision.',\n alertTitle: 'Outbound email held for review — possible phishing',\n alertBody: `An email from a site was held: \"${subject.slice(0, 120)}\".`,\n url: flagged ? `https://${flagged}/` : null,\n reportedHostname: flagged,\n })\n seamMemo.set(reviewId, { state, atMs: Date.now() })\n return state === 'released'\n ? { outcome: 'send' }\n : { outcome: state === 'rejected' ? 'rejected' : 'held', reference }\n}\n\n/**\n * Puts the review store on `sendEmail`'s phishing screen.\n *\n * **Called at module load**, from the bottom of this file, for the reason\n * `installEmailSendGovernor` is — and this module is also imported by\n * `sending-domains.ts`, so any sender that resolved its identity through\n * `hostSendingIdentity` (the only thing that stamps a workspace on it, and\n * so the only thing the seam screens) has installed it.\n */\nexport function installOutboundScreenGate(): void {\n if (typeof setOutboundScreenGate !== 'function') return\n setOutboundScreenGate((request) => screenSeamMessage(request))\n // And the link reputation lookup the same screen asks (AGL-3451).\n installLinkReputationLookup()\n}\n\n/**\n * Apply a staff decision to a held send (the admin abuse route, in the same\n * act as the row's status change).\n *\n * The row's `heldSend.state` is written first: it is what the screen reads,\n * so an automation step released here sends on its next run whatever\n * happens to the rest. A CAMPAIGN is also put back on the clock (released)\n * or canceled (rejected) — but only while it is still the parked send this\n * row held: a send that has since been edited, sent or canceled by the\n * merchant is left alone, and the answer says so.\n */\nexport async function decideHeldOutboundSend(input: {\n reviewId: string\n decision: 'release' | 'reject'\n actorUid: string\n actorEmail: string | null\n nowMs?: number\n firestore?: FirebaseFirestore.Firestore\n}): Promise<'released' | 'rejected' | 'not-held' | 'campaign-moved'> {\n const nowMs = input.nowMs ?? Date.now()\n const firestore = input.firestore ?? firebaseAdmin.app().firestore()\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(input.reviewId)\n const snapshot = await ref.get()\n const heldSend = snapshot.get('heldSend') as HeldOutboundSend | undefined\n if (!heldSend?.path || !heldSend.contentHash) return 'not-held'\n const state: HeldOutboundSendState =\n input.decision === 'release' ? 'released' : 'rejected'\n const decided = {\n state,\n decidedAtMs: nowMs,\n decidedByUid: input.actorUid,\n decidedByEmail: input.actorEmail,\n }\n await ref.set(\n { heldSend: { ...heldSend, ...decided } },\n { merge: true },\n )\n if (heldSend.kind !== 'campaign') return state\n\n const sendRef = firestore.doc(heldSend.path)\n const moved = await firestore.runTransaction(async (transaction) => {\n const send = await transaction.get(sendRef)\n const review = send.get('staffReview') as\n | { reviewId?: string; state?: string }\n | undefined\n const status = String(send.get('status') ?? '')\n if (\n !send.exists ||\n review?.reviewId !== input.reviewId ||\n (status !== 'scheduled' && status !== 'draft')\n ) {\n return true\n }\n transaction.set(\n sendRef,\n input.decision === 'release'\n ? {\n status: 'scheduled',\n // Due now: the next processor run sends it, and the screen\n // finds this row released for exactly this content.\n sendAtMs: nowMs,\n staffReview: { ...review, ...decided },\n }\n : {\n status: 'canceled',\n sendAtMs: FieldValue.delete(),\n staffReview: { ...review, ...decided },\n },\n { merge: true },\n )\n return false\n })\n return moved ? 'campaign-moved' : state\n}\n\ninstallOutboundScreenGate()\n"],"names":["createHash","ABUSE_REPORT_COLLECTION","TENANT_APEX","linkReputationSignals","describePhishingScreenSignals","isYoungWorkspaceAge","linkUrlsIn","OUTBOUND_REVIEW_YOUNG_DAYS","screenOutboundEmail","signalsThatHold","setOutboundScreenGate","FieldValue","orgAgeDays","firebaseAdmin","notifyRiskEvent","installLinkReputationLookup","HELD_SEND_AT_MS","isYoungWorkspace","org","nowMs","Date","now","createdAt","sendingWorkspaceFor","input","identity","workspaceScreenIdentity","host","hostId","orgId","ageDays","ownNames","filter","name","Boolean","ownDomains","domain","text","value","subdomain","canonicalJson","JSON","stringify","_key","entry","Array","isArray","Object","fromEntries","keys","sort","map","key","outboundContentHash","parts","update","digest","heldOutboundReviewId","path","contentHash","slice","heldOutboundReference","reviewId","toUpperCase","heldSendItem","subject","id","split","pop","kind","label","encodeURIComponent","test","context","fileOutboundHold","filing","firestore","app","ref","collection","doc","reference","first","state","runTransaction","transaction","existing","get","exists","stored","undefined","set","category","severity","source","url","reportedHostname","heldSend","details","headline","signals","join","reporterEmail","reporterName","dmca","reportCount","increment","heldAtMs","extra","updatedAt","serverTimestamp","status","merge","occurredAtMs","item","page","staffEvidence","alertBody","flaggedHostOf","flagged","find","signal","screenOutboundSend","request","verdict","fromName","fromAddress","replyTo","preheader","bodies","reputation","length","outcome","body","String","alertTitle","releasedReviewId","error","console","SEAM_MEMO_TTL_MS","seamMemo","Map","resetOutboundSeamMemoForTests","clear","seamReviewId","isReleaseForHost","memoKey","memo","atMs","snapshot","held","released","screenSeamMessage","workspace","heldContext","installOutboundScreenGate","decideHeldOutboundSend","decision","decided","decidedAtMs","decidedByUid","actorUid","decidedByEmail","actorEmail","sendRef","moved","send","review","sendAtMs","staffReview","delete"],"mappings":"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"}
@@ -0,0 +1,51 @@
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
+ /**
18
+ * PAGED SWEEPS BY ONE FIELD (AGL-2623, AGL-3080): delete, or patch, every
19
+ * document in a collection whose field equals a value, a batch at a time.
20
+ *
21
+ * What an erasure does to each collection that files a person under a key —
22
+ * the platform's own sweeps and every plugin's share of a person erasure use
23
+ * the same two, so a bound or a failure reads the same in each.
24
+ *
25
+ * Bounded: a pathological collection must not hold the caller open
26
+ * indefinitely, so a sweep stops after {@link SWEEP_MAX_PAGES} pages of
27
+ * {@link SWEEP_PAGE}. Never throws: a failure is logged under the caller's
28
+ * label and the sweep stops, and the count says how far it got.
29
+ */
30
+ /** Firestore caps a batch at 500 writes; a page under it keeps a margin. */
31
+ export declare const SWEEP_PAGE = 400;
32
+ /**
33
+ * Twenty pages of four hundred is eight thousand rows of one kind for one
34
+ * key, past anything a real relationship produces.
35
+ */
36
+ export declare const SWEEP_MAX_PAGES = 20;
37
+ /**
38
+ * Deletes every document in `collection` whose `field` equals `value`, a page
39
+ * at a time. Returns how many went.
40
+ */
41
+ export declare function deleteWhereEquals(db: Pick<FirebaseFirestore.Firestore, 'batch'>, collection: FirebaseFirestore.Query, field: string, value: string, label: string): Promise<number>;
42
+ /**
43
+ * Applies `patch` — or what it answers for each document's data — to every
44
+ * document in `collection` whose `field` equals `value`. Returns how many
45
+ * changed.
46
+ *
47
+ * The patch must clear the very field that was matched on: a second page then
48
+ * never sees the first page's rows again, so the loop needs no cursor and the
49
+ * page cap is the only bound.
50
+ */
51
+ export declare function updateWhereEquals(db: Pick<FirebaseFirestore.Firestore, 'batch'>, collection: FirebaseFirestore.Query, field: string, value: string, patch: Record<string, unknown> | ((data: FirebaseFirestore.DocumentData) => Record<string, unknown>), label: string): Promise<number>;
@@ -0,0 +1,79 @@
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
+ * PAGED SWEEPS BY ONE FIELD (AGL-2623, AGL-3080): delete, or patch, every
18
+ * document in a collection whose field equals a value, a batch at a time.
19
+ *
20
+ * What an erasure does to each collection that files a person under a key —
21
+ * the platform's own sweeps and every plugin's share of a person erasure use
22
+ * the same two, so a bound or a failure reads the same in each.
23
+ *
24
+ * Bounded: a pathological collection must not hold the caller open
25
+ * indefinitely, so a sweep stops after {@link SWEEP_MAX_PAGES} pages of
26
+ * {@link SWEEP_PAGE}. Never throws: a failure is logged under the caller's
27
+ * label and the sweep stops, and the count says how far it got.
28
+ */ /** Firestore caps a batch at 500 writes; a page under it keeps a margin. */ export const SWEEP_PAGE = 400;
29
+ /**
30
+ * Twenty pages of four hundred is eight thousand rows of one kind for one
31
+ * key, past anything a real relationship produces.
32
+ */ export const SWEEP_MAX_PAGES = 20;
33
+ /**
34
+ * Deletes every document in `collection` whose `field` equals `value`, a page
35
+ * at a time. Returns how many went.
36
+ */ export async function deleteWhereEquals(db, collection, field, value, label) {
37
+ let removed = 0;
38
+ try {
39
+ for(let pass = 0; pass < SWEEP_MAX_PAGES; pass += 1){
40
+ const page = await collection.where(field, '==', value).limit(SWEEP_PAGE).get();
41
+ if (page.empty) break;
42
+ const batch = db.batch();
43
+ page.docs.forEach((doc)=>batch.delete(doc.ref));
44
+ await batch.commit();
45
+ removed += page.size;
46
+ if (page.size < SWEEP_PAGE) break;
47
+ }
48
+ } catch (error) {
49
+ console.error(`${label}: delete sweep failed on ${field}`, error);
50
+ }
51
+ return removed;
52
+ }
53
+ /**
54
+ * Applies `patch` — or what it answers for each document's data — to every
55
+ * document in `collection` whose `field` equals `value`. Returns how many
56
+ * changed.
57
+ *
58
+ * The patch must clear the very field that was matched on: a second page then
59
+ * never sees the first page's rows again, so the loop needs no cursor and the
60
+ * page cap is the only bound.
61
+ */ export async function updateWhereEquals(db, collection, field, value, patch, label) {
62
+ let changed = 0;
63
+ try {
64
+ for(let pass = 0; pass < SWEEP_MAX_PAGES; pass += 1){
65
+ const page = await collection.where(field, '==', value).limit(SWEEP_PAGE).get();
66
+ if (page.empty) break;
67
+ const batch = db.batch();
68
+ page.docs.forEach((doc)=>batch.update(doc.ref, typeof patch === 'function' ? patch(doc.data()) : patch));
69
+ await batch.commit();
70
+ changed += page.size;
71
+ if (page.size < SWEEP_PAGE) break;
72
+ }
73
+ } catch (error) {
74
+ console.error(`${label}: update sweep failed on ${field}`, error);
75
+ }
76
+ return changed;
77
+ }
78
+
79
+ //# sourceMappingURL=paged-sweeps.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/paged-sweeps.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 * PAGED SWEEPS BY ONE FIELD (AGL-2623, AGL-3080): delete, or patch, every\n * document in a collection whose field equals a value, a batch at a time.\n *\n * What an erasure does to each collection that files a person under a key —\n * the platform's own sweeps and every plugin's share of a person erasure use\n * the same two, so a bound or a failure reads the same in each.\n *\n * Bounded: a pathological collection must not hold the caller open\n * indefinitely, so a sweep stops after {@link SWEEP_MAX_PAGES} pages of\n * {@link SWEEP_PAGE}. Never throws: a failure is logged under the caller's\n * label and the sweep stops, and the count says how far it got.\n */\n\n/** Firestore caps a batch at 500 writes; a page under it keeps a margin. */\nexport const SWEEP_PAGE = 400\n\n/**\n * Twenty pages of four hundred is eight thousand rows of one kind for one\n * key, past anything a real relationship produces.\n */\nexport const SWEEP_MAX_PAGES = 20\n\n/**\n * Deletes every document in `collection` whose `field` equals `value`, a page\n * at a time. Returns how many went.\n */\nexport async function deleteWhereEquals(\n db: Pick<FirebaseFirestore.Firestore, 'batch'>,\n collection: FirebaseFirestore.Query,\n field: string,\n value: string,\n label: string,\n): Promise<number> {\n let removed = 0\n try {\n for (let pass = 0; pass < SWEEP_MAX_PAGES; pass += 1) {\n const page = await collection.where(field, '==', value).limit(SWEEP_PAGE).get()\n if (page.empty) break\n const batch = db.batch()\n page.docs.forEach((doc) => batch.delete(doc.ref))\n await batch.commit()\n removed += page.size\n if (page.size < SWEEP_PAGE) break\n }\n } catch (error) {\n console.error(`${label}: delete sweep failed on ${field}`, error)\n }\n return removed\n}\n\n/**\n * Applies `patch` — or what it answers for each document's data — to every\n * document in `collection` whose `field` equals `value`. Returns how many\n * changed.\n *\n * The patch must clear the very field that was matched on: a second page then\n * never sees the first page's rows again, so the loop needs no cursor and the\n * page cap is the only bound.\n */\nexport async function updateWhereEquals(\n db: Pick<FirebaseFirestore.Firestore, 'batch'>,\n collection: FirebaseFirestore.Query,\n field: string,\n value: string,\n patch:\n | Record<string, unknown>\n | ((data: FirebaseFirestore.DocumentData) => Record<string, unknown>),\n label: string,\n): Promise<number> {\n let changed = 0\n try {\n for (let pass = 0; pass < SWEEP_MAX_PAGES; pass += 1) {\n const page = await collection.where(field, '==', value).limit(SWEEP_PAGE).get()\n if (page.empty) break\n const batch = db.batch()\n page.docs.forEach((doc) =>\n batch.update(doc.ref, typeof patch === 'function' ? patch(doc.data()) : patch),\n )\n await batch.commit()\n changed += page.size\n if (page.size < SWEEP_PAGE) break\n }\n } catch (error) {\n console.error(`${label}: update sweep failed on ${field}`, error)\n }\n return changed\n}\n"],"names":["SWEEP_PAGE","SWEEP_MAX_PAGES","deleteWhereEquals","db","collection","field","value","label","removed","pass","page","where","limit","get","empty","batch","docs","forEach","doc","delete","ref","commit","size","error","console","updateWhereEquals","patch","changed","update","data"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;CAYC,GAED,0EAA0E,GAC1E,OAAO,MAAMA,aAAa,IAAG;AAE7B;;;CAGC,GACD,OAAO,MAAMC,kBAAkB,GAAE;AAEjC;;;CAGC,GACD,OAAO,eAAeC,kBACpBC,EAA8C,EAC9CC,UAAmC,EACnCC,KAAa,EACbC,KAAa,EACbC,KAAa;IAEb,IAAIC,UAAU;IACd,IAAI;QACF,IAAK,IAAIC,OAAO,GAAGA,OAAOR,iBAAiBQ,QAAQ,EAAG;YACpD,MAAMC,OAAO,MAAMN,WAAWO,KAAK,CAACN,OAAO,MAAMC,OAAOM,KAAK,CAACZ,YAAYa,GAAG;YAC7E,IAAIH,KAAKI,KAAK,EAAE;YAChB,MAAMC,QAAQZ,GAAGY,KAAK;YACtBL,KAAKM,IAAI,CAACC,OAAO,CAAC,CAACC,MAAQH,MAAMI,MAAM,CAACD,IAAIE,GAAG;YAC/C,MAAML,MAAMM,MAAM;YAClBb,WAAWE,KAAKY,IAAI;YACpB,IAAIZ,KAAKY,IAAI,GAAGtB,YAAY;QAC9B;IACF,EAAE,OAAOuB,OAAO;QACdC,QAAQD,KAAK,CAAC,GAAGhB,MAAM,yBAAyB,EAAEF,OAAO,EAAEkB;IAC7D;IACA,OAAOf;AACT;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAeiB,kBACpBtB,EAA8C,EAC9CC,UAAmC,EACnCC,KAAa,EACbC,KAAa,EACboB,KAEuE,EACvEnB,KAAa;IAEb,IAAIoB,UAAU;IACd,IAAI;QACF,IAAK,IAAIlB,OAAO,GAAGA,OAAOR,iBAAiBQ,QAAQ,EAAG;YACpD,MAAMC,OAAO,MAAMN,WAAWO,KAAK,CAACN,OAAO,MAAMC,OAAOM,KAAK,CAACZ,YAAYa,GAAG;YAC7E,IAAIH,KAAKI,KAAK,EAAE;YAChB,MAAMC,QAAQZ,GAAGY,KAAK;YACtBL,KAAKM,IAAI,CAACC,OAAO,CAAC,CAACC,MACjBH,MAAMa,MAAM,CAACV,IAAIE,GAAG,EAAE,OAAOM,UAAU,aAAaA,MAAMR,IAAIW,IAAI,MAAMH;YAE1E,MAAMX,MAAMM,MAAM;YAClBM,WAAWjB,KAAKY,IAAI;YACpB,IAAIZ,KAAKY,IAAI,GAAGtB,YAAY;QAC9B;IACF,EAAE,OAAOuB,OAAO;QACdC,QAAQD,KAAK,CAAC,GAAGhB,MAAM,yBAAyB,EAAEF,OAAO,EAAEkB;IAC7D;IACA,OAAOI;AACT"}
@@ -14,55 +14,6 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- /**
18
- * The record a console door writes when a person decides about the
19
- * platform's own product email (AGL-3185).
20
- *
21
- * ## Two documents, one decision
22
- *
23
- * The decision goes onto the person's own `users/{uid}` first — the console
24
- * shows the preference from there, and it must not depend on the operator's
25
- * CRM accepting a row — and then onto the operator's marketing contact for
26
- * the same address through `upsertHostContact`, which is every other capture
27
- * door's path and the only one the send-time consent join reads. The contact
28
- * half carries the decision AND its provenance, so a campaign's audience
29
- * count reports this basis as the person's own click and never as something
30
- * asserted on their behalf.
31
- *
32
- * ## Server-side, because the client cannot and should not
33
- *
34
- * The sign-up page is a client of the person's own project; the operator's
35
- * marketing org is somebody else's data. Only the Admin SDK writes there, and
36
- * only after the person's ID token has been verified by the route in front
37
- * of this module. The user-document half could be a client write — the rules
38
- * make it owner-writable — but a mirror the client writes and the server
39
- * reads back is two writers of one fact, so both halves are written here.
40
- *
41
- * ## Which host, and why it is a setting
42
- *
43
- * The marketing host is the deployment's, not the code's: this repository is
44
- * what a self-hosted install runs, and an install with no product audience of
45
- * its own has nowhere for a contact to go. `PLATFORM_MARKETING_HOST_ID` names
46
- * it, and when it is unset the decision is recorded on the person's document
47
- * alone. No hostname or document id is hardcoded here for the same reason
48
- * `platform-marketing-host.ts` gives: nothing in the code knows which site
49
- * is the operator's.
50
- *
51
- * ## The site's own pages answer the same question, for one list (AGL-3305)
52
- *
53
- * The marketing site's emails carry their own way out, and it writes the
54
- * site's lists, not either consent record. An unsubscribe that was about the
55
- * list the console's question stands for — `PLATFORM_MARKETING_TOPIC_ID`, or
56
- * everything the site sends — is mirrored onto the ACCOUNT here, through an
57
- * email door; an unsubscribe from any other list is not, because the person
58
- * said nothing about product updates. The contact is left alone on purpose:
59
- * its basis covers every list the site sends, and declining it would stop the
60
- * newsletter for somebody who only left product updates.
61
- *
62
- * The console's switch undoes it the same way, one list wide: a Yes from a
63
- * verified account asks the plugin that keeps the site's preferences to
64
- * reopen that list and nothing else (`plugin-email-streams`).
65
- */
66
17
  import { type TopicSubscriptionState } from '@aglyn/aglyn/app-utils/subscription-topics';
67
18
  import { type PlatformMarketingConsentDecision, type PlatformMarketingConsoleSourceKind, type PlatformMarketingConsentState } from '@aglyn/aglyn/app-utils/platform-marketing-consent';
68
19
  import { type MarketingBasis, type MarketingConsentAssertedBy, type MarketingConsentReason, type MarketingConsentVerdict } from '@aglyn/aglyn/app-utils/marketing-consent';
@@ -62,15 +62,16 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
62
62
  * The console's switch undoes it the same way, one list wide: a Yes from a
63
63
  * verified account asks the plugin that keeps the site's preferences to
64
64
  * reopen that list and nothing else (`plugin-email-streams`).
65
- */ import { readTopicSubscriptionState, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/subscription-topics";
65
+ */ import { stampRecordOrigin } from "@aglyn/aglyn/plugin-manager/plugin-record-origin";
66
+ import { readTopicSubscriptionState, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/subscription-topics";
66
67
  import { isPlatformMarketingConsentDecision, isPlatformMarketingConsoleSourceKind, isPlatformMarketingEmailSourceKind, platformMarketingConsentSource, platformMarketingEmailSource, PLATFORM_MARKETING_TOPIC_ID, platformMarketingUserFields, readPlatformMarketingConsent, USER_MARKETING_PROMPT_DISMISSED_AT_FIELD } from "@aglyn/aglyn/app-utils/platform-marketing-consent";
67
68
  import { marketingConsentDecision, readMarketingBasis, resolveMarketingConsentPolicy } from "@aglyn/aglyn/app-utils/marketing-consent";
68
69
  import { rejoinEmailStream } from "@aglyn/aglyn/plugin-manager/plugin-email-streams";
69
70
  import { attributableAccountForAddress } from "./account-addresses.js";
70
- import { findContactByEmail } from "./contact-email-index.js";
71
+ import { findPluginPerson } from "@aglyn/aglyn/plugin-manager/plugin-person-records";
71
72
  import { EMAIL_SUPPRESSIONS_COLLECTION, emailSuppressionKey, HOST_SUPPRESSIONS_SUBCOLLECTION } from "./email-suppression.js";
72
73
  import firebaseAdmin from "./firebase-admin.js";
73
- import { consentGroupForSite, getOrgForHost, orgDataCollectionForHost } from "./organizations.js";
74
+ import { consentGroupForSite, getOrgForHost } from "./organizations.js";
74
75
  import { upsertHostContact } from "./upsert-contact.js";
75
76
  const firestore = ()=>firebaseAdmin.app().firestore();
76
77
  /**
@@ -192,6 +193,16 @@ const firestore = ()=>firebaseAdmin.app().firestore();
192
193
  contactId: verdict.contactId,
193
194
  created: verdict.created
194
195
  };
196
+ // Where the person came from (AGL-3519): an account sign-up, on a record
197
+ // the platform's own CRM just started and that names no lead source.
198
+ if (contact.status === 'recorded') {
199
+ await stampRecordOrigin({
200
+ hostId,
201
+ email,
202
+ origin: 'account',
203
+ firstTouchOnly: true
204
+ });
205
+ }
195
206
  if (input.decision !== 'granted') return {
196
207
  atMs,
197
208
  contact
@@ -320,33 +331,38 @@ const firestore = ()=>firebaseAdmin.app().firestore();
320
331
  };
321
332
  const db = (_input_firestore = input.firestore) != null ? _input_firestore : firestore();
322
333
  try {
323
- var _ref, _ref1, _ref2;
324
- var _contact_data, _record_source;
334
+ var _ref, _ref1;
335
+ var _record_source;
325
336
  const owner = await getOrgForHost(hostId);
326
337
  if (!owner) return {
327
338
  status: 'unreadable',
328
339
  hostId
329
340
  };
330
341
  const org = owner.org;
331
- const [contact, group, suppression, topic] = await Promise.all([
332
- // Through the org-data seam, as the capture door finds the same row.
333
- orgDataCollectionForHost(hostId, 'contacts').then((contacts)=>findContactByEmail(contacts, email)),
342
+ const [person, group, suppression, topic] = await Promise.all([
343
+ // The operator's person for the address, asked of the plugin that
344
+ // keeps people (AGL-3080) — the record the capture door wrote.
345
+ findPluginPerson({
346
+ orgId: owner.orgId,
347
+ hostId,
348
+ email
349
+ }),
334
350
  consentGroupForSite(hostId, org),
335
351
  readPlatformMarketingSuppression(db, hostId, email),
336
352
  readPlatformMarketingTopic(db, hostId, email)
337
353
  ]);
338
- const record = readMarketingBasis((_ref = contact == null ? void 0 : (_contact_data = contact.data) == null ? void 0 : _contact_data.call(contact)) != null ? _ref : null, group);
354
+ const record = readMarketingBasis(person ? _extends({}, person.data) : null, group);
339
355
  const decision = marketingConsentDecision(record, resolveMarketingConsentPolicy(org['marketingConsentPolicy']));
340
356
  return {
341
357
  status: 'read',
342
358
  hostId,
343
359
  orgId: owner.orgId,
344
360
  orgSlug: typeof org['slug'] === 'string' ? org['slug'] : null,
345
- contactId: (_ref1 = contact == null ? void 0 : contact.id) != null ? _ref1 : null,
361
+ contactId: (_ref = person == null ? void 0 : person.id) != null ? _ref : null,
346
362
  basis: record.basis,
347
363
  basisAtMs: record.basisAtMs,
348
364
  assertedBy: record.assertedBy,
349
- basisKind: (_ref2 = (_record_source = record.source) == null ? void 0 : _record_source.kind) != null ? _ref2 : null,
365
+ basisKind: (_ref1 = (_record_source = record.source) == null ? void 0 : _record_source.kind) != null ? _ref1 : null,
350
366
  verdict: decision.verdict,
351
367
  reason: decision.reason,
352
368
  suppression,