@aglyn/tenant-data-admin 1.0.0-beta.222 → 1.0.0-beta.223
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/lib/server/ga4-measurement-protocol.js +41 -28
- package/src/lib/server/ga4-measurement-protocol.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +13 -0
- package/src/lib/server/host-visitor-records.js +22 -4
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.js +6 -3
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/outbound-send-review.js +6 -3
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/payment-fraud-signal.d.ts +49 -9
- package/src/lib/server/payment-fraud-signal.js +192 -48
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/render-monitor.d.ts +166 -0
- package/src/lib/server/render-monitor.js +412 -0
- package/src/lib/server/render-monitor.js.map +1 -0
- package/src/lib/server/risk-notice.d.ts +7 -0
- package/src/lib/server/risk-notice.js +6 -0
- package/src/lib/server/risk-notice.js.map +1 -1
- package/src/lib/server/web-risk.d.ts +2 -0
- package/src/lib/server/web-risk.js +18 -21
- package/src/lib/server/web-risk.js.map +1 -1
- package/src/tenancy.d.ts +0 -2
- package/src/tenancy.js +0 -2
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/crm-email-activity.d.ts +0 -118
- package/src/lib/server/crm-email-activity.js +0 -148
- package/src/lib/server/crm-email-activity.js.map +0 -1
- package/src/lib/server/crm-next-activity.d.ts +0 -74
- package/src/lib/server/crm-next-activity.js +0 -194
- package/src/lib/server/crm-next-activity.js.map +0 -1
|
@@ -97,10 +97,11 @@ import { heldPageConsolePath, heldPageDetails, heldPageLabel, heldPageVisitorSen
|
|
|
97
97
|
import { screenHostedPage, screenSiteRedirect } from "@aglyn/shared-util-email/hosted-page-screen";
|
|
98
98
|
import { lookalikeBrandForHost, phishingScreenBrandLabel, describePhishingScreenSignals, isYoungWorkspaceAge, signalsThatHold } from "@aglyn/shared-util-email/outbound-phishing-screen";
|
|
99
99
|
import { foreignLinksForReputation, webRiskSignals } from "@aglyn/shared-util-email/link-reputation";
|
|
100
|
+
import { boundedAwait } from "@aglyn/shared-util-http/bounded-await";
|
|
100
101
|
import { FieldValue } from "firebase-admin/firestore";
|
|
101
102
|
import firebaseAdmin from "./firebase-admin.js";
|
|
102
103
|
import { describeHeldPage } from "./held-page-subject.js";
|
|
103
|
-
import { notifyRiskEvent } from "./risk-notice.js";
|
|
104
|
+
import { notifyRiskEvent, RISK_NOTICE_RENDER_DEADLINE_MS } from "./risk-notice.js";
|
|
104
105
|
import { orgAgeDays } from "./org-age.js";
|
|
105
106
|
import { requestPageSecurityHold } from "./page-security-hold.js";
|
|
106
107
|
import { getHostDocAdmin, getOrgForHost } from "./organizations.js";
|
|
@@ -620,7 +621,9 @@ async function isAlreadyLive(hostId, screenId, versionId, nowMs) {
|
|
|
620
621
|
merge: true
|
|
621
622
|
});
|
|
622
623
|
if (!first) return;
|
|
623
|
-
|
|
624
|
+
// Inside the page's render: bounded, so a mail or webhook outage cannot
|
|
625
|
+
// hold the page (AGL-3565).
|
|
626
|
+
await boundedAwait(notifyRiskEvent({
|
|
624
627
|
kind: 'page-flagged',
|
|
625
628
|
orgId: input.orgId,
|
|
626
629
|
hostId: input.hostId,
|
|
@@ -635,7 +638,7 @@ async function isAlreadyLive(hostId, screenId, versionId, nowMs) {
|
|
|
635
638
|
...heldPageDetails(subject),
|
|
636
639
|
evidence
|
|
637
640
|
].join(' ')
|
|
638
|
-
});
|
|
641
|
+
}), RISK_NOTICE_RENDER_DEADLINE_MS, null, 'risk-notice.page-flagged');
|
|
639
642
|
} catch (error) {
|
|
640
643
|
console.error('[page-review] a live page could not be flagged', error);
|
|
641
644
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/hosted-page-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: a published page (AGL-3362).\n *\n * ## Why the check sits where a page is COMPOSED, not where it is published\n *\n * A publish is a browser write. The besigner toolbar, the versions panel,\n * the screen details page, page-from-template, the layout and component\n * besigners and the form besigner each move a pointer (`versionId`, the\n * host's `screens` map, a component's own `nodes`) straight from the\n * client SDK; the scheduled-publish beat moves it from the server; an\n * experiment serves a version no pointer names; and an author may rewrite\n * the LIVE version document itself without moving any pointer at all. A\n * check on any one of those writes is a check the next one walks around.\n *\n * They all meet in one place: `composeScreenNodes` (`@aglyn/tenant-runtime`),\n * which every published render, the password unlock, the collection and\n * product templates and every experiment variant go through, and which\n * returns the page exactly as a visitor will receive it — the screen, its\n * layout chrome, its grafted components and its forms. So the screen is read\n * there, and a held page is one that is never composed for a visitor. For a\n * visitor, \"publish\" is the first render after the pointer moved; this is\n * that moment.\n *\n * ## What a hold does\n *\n * - The version is not served. The last version this page served clean\n * ({@link servedPageVersion}) is composed instead — screened again, since\n * a version document can be edited in place — and if there is none, or it\n * holds too, the page is not found. A first publish that holds therefore\n * serves nothing.\n * - One review row per (page, signal set) lands in the abuse queue at\n * `/admin/abuse-reports`, the same `phishing` row a held email files\n * (`fileOutboundHold`), with `heldSend.kind: 'page'`, the page's URL and\n * the held version. Staff dismiss it to RELEASE (the admin route drops the\n * site's cache and the next render serves it) or mark it actioned to\n * REJECT (it stays unserved; any later version carrying the same signals\n * is refused without a new row).\n * - A hold from a workspace in its first fortnight also asks for an\n * automatic `security` hold on the workspace, the site and the publishing\n * account (`page-security-hold.ts`, AGL-3450), which the console places.\n * - Keyed on the SIGNALS, as the send seam's rows are: a composed page\n * carries per-render values (dates, collection rows) that no content hash\n * would survive, while what makes it phishing does not change between\n * renders.\n *\n * ## Tiers\n *\n * `signalsThatHold`: a lookalike link or embed, an author-defined\n * credential field and a link to a host Google Web Risk lists\n * (`web-risk-link`, AGL-3451) hold for EVERY workspace; a brand's call to\n * action or lure, and a call to action that leaves the site beside a lure,\n * only for a workspace in its first fortnight. The workspace's documents are\n * read only when something was found, so a clean page costs no read for\n * this. The page's foreign hosts — and, in `'url'` mode (AGL-3459), its\n * links' addresses without their query strings — are looked up first,\n * against a cache (`web-risk.ts`), and noted beside the version it serves so\n * the daily re-check can look again: a harvester is often listed only after\n * it is published. A lookup that fails finds nothing, so it never holds a\n * page.\n *\n * Fails CLOSED, unlike the send path: a page the pure screen flagged whose\n * review cannot finish — the workspace read, the row write — is answered\n * `held`, and the composer serves its last clean version or nothing. The\n * answer is not remembered, so the next render reviews it again. Composition\n * itself reads the store, so an outage here is an outage on the render\n * already, and the only cost of not serving a page the screen flagged is a\n * false positive's wait; serving it is the phishing page this exists to\n * stop. A page the screen found nothing on never reaches this.\n *\n * ## Site redirects\n *\n * A redirect rule that sends a path off the site is a page whose one call to\n * action fires on arrival, so the redirects plugin asks\n * {@link reviewSiteRedirect} before it serves one. Same screen\n * (`screenSiteRedirect`), same tiers, same row and the same staff decision:\n * a held rule does not fire, and is filed as a `page` hold on its rule's\n * document. Its destination is looked up against the reputation list too\n * (AGL-3451), so a redirect to a listed site holds for every workspace.\n *=========================================*/\n\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport {\n heldPageConsolePath,\n heldPageDetails,\n heldPageLabel,\n heldPageVisitorSentence,\n type HeldPageSubject,\n} from '@aglyn/shared-util-email/held-page'\nimport { screenHostedPage, screenSiteRedirect } from '@aglyn/shared-util-email/hosted-page-screen'\nimport {\n lookalikeBrandForHost,\n phishingScreenBrandLabel,\n type PhishingScreenSignal,\n describePhishingScreenSignals,\n isYoungWorkspaceAge,\n signalsThatHold,\n} from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport {\n type ForeignReputationLinks,\n foreignLinksForReputation,\n webRiskSignals,\n} from '@aglyn/shared-util-email/link-reputation'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\nimport { describeHeldPage, type HostedPageContext } from './held-page-subject'\nimport { notifyRiskEvent } from './risk-notice'\nimport { orgAgeDays } from './org-age'\nimport { requestPageSecurityHold } from './page-security-hold'\nimport { getHostDocAdmin, getOrgForHost } from './organizations'\nimport { lookupLinkReputation, platformReputationExclusions } from './web-risk'\nimport {\n canonicalJson,\n fileOutboundHold,\n flaggedHostOf,\n heldOutboundReference,\n heldOutboundReviewId,\n outboundContentHash,\n type HeldOutboundSendState,\n workspaceScreenIdentity,\n} from './outbound-send-review'\n\n/** Where the last version a page served clean is noted: a hint, re-screened before use. */\nexport const PAGE_REVIEW_SUBCOLLECTION = 'pageReviews'\n\nexport interface HostedPageReviewRequest {\n hostId: string\n screenId: string\n versionId: string\n /** The page as composed for a visitor. */\n nodes: unknown\n /** The host and org documents, when the caller already holds them. */\n host?: Record<string, unknown> | null\n org?: Record<string, unknown> | null\n orgId?: string | null\n nowMs?: number\n /**\n * What the composer knows about the page (AGL-3374): the screen document,\n * the template route it is rendering, the variant, and a way to read the\n * layouts and components composed into it. Read only when a row is filed,\n * to name the page — never to decide whether it serves.\n */\n page?: HostedPageContext | null\n /**\n * Signals found about this page elsewhere, judged with the page's own\n * (AGL-3451): the daily link re-check passes a link Web Risk has listed\n * since the page went live, so the hold, its row and the young\n * workspace's security hold all come from this one path.\n */\n extraSignals?: readonly PhishingScreenSignal[]\n}\n\nexport type HostedPageReviewOutcome =\n | { outcome: 'serve' }\n | { outcome: 'held' | 'rejected'; reviewId: string; reference: string }\n\n/** The review row's id for a page and the signals that held. */\nexport function pageReviewId(\n hostId: string,\n screenId: string,\n signals: readonly PhishingScreenSignal[],\n): string {\n const key = signals\n .map((signal) => canonicalJson(signal))\n .sort()\n .join('\\n')\n return heldOutboundReviewId(`hosts/${hostId}/screens/${screenId}`, outboundContentHash([key]))\n}\n\n/** How long this process trusts a page row's state — the send seam's minute. */\nconst PAGE_MEMO_TTL_MS = 60_000\nconst pageMemo = new Map<string, { state: HeldOutboundSendState; atMs: number }>()\n/** The version each page last served clean, as this process last wrote it. */\nconst servedMemo = new Map<string, string>()\n/** Lookalike hosts this process already flagged for an established page, and when. */\nconst flaggedMemo = new Map<string, number>()\nconst FLAGGED_MEMO_TTL_MS = 60 * 60_000\n/**\n * The foreign hosts each reviewed page version links to (AGL-3451), and the\n * addresses on them worth asking about (AGL-3459), for\n * {@link recordServedPageVersion} to note beside the version it served, which\n * is what the daily link re-check walks. Bounded: a process that reviews more\n * pages than this starts again.\n */\nconst reviewedForeignHosts = new Map<string, ForeignReputationLinks>()\nconst REVIEWED_FOREIGN_HOSTS_MAX = 10_000\n\n/** Test seam: forget what this process learned. */\nexport function resetHostedPageReviewMemoForTests(): void {\n pageMemo.clear()\n servedMemo.clear()\n flaggedMemo.clear()\n reviewedForeignHosts.clear()\n}\n\n/**\n * Every foreign host the page links to, the addresses on them worth asking\n * about, and the ones Google Web Risk lists (AGL-3451; addresses only in\n * `'url'` mode, AGL-3459). Foreign before the workspace is known: not the\n * platform's own, not a brand's own domain, not a common social link. The\n * addresses carry no query string or fragment. Never throws; a lookup that\n * failed lists nothing.\n */\nasync function pageLinkReputation(\n linkUrls: readonly string[],\n): Promise<ForeignReputationLinks & { signals: PhishingScreenSignal[] }> {\n // Runs before the review's fail-closed `try`, so it must not throw: an\n // answer it cannot give is no listing, never a reason to hold.\n try {\n const foreign = foreignLinksForReputation(linkUrls, {\n excludeDomains: platformReputationExclusions(),\n })\n if (!foreign.hosts.length) return { ...foreign, signals: [] }\n const answer = await lookupLinkReputation(foreign.hosts, { urls: foreign.urls })\n return { ...foreign, signals: webRiskSignals(answer.hits) }\n } catch (error) {\n console.warn('[page-review] link reputation could not be read — no listing assumed', error)\n return { hosts: [], urls: [], signals: [] }\n }\n}\n\n/** Is this host one of the workspace's own? */\nfunction isOwnHost(host: string, ownDomains: readonly (string | null | undefined)[] | undefined): boolean {\n return (ownDomains ?? []).some((own) => {\n const domain = String(own ?? '').trim().toLowerCase()\n return Boolean(domain) && (host === domain || host.endsWith(`.${domain}`))\n })\n}\n\n/**\n * The subject a row already stored, or a fresh description (AGL-3374). A row\n * that names its page is not described again, so the reads the description\n * may make are paid once per row, not once per render.\n */\nasync function subjectForRow(\n reviewId: string,\n describe: () => Promise<HeldPageSubject>,\n): Promise<HeldPageSubject> {\n const stored = await firebaseAdmin\n .app()\n .firestore()\n .collection(ABUSE_REPORT_COLLECTION)\n .doc(reviewId)\n .get()\n .then((row) => storedSubject(row.get('heldPage')))\n .catch(() => null)\n return stored ?? describe()\n}\n\n/** A row's stored subject, when it has one. */\nfunction storedSubject(heldPage: unknown): HeldPageSubject | null {\n const subject = (heldPage as { subject?: HeldPageSubject } | null | undefined)?.subject\n return subject && typeof subject === 'object' && subject.screenId ? subject : null\n}\n\n/** The row's first line, naming the page and where it was seen. */\nfunction pageHeadline(subject: HeldPageSubject, siteName: string): string {\n return [`${heldPageLabel(subject)} of ${siteName}.`, ...heldPageDetails(subject)].join(' ')\n}\n\n/**\n * Screen one page as a visitor would receive it, and say whether it may be\n * served. See the module header.\n */\nexport async function reviewHostedPage(\n request: HostedPageReviewRequest,\n): Promise<HostedPageReviewOutcome> {\n const found = screenHostedPage({ nodes: request.nodes })\n // Every foreign host it points at, against the reputation list\n // (AGL-3451): a harvester known to be bad anywhere holds however plain the\n // page around it is. Strong, so it holds at any workspace age.\n const reputation = await pageLinkReputation(found.linkUrls)\n if (reviewedForeignHosts.size >= REVIEWED_FOREIGN_HOSTS_MAX) reviewedForeignHosts.clear()\n reviewedForeignHosts.set(`${request.hostId}/${request.screenId}/${request.versionId}`, {\n hosts: reputation.hosts,\n urls: reputation.urls,\n })\n const listed = [...reputation.signals, ...(request.extraSignals ?? [])]\n if (!found.signals.length && !listed.length) return { outcome: 'serve' }\n\n const nowMs = request.nowMs ?? Date.now()\n try {\n const [host, owner] = await Promise.all([\n request.host !== undefined ? request.host : getHostDocAdmin(request.hostId),\n request.org !== undefined\n ? { orgId: request.orgId ?? null, org: request.org }\n : getOrgForHost(request.hostId),\n ])\n const org = (owner?.org as Record<string, unknown> | null | undefined) ?? null\n const orgId = owner?.orgId ?? null\n // Again, now knowing whose page it is: a brand in the workspace's own\n // name or domain is its own, and a link to its own site is not away.\n const identity = workspaceScreenIdentity({ org, host })\n const verdict = screenHostedPage({ nodes: request.nodes, ...identity })\n // A listing of the workspace's own host is not a link away from it.\n const screened = [\n ...verdict.signals,\n ...listed.filter(\n (signal) => !('host' in signal) || !isOwnHost(signal.host, identity.ownDomains),\n ),\n ]\n const ageDays = orgAgeDays((org as { createdAt?: unknown } | null)?.createdAt, nowMs)\n // Every workspace is screened, however old: an established account can\n // be compromised, and a compromised account publishing a lure is the\n // same harm as a new one. What differs is what a hold may take DOWN.\n // Holding content that is already live serves its last clean version or\n // nothing, and on a real business's home page that is an outage the\n // screen caused (aglyn.com, 2026-09-28, a page that had served clean\n // until a rule changed). So for an established workspace:\n // - NEW content (a new version of a live page, or a page that has\n // never been live) holds, exactly as for a young workspace; the live\n // page keeps serving its last clean version, or the new page waits;\n // - content ALREADY LIVE keeps serving, and is flagged to staff as\n // urgent, who lock the site if it is impersonation.\n if (!isYoungWorkspaceAge(ageDays)) {\n const strong = signalsThatHold(screened, { ageDays })\n if (!strong.length) return { outcome: 'serve' }\n if (await isAlreadyLive(request.hostId, request.screenId, request.versionId, nowMs)) {\n await flagLivePage({\n hostId: request.hostId,\n screenId: request.screenId,\n versionId: request.versionId,\n orgId,\n host,\n signals: strong,\n nowMs,\n page: request.page ?? null,\n identity,\n })\n return { outcome: 'serve' }\n }\n }\n const signals = signalsThatHold(screened, { ageDays })\n if (!signals.length) return { outcome: 'serve' }\n\n const reviewId = pageReviewId(request.hostId, request.screenId, signals)\n const reference = heldOutboundReference(reviewId)\n const memo = pageMemo.get(reviewId)\n if (memo && nowMs - memo.atMs < PAGE_MEMO_TTL_MS) {\n return memo.state === 'released'\n ? { outcome: 'serve' }\n : { outcome: memo.state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n }\n // The page by its real name and route (AGL-3374): a template, a list,\n // a variant, or a page whose flagged content lives in its layout.\n const subject = await subjectForRow(reviewId, async () =>\n describeHeldPage({\n hostId: request.hostId,\n screenId: request.screenId,\n versionId: request.versionId,\n host,\n page: request.page,\n signals,\n identity,\n live: false,\n previousVersionId: await servedPageVersion(request.hostId, request.screenId),\n }),\n )\n const url = subject.entryUrl ?? subject.url\n const flagged = flaggedHostOf(signals)\n const siteName =\n typeof host?.['name'] === 'string' && host['name'] ? `\"${host['name']}\"` : 'a site'\n const label = heldPageLabel(subject)\n const state = await fileOutboundHold({\n reviewId,\n heldSend: {\n kind: 'page',\n path: `hosts/${request.hostId}/screens/${request.screenId}`,\n hostId: request.hostId,\n orgId,\n contentHash: outboundContentHash([signals]),\n subject: label.slice(0, 300),\n fromName: null,\n signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays,\n },\n extra: {\n heldPage: { screenId: request.screenId, versionId: request.versionId, url, subject },\n },\n headline:\n `Held ${pageHeadline(subject, siteName)} Version ${request.versionId}, from a ` +\n `workspace ${ageDays ?? '?'} day(s) old. ${heldPageVisitorSentence(subject)}`,\n alertTitle: 'Published page held for review — possible phishing',\n alertBody: `${label.charAt(0).toUpperCase()}${label.slice(1)} was held before it went live.`,\n // The owners' words for it, and where they fix it (AGL-3368, AGL-3374).\n item: { label, path: heldPageConsolePath(subject, request.hostId) },\n page: subject,\n // The page itself is what was reported; the flagged host, when there\n // is one, is named in the details and on `reportedHostname`.\n url,\n reportedHostname: flagged,\n })\n // A hold from a workspace in its first fortnight also holds the\n // workspace, the site and the account that published it (AGL-3450).\n if (state === 'held') {\n await requestPageSecurityHold({\n orgId: orgId ?? '',\n org,\n hostId: request.hostId,\n screenId: request.screenId,\n versionId: request.versionId,\n reviewId,\n reference,\n signals,\n ageDays,\n pageLabel: label,\n pageUrl: url,\n siteName: typeof host?.['name'] === 'string' ? host['name'] : null,\n nowMs,\n })\n }\n pageMemo.set(reviewId, { state, atMs: nowMs })\n return state === 'released'\n ? { outcome: 'serve' }\n : { outcome: state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n } catch (error) {\n // Closed: the screen has already found a signal on this page, and a\n // review that could not finish has not cleared it. Not remembered, so\n // the next render reviews it again.\n console.error('[page-review] a flagged page could not be reviewed — holding it', error)\n const reviewId = pageReviewId(request.hostId, request.screenId, [...found.signals, ...listed])\n return { outcome: 'held', reviewId, reference: heldOutboundReference(reviewId) }\n }\n}\n\nexport interface SiteRedirectReviewRequest {\n hostId: string\n /** The rule's document id under `hosts/{hostId}/redirects`. */\n ruleId: string\n /** The path, or the pattern, the rule answers. */\n source: string\n /** Where it sends the visitor, after any `$n` substitution. */\n destination: string\n /** The host and org documents, when the caller already holds them. */\n host?: Record<string, unknown> | null\n org?: Record<string, unknown> | null\n orgId?: string | null\n nowMs?: number\n}\n\n/** The review row's id for a redirect rule and the signals that held. */\nexport function redirectReviewId(\n hostId: string,\n ruleId: string,\n signals: readonly PhishingScreenSignal[],\n): string {\n const key = signals\n .map((signal) => canonicalJson(signal))\n .sort()\n .join('\\n')\n return heldOutboundReviewId(`hosts/${hostId}/redirects/${ruleId}`, outboundContentHash([key]))\n}\n\n/**\n * Screen one redirect rule before it sends a visitor off the site\n * (AGL-3447), and say whether it may fire. See the module header.\n *\n * Every workspace is screened and the tiers decide, as for a page: a\n * destination that wears a brand holds for all of them, a lure path sent\n * off the site only for a workspace in its first fortnight. A held rule is\n * filed once per (rule, signal set) and decided by staff on that row:\n * dismissed, it fires; actioned, it never does with those signals.\n */\nexport async function reviewSiteRedirect(\n request: SiteRedirectReviewRequest,\n): Promise<HostedPageReviewOutcome> {\n const found = screenSiteRedirect({ source: request.source, destination: request.destination })\n // Where it sends visitors, against the reputation list (AGL-3451): a\n // listed destination holds for every workspace, as a listed link does.\n const reputation = await pageLinkReputation(found.linkUrls)\n if (!found.signals.length && !reputation.signals.length) return { outcome: 'serve' }\n\n const nowMs = request.nowMs ?? Date.now()\n try {\n const [host, owner] = await Promise.all([\n request.host !== undefined ? request.host : getHostDocAdmin(request.hostId),\n request.org !== undefined\n ? { orgId: request.orgId ?? null, org: request.org }\n : getOrgForHost(request.hostId),\n ])\n const org = (owner?.org as Record<string, unknown> | null | undefined) ?? null\n const orgId = owner?.orgId ?? null\n const identity = workspaceScreenIdentity({ org, host })\n const verdict = screenSiteRedirect({\n source: request.source,\n destination: request.destination,\n ...identity,\n })\n const screened = [\n ...verdict.signals,\n ...reputation.signals.filter(\n (signal) => !('host' in signal) || !isOwnHost(signal.host, identity.ownDomains),\n ),\n ]\n const ageDays = orgAgeDays((org as { createdAt?: unknown } | null)?.createdAt, nowMs)\n const signals = signalsThatHold(screened, { ageDays })\n if (!signals.length) return { outcome: 'serve' }\n\n const reviewId = redirectReviewId(request.hostId, request.ruleId, signals)\n const reference = heldOutboundReference(reviewId)\n const memo = pageMemo.get(reviewId)\n if (memo && nowMs - memo.atMs < PAGE_MEMO_TTL_MS) {\n return memo.state === 'released'\n ? { outcome: 'serve' }\n : { outcome: memo.state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n }\n const source = request.source.slice(0, 200)\n const destination = request.destination.slice(0, 500)\n const label = `the redirect from ${source}`\n const siteName =\n typeof host?.['name'] === 'string' && host['name'] ? `\"${host['name']}\"` : 'a site'\n const [subdomainHost, customDomain] = identity.ownDomains ?? []\n const siteHost = customDomain || subdomainHost\n const state = await fileOutboundHold({\n reviewId,\n heldSend: {\n kind: 'page',\n path: `hosts/${request.hostId}/redirects/${request.ruleId}`,\n hostId: request.hostId,\n orgId,\n contentHash: outboundContentHash([signals]),\n subject: label.slice(0, 300),\n fromName: null,\n signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays,\n },\n headline:\n `Held ${label} of ${siteName}, which sends visitors to ${destination}, from a ` +\n `workspace ${ageDays ?? '?'} day(s) old. The rule does not fire until this is decided.`,\n alertTitle: 'Site redirect held for review — possible phishing',\n alertBody: `The redirect from ${source} was held before it sent anyone off the site.`,\n // Where the owners fix it: the site's Redirects page.\n item: { label, path: `/${request.hostId}/redirects` },\n url: siteHost && source.startsWith('/') ? `https://${siteHost}${source}` : destination,\n reportedHostname: flaggedHostOf(signals),\n })\n pageMemo.set(reviewId, { state, atMs: nowMs })\n return state === 'released'\n ? { outcome: 'serve' }\n : { outcome: state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n } catch (error) {\n // Closed, as a page is: the screen found a signal and nothing cleared it.\n console.error('[page-review] a flagged redirect could not be reviewed — holding it', error)\n const reviewId = redirectReviewId(request.hostId, request.ruleId, [\n ...found.signals,\n ...reputation.signals,\n ])\n return { outcome: 'held', reviewId, reference: heldOutboundReference(reviewId) }\n }\n}\n\nfunction pageReviewRef(hostId: string, screenId: string) {\n return firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection(PAGE_REVIEW_SUBCOLLECTION)\n .doc(screenId)\n}\n\n/**\n * Is this version of the page already what visitors receive? It is when it\n * is the version the page last served clean, or when the page has no record\n * at all and is not new: it went live before the screen existed. A page\n * created in the last {@link NEW_PAGE_DAYS} days with no record has never\n * been live, so holding it takes nothing down.\n */\nconst NEW_PAGE_DAYS = 7\nasync function isAlreadyLive(\n hostId: string,\n screenId: string,\n versionId: string,\n nowMs: number,\n): Promise<boolean> {\n const lastClean = await servedPageVersion(hostId, screenId)\n if (lastClean) return lastClean === versionId\n try {\n const screen = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('screens')\n .doc(screenId)\n .get()\n const created = screen.get('createdAt') as { toMillis?: () => number } | number | undefined\n const createdMs =\n typeof created === 'number' ? created : typeof created?.toMillis === 'function' ? created.toMillis() : null\n // No readable creation time: an old page, so live.\n return createdMs === null || nowMs - createdMs >= NEW_PAGE_DAYS * 24 * 60 * 60 * 1000\n } catch {\n return true\n }\n}\n\n/**\n * A page ALREADY LIVE carries a strong signal: file one urgent abuse row for\n * it (keyed on the page and its signals, so a busy page is one row) and, the\n * first time, tell staff and the owners through the `page-flagged` notice.\n * The page keeps serving; staff lock the site if it is impersonation or the\n * account looks compromised. Once per page and signal set per process.\n */\nasync function flagLivePage(input: {\n hostId: string\n screenId: string\n versionId: string\n orgId: string | null\n host: Record<string, unknown> | null\n signals: readonly PhishingScreenSignal[]\n nowMs: number\n page?: HostedPageContext | null\n identity?: ReturnType<typeof workspaceScreenIdentity>\n}): Promise<void> {\n const key = `${input.hostId}\\n${input.screenId}\\n${input.signals.map((signal) => canonicalJson(signal)).sort().join('\\n')}`\n const at = flaggedMemo.get(key)\n if (at !== undefined && input.nowMs - at < FLAGGED_MEMO_TTL_MS) return\n flaggedMemo.set(key, input.nowMs)\n try {\n const reviewId = pageReviewId(input.hostId, `${input.screenId}/live`, input.signals)\n const reference = heldOutboundReference(reviewId)\n const evidence = describePhishingScreenSignals(input.signals).join(' ')\n const flagged = flaggedHostOf(input.signals)\n const ref = firebaseAdmin.app().firestore().collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const existing = await ref.get()\n const first = !existing.exists\n // Named once, when the row is new (AGL-3374).\n const subject =\n (existing.exists ? storedSubject(existing.get('heldPage')) : null) ??\n (await describeHeldPage({\n hostId: input.hostId,\n screenId: input.screenId,\n versionId: input.versionId,\n host: input.host,\n page: input.page,\n signals: input.signals,\n identity: input.identity ?? workspaceScreenIdentity({ host: input.host }),\n live: true,\n previousVersionId: null,\n }))\n const url = subject.entryUrl ?? subject.url\n const siteName =\n typeof input.host?.['name'] === 'string' && input.host['name']\n ? `\"${input.host['name']}\"`\n : 'a site'\n await ref.set(\n {\n reference,\n category: 'phishing',\n severity: 'urgent',\n source: 'outbound-screen',\n url,\n reportedHostname: flagged,\n hostId: input.hostId,\n orgId: input.orgId,\n details: [\n `LIVE: ${pageHeadline(subject, siteName)} It was flagged by the phishing screen and is still serving.`,\n evidence,\n 'Lock the site if it is impersonation or the account looks compromised; dismiss this if the content is theirs.',\n ].join('\\n'),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n heldPage: { screenId: input.screenId, versionId: input.versionId, url, subject },\n riskNotice: { kind: 'page-flagged' },\n reportCount: FieldValue.increment(1),\n updatedAt: FieldValue.serverTimestamp(),\n ...(first ? { status: 'open', createdAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n if (!first) return\n await notifyRiskEvent({\n kind: 'page-flagged',\n orgId: input.orgId,\n hostId: input.hostId,\n reviewId,\n reference,\n item: { label: heldPageLabel(subject), path: heldPageConsolePath(subject, input.hostId) },\n page: subject,\n staffEvidence: [...heldPageDetails(subject), evidence].join(' '),\n })\n } catch (error) {\n console.error('[page-review] a live page could not be flagged', error)\n }\n}\n\n/**\n * The version this page last served clean, or null. A HINT, not a\n * clearance: the caller composes and screens it again before serving it,\n * because a version document can be edited after it was served.\n */\nexport async function servedPageVersion(hostId: string, screenId: string): Promise<string | null> {\n try {\n const snapshot = await pageReviewRef(hostId, screenId).get()\n const value = snapshot.get('servedVersionId')\n return typeof value === 'string' && value ? value : null\n } catch {\n return null\n }\n}\n\n/**\n * Note that this page served `versionId` clean. One read the first time a\n * process renders a page, a write only when the version moved; nothing\n * after that, however often the page is rendered.\n */\nexport async function recordServedPageVersion(\n hostId: string,\n screenId: string,\n versionId: string,\n): Promise<void> {\n const key = `${hostId}/${screenId}`\n // The foreign hosts this version links to, and the addresses on them, as\n // its review found them (AGL-3451, AGL-3459): noted beside it so the daily\n // link re-check can walk them. An address carries no query string.\n const foreign = reviewedForeignHosts.get(`${key}/${versionId}`)\n const linksKey = foreign ? `${foreign.hosts.join(',')}|${foreign.urls.join(',')}` : ''\n const memoValue = foreign ? `${versionId}|${linksKey}` : versionId\n if (!versionId || servedMemo.get(key) === memoValue) return\n try {\n const ref = pageReviewRef(hostId, screenId)\n const snapshot = await ref.get()\n const versionMoved = snapshot.get('servedVersionId') !== versionId\n const storedHosts = snapshot.get('foreignHosts')\n const storedLinks = snapshot.get('foreignLinks')\n const linksMoved =\n foreign !== undefined &&\n `${Array.isArray(storedHosts) ? storedHosts.join(',') : ''}|${\n Array.isArray(storedLinks) ? storedLinks.join(',') : ''\n }` !== linksKey\n if (versionMoved || linksMoved) {\n await ref.set(\n {\n ...(versionMoved ? { servedVersionId: versionId, servedAtMs: Date.now() } : {}),\n ...(foreign\n ? {\n foreignHosts: foreign.hosts,\n foreignHostCount: foreign.hosts.length,\n foreignLinks: foreign.urls,\n }\n : {}),\n },\n { merge: true },\n )\n }\n servedMemo.set(key, memoValue)\n } catch (error) {\n console.warn('[page-review] the served version could not be noted', error)\n }\n}\n\n/**\n * A domain a workspace attached that wears a brand (AGL-3356, AGL-3362):\n * filed in the abuse queue as an urgent `phishing` row for staff, not\n * refused. Two kinds, one rule:\n *\n * - `site` — a custom domain a site is served on (the attach route);\n * - `sending` — a custom domain a workspace sends email as, when it is\n * added and again when it verifies (`sending-domains.ts`);\n * - `link` — a link a merchant configured that a buyer or a server would\n * follow: a product's paid download or video hotlink, a supplier's order\n * webhook (AGL-3363). The link itself is refused where it is followed; this\n * files it, and tells the site's managers once ({@link notifyLookalikeLink}).\n *\n * Not refused, because the domain is the merchant's own registration and\n * the platform cannot tell a brand's own agency attaching `paypal-promo.com`\n * from a kit — but a brand's lookalike on a site or in a `From:` line is\n * exactly what staff look at first. The site's pages are still screened as\n * they are served, and mail FROM a lookalike domain is held at the send seam\n * for every workspace. The test is the phishing screen's own lookalike rule\n * (`lookalikeBrandForHost`), the one that holds a link in an email. One row\n * per (owner, kind, domain), so a re-attach or a re-verify counts rather than\n * re-alerts.\n *\n * Never throws: the write it rides on has already happened.\n */\nexport async function flagLookalikeDomain(input: {\n kind: 'site' | 'sending' | 'link'\n hostId: string | null\n orgId: string | null\n domain: string\n /** For a `link`: where it was configured, as staff read it. */\n where?: string\n}): Promise<'flagged' | 'clean' | 'failed'> {\n const domain = String(input.domain ?? '').trim().toLowerCase()\n const brand = lookalikeBrandForHost(domain)\n if (!brand) return 'clean'\n const label = phishingScreenBrandLabel(brand.id)\n const sending = input.kind === 'sending'\n const link = input.kind === 'link'\n try {\n const firestore = firebaseAdmin.app().firestore()\n const owner = sending\n ? `orgs/${input.orgId ?? ''}/sendingDomains`\n : link\n ? `hosts/${input.hostId ?? ''}/links`\n : `hosts/${input.hostId ?? ''}/cname`\n const reviewId = heldOutboundReviewId(owner, domain)\n const reference = heldOutboundReference(reviewId)\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n await ref.set(\n {\n reference,\n category: 'phishing',\n severity: 'urgent',\n source: 'outbound-screen',\n url: `https://${domain}/`,\n reportedHostname: domain,\n hostId: input.hostId,\n orgId: input.orgId,\n details: [\n sending\n ? `A workspace added ${domain} as a domain to send email from.`\n : link\n ? `A site configured a link to ${domain} (${input.where ?? 'a commerce link'}).`\n : `A site attached the custom domain ${domain}.`,\n `${domain} wears the ${label} name but is not ${label}'s domain.`,\n sending\n ? 'Email from it is held at the send seam for every workspace. Mark this actioned ' +\n 'and lock the workspace if it is impersonation; dismiss it if the brand is theirs.'\n : link\n ? 'Nobody is sent to it: the link is refused wherever it is followed. Mark this ' +\n 'actioned and lock the site if it is impersonation.'\n : 'Its pages are still screened as they are served. Mark this actioned ' +\n 'and lock the site if it is impersonation; dismiss it if the brand is theirs.',\n ].join('\\n'),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: FieldValue.increment(1),\n updatedAt: FieldValue.serverTimestamp(),\n ...(first ? { status: 'open', createdAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n if (first && link) {\n await notifyLookalikeLink({\n hostId: input.hostId,\n orgId: input.orgId,\n domain,\n label,\n where: input.where ?? 'a commerce link',\n reference,\n reviewId,\n })\n } else if (first) {\n // The owners hear that the domain is waiting on a routine review — not\n // which name it resembles; staff get the brand and a link to the row.\n await notifyRiskEvent({\n kind: 'domain-flagged',\n orgId: input.orgId,\n hostId: input.hostId,\n reviewId,\n reference,\n item: {\n label: sending ? `the sending domain ${domain}` : `the custom domain ${domain}`,\n // Emails → Sending, or the site's Domain settings.\n path: sending ? '/org/emails/sending' : input.hostId ? `/${input.hostId}/admin/domain` : null,\n },\n staffEvidence:\n `${sending ? 'Sending domain' : 'Custom domain'} ${domain} looks like ${label}.`,\n })\n }\n return 'flagged'\n } catch (error) {\n console.error('[page-review] a lookalike domain could not be flagged', error)\n return 'failed'\n }\n}\n\n/**\n * Tell staff and the site's managers and the workspace's owners, once per\n * (site, domain), that a link the merchant configured was refused because it\n * wears another brand's name (AGL-3363), through the risk notice seam\n * (AGL-3368). The merchant reads the catalog's `link-blocked` words — what\n * was refused and how to fix it, never how the check works; staff get the\n * brand and a link to the row. Never throws.\n */\nexport async function notifyLookalikeLink(input: {\n hostId: string | null\n orgId?: string | null\n domain: string\n label: string\n where: string\n reference: string\n reviewId?: string | null\n}): Promise<void> {\n await notifyRiskEvent({\n kind: 'link-blocked',\n orgId: input.orgId ?? null,\n hostId: input.hostId,\n reviewId: input.reviewId ?? null,\n reference: input.reference,\n item: {\n label: `${input.where} pointing to ${input.domain}`,\n path: input.hostId ? `/${input.hostId}/products` : null,\n },\n staffEvidence:\n `A site configured ${input.where} to ${input.domain}, which looks like ${input.label}. ` +\n 'It is refused wherever it is followed.',\n }).catch(() => undefined)\n}\n\n/** A site's custom domain, through {@link flagLookalikeDomain}. */\nexport function flagLookalikeCustomDomain(input: {\n hostId: string\n orgId: string | null\n domain: string\n}): Promise<'flagged' | 'clean' | 'failed'> {\n return flagLookalikeDomain({ kind: 'site', ...input })\n}\n"],"names":["ABUSE_REPORT_COLLECTION","heldPageConsolePath","heldPageDetails","heldPageLabel","heldPageVisitorSentence","screenHostedPage","screenSiteRedirect","lookalikeBrandForHost","phishingScreenBrandLabel","describePhishingScreenSignals","isYoungWorkspaceAge","signalsThatHold","foreignLinksForReputation","webRiskSignals","FieldValue","firebaseAdmin","describeHeldPage","notifyRiskEvent","orgAgeDays","requestPageSecurityHold","getHostDocAdmin","getOrgForHost","lookupLinkReputation","platformReputationExclusions","canonicalJson","fileOutboundHold","flaggedHostOf","heldOutboundReference","heldOutboundReviewId","outboundContentHash","workspaceScreenIdentity","PAGE_REVIEW_SUBCOLLECTION","pageReviewId","hostId","screenId","signals","key","map","signal","sort","join","PAGE_MEMO_TTL_MS","pageMemo","Map","servedMemo","flaggedMemo","FLAGGED_MEMO_TTL_MS","reviewedForeignHosts","REVIEWED_FOREIGN_HOSTS_MAX","resetHostedPageReviewMemoForTests","clear","pageLinkReputation","linkUrls","foreign","excludeDomains","hosts","length","answer","urls","hits","error","console","warn","isOwnHost","host","ownDomains","some","own","domain","String","trim","toLowerCase","Boolean","endsWith","subjectForRow","reviewId","describe","stored","app","firestore","collection","doc","get","then","row","storedSubject","catch","heldPage","subject","pageHeadline","siteName","reviewHostedPage","request","found","nodes","reputation","size","set","versionId","listed","extraSignals","outcome","nowMs","Date","now","owner","Promise","all","undefined","org","orgId","identity","verdict","screened","filter","ageDays","createdAt","strong","isAlreadyLive","flagLivePage","page","reference","memo","atMs","state","live","previousVersionId","servedPageVersion","url","entryUrl","flagged","label","heldSend","kind","path","contentHash","slice","fromName","heldAtMs","extra","headline","alertTitle","alertBody","charAt","toUpperCase","item","reportedHostname","pageLabel","pageUrl","redirectReviewId","ruleId","reviewSiteRedirect","source","destination","subdomainHost","customDomain","siteHost","startsWith","pageReviewRef","NEW_PAGE_DAYS","lastClean","screen","created","createdMs","toMillis","input","at","existing","evidence","ref","first","exists","category","severity","details","reporterEmail","reporterName","dmca","riskNotice","reportCount","increment","updatedAt","serverTimestamp","status","merge","staffEvidence","snapshot","value","recordServedPageVersion","linksKey","memoValue","versionMoved","storedHosts","storedLinks","linksMoved","Array","isArray","servedVersionId","servedAtMs","foreignHosts","foreignHostCount","foreignLinks","flagLookalikeDomain","brand","id","sending","link","where","notifyLookalikeLink","flagLookalikeCustomDomain"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/hosted-page-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: a published page (AGL-3362).\n *\n * ## Why the check sits where a page is COMPOSED, not where it is published\n *\n * A publish is a browser write. The besigner toolbar, the versions panel,\n * the screen details page, page-from-template, the layout and component\n * besigners and the form besigner each move a pointer (`versionId`, the\n * host's `screens` map, a component's own `nodes`) straight from the\n * client SDK; the scheduled-publish beat moves it from the server; an\n * experiment serves a version no pointer names; and an author may rewrite\n * the LIVE version document itself without moving any pointer at all. A\n * check on any one of those writes is a check the next one walks around.\n *\n * They all meet in one place: `composeScreenNodes` (`@aglyn/tenant-runtime`),\n * which every published render, the password unlock, the collection and\n * product templates and every experiment variant go through, and which\n * returns the page exactly as a visitor will receive it — the screen, its\n * layout chrome, its grafted components and its forms. So the screen is read\n * there, and a held page is one that is never composed for a visitor. For a\n * visitor, \"publish\" is the first render after the pointer moved; this is\n * that moment.\n *\n * ## What a hold does\n *\n * - The version is not served. The last version this page served clean\n * ({@link servedPageVersion}) is composed instead — screened again, since\n * a version document can be edited in place — and if there is none, or it\n * holds too, the page is not found. A first publish that holds therefore\n * serves nothing.\n * - One review row per (page, signal set) lands in the abuse queue at\n * `/admin/abuse-reports`, the same `phishing` row a held email files\n * (`fileOutboundHold`), with `heldSend.kind: 'page'`, the page's URL and\n * the held version. Staff dismiss it to RELEASE (the admin route drops the\n * site's cache and the next render serves it) or mark it actioned to\n * REJECT (it stays unserved; any later version carrying the same signals\n * is refused without a new row).\n * - A hold from a workspace in its first fortnight also asks for an\n * automatic `security` hold on the workspace, the site and the publishing\n * account (`page-security-hold.ts`, AGL-3450), which the console places.\n * - Keyed on the SIGNALS, as the send seam's rows are: a composed page\n * carries per-render values (dates, collection rows) that no content hash\n * would survive, while what makes it phishing does not change between\n * renders.\n *\n * ## Tiers\n *\n * `signalsThatHold`: a lookalike link or embed, an author-defined\n * credential field and a link to a host Google Web Risk lists\n * (`web-risk-link`, AGL-3451) hold for EVERY workspace; a brand's call to\n * action or lure, and a call to action that leaves the site beside a lure,\n * only for a workspace in its first fortnight. The workspace's documents are\n * read only when something was found, so a clean page costs no read for\n * this. The page's foreign hosts — and, in `'url'` mode (AGL-3459), its\n * links' addresses without their query strings — are looked up first,\n * against a cache (`web-risk.ts`), and noted beside the version it serves so\n * the daily re-check can look again: a harvester is often listed only after\n * it is published. A lookup that fails finds nothing, so it never holds a\n * page.\n *\n * Fails CLOSED, unlike the send path: a page the pure screen flagged whose\n * review cannot finish — the workspace read, the row write — is answered\n * `held`, and the composer serves its last clean version or nothing. The\n * answer is not remembered, so the next render reviews it again. Composition\n * itself reads the store, so an outage here is an outage on the render\n * already, and the only cost of not serving a page the screen flagged is a\n * false positive's wait; serving it is the phishing page this exists to\n * stop. A page the screen found nothing on never reaches this.\n *\n * ## Site redirects\n *\n * A redirect rule that sends a path off the site is a page whose one call to\n * action fires on arrival, so the redirects plugin asks\n * {@link reviewSiteRedirect} before it serves one. Same screen\n * (`screenSiteRedirect`), same tiers, same row and the same staff decision:\n * a held rule does not fire, and is filed as a `page` hold on its rule's\n * document. Its destination is looked up against the reputation list too\n * (AGL-3451), so a redirect to a listed site holds for every workspace.\n *=========================================*/\n\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport {\n heldPageConsolePath,\n heldPageDetails,\n heldPageLabel,\n heldPageVisitorSentence,\n type HeldPageSubject,\n} from '@aglyn/shared-util-email/held-page'\nimport { screenHostedPage, screenSiteRedirect } from '@aglyn/shared-util-email/hosted-page-screen'\nimport {\n lookalikeBrandForHost,\n phishingScreenBrandLabel,\n type PhishingScreenSignal,\n describePhishingScreenSignals,\n isYoungWorkspaceAge,\n signalsThatHold,\n} from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport {\n type ForeignReputationLinks,\n foreignLinksForReputation,\n webRiskSignals,\n} from '@aglyn/shared-util-email/link-reputation'\nimport { boundedAwait } from '@aglyn/shared-util-http/bounded-await'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\nimport { describeHeldPage, type HostedPageContext } from './held-page-subject'\nimport { notifyRiskEvent, RISK_NOTICE_RENDER_DEADLINE_MS } from './risk-notice'\nimport { orgAgeDays } from './org-age'\nimport { requestPageSecurityHold } from './page-security-hold'\nimport { getHostDocAdmin, getOrgForHost } from './organizations'\nimport { lookupLinkReputation, platformReputationExclusions } from './web-risk'\nimport {\n canonicalJson,\n fileOutboundHold,\n flaggedHostOf,\n heldOutboundReference,\n heldOutboundReviewId,\n outboundContentHash,\n type HeldOutboundSendState,\n workspaceScreenIdentity,\n} from './outbound-send-review'\n\n/** Where the last version a page served clean is noted: a hint, re-screened before use. */\nexport const PAGE_REVIEW_SUBCOLLECTION = 'pageReviews'\n\nexport interface HostedPageReviewRequest {\n hostId: string\n screenId: string\n versionId: string\n /** The page as composed for a visitor. */\n nodes: unknown\n /** The host and org documents, when the caller already holds them. */\n host?: Record<string, unknown> | null\n org?: Record<string, unknown> | null\n orgId?: string | null\n nowMs?: number\n /**\n * What the composer knows about the page (AGL-3374): the screen document,\n * the template route it is rendering, the variant, and a way to read the\n * layouts and components composed into it. Read only when a row is filed,\n * to name the page — never to decide whether it serves.\n */\n page?: HostedPageContext | null\n /**\n * Signals found about this page elsewhere, judged with the page's own\n * (AGL-3451): the daily link re-check passes a link Web Risk has listed\n * since the page went live, so the hold, its row and the young\n * workspace's security hold all come from this one path.\n */\n extraSignals?: readonly PhishingScreenSignal[]\n}\n\nexport type HostedPageReviewOutcome =\n | { outcome: 'serve' }\n | { outcome: 'held' | 'rejected'; reviewId: string; reference: string }\n\n/** The review row's id for a page and the signals that held. */\nexport function pageReviewId(\n hostId: string,\n screenId: string,\n signals: readonly PhishingScreenSignal[],\n): string {\n const key = signals\n .map((signal) => canonicalJson(signal))\n .sort()\n .join('\\n')\n return heldOutboundReviewId(`hosts/${hostId}/screens/${screenId}`, outboundContentHash([key]))\n}\n\n/** How long this process trusts a page row's state — the send seam's minute. */\nconst PAGE_MEMO_TTL_MS = 60_000\nconst pageMemo = new Map<string, { state: HeldOutboundSendState; atMs: number }>()\n/** The version each page last served clean, as this process last wrote it. */\nconst servedMemo = new Map<string, string>()\n/** Lookalike hosts this process already flagged for an established page, and when. */\nconst flaggedMemo = new Map<string, number>()\nconst FLAGGED_MEMO_TTL_MS = 60 * 60_000\n/**\n * The foreign hosts each reviewed page version links to (AGL-3451), and the\n * addresses on them worth asking about (AGL-3459), for\n * {@link recordServedPageVersion} to note beside the version it served, which\n * is what the daily link re-check walks. Bounded: a process that reviews more\n * pages than this starts again.\n */\nconst reviewedForeignHosts = new Map<string, ForeignReputationLinks>()\nconst REVIEWED_FOREIGN_HOSTS_MAX = 10_000\n\n/** Test seam: forget what this process learned. */\nexport function resetHostedPageReviewMemoForTests(): void {\n pageMemo.clear()\n servedMemo.clear()\n flaggedMemo.clear()\n reviewedForeignHosts.clear()\n}\n\n/**\n * Every foreign host the page links to, the addresses on them worth asking\n * about, and the ones Google Web Risk lists (AGL-3451; addresses only in\n * `'url'` mode, AGL-3459). Foreign before the workspace is known: not the\n * platform's own, not a brand's own domain, not a common social link. The\n * addresses carry no query string or fragment. Never throws; a lookup that\n * failed lists nothing.\n */\nasync function pageLinkReputation(\n linkUrls: readonly string[],\n): Promise<ForeignReputationLinks & { signals: PhishingScreenSignal[] }> {\n // Runs before the review's fail-closed `try`, so it must not throw: an\n // answer it cannot give is no listing, never a reason to hold.\n try {\n const foreign = foreignLinksForReputation(linkUrls, {\n excludeDomains: platformReputationExclusions(),\n })\n if (!foreign.hosts.length) return { ...foreign, signals: [] }\n const answer = await lookupLinkReputation(foreign.hosts, { urls: foreign.urls })\n return { ...foreign, signals: webRiskSignals(answer.hits) }\n } catch (error) {\n console.warn('[page-review] link reputation could not be read — no listing assumed', error)\n return { hosts: [], urls: [], signals: [] }\n }\n}\n\n/** Is this host one of the workspace's own? */\nfunction isOwnHost(host: string, ownDomains: readonly (string | null | undefined)[] | undefined): boolean {\n return (ownDomains ?? []).some((own) => {\n const domain = String(own ?? '').trim().toLowerCase()\n return Boolean(domain) && (host === domain || host.endsWith(`.${domain}`))\n })\n}\n\n/**\n * The subject a row already stored, or a fresh description (AGL-3374). A row\n * that names its page is not described again, so the reads the description\n * may make are paid once per row, not once per render.\n */\nasync function subjectForRow(\n reviewId: string,\n describe: () => Promise<HeldPageSubject>,\n): Promise<HeldPageSubject> {\n const stored = await firebaseAdmin\n .app()\n .firestore()\n .collection(ABUSE_REPORT_COLLECTION)\n .doc(reviewId)\n .get()\n .then((row) => storedSubject(row.get('heldPage')))\n .catch(() => null)\n return stored ?? describe()\n}\n\n/** A row's stored subject, when it has one. */\nfunction storedSubject(heldPage: unknown): HeldPageSubject | null {\n const subject = (heldPage as { subject?: HeldPageSubject } | null | undefined)?.subject\n return subject && typeof subject === 'object' && subject.screenId ? subject : null\n}\n\n/** The row's first line, naming the page and where it was seen. */\nfunction pageHeadline(subject: HeldPageSubject, siteName: string): string {\n return [`${heldPageLabel(subject)} of ${siteName}.`, ...heldPageDetails(subject)].join(' ')\n}\n\n/**\n * Screen one page as a visitor would receive it, and say whether it may be\n * served. See the module header.\n */\nexport async function reviewHostedPage(\n request: HostedPageReviewRequest,\n): Promise<HostedPageReviewOutcome> {\n const found = screenHostedPage({ nodes: request.nodes })\n // Every foreign host it points at, against the reputation list\n // (AGL-3451): a harvester known to be bad anywhere holds however plain the\n // page around it is. Strong, so it holds at any workspace age.\n const reputation = await pageLinkReputation(found.linkUrls)\n if (reviewedForeignHosts.size >= REVIEWED_FOREIGN_HOSTS_MAX) reviewedForeignHosts.clear()\n reviewedForeignHosts.set(`${request.hostId}/${request.screenId}/${request.versionId}`, {\n hosts: reputation.hosts,\n urls: reputation.urls,\n })\n const listed = [...reputation.signals, ...(request.extraSignals ?? [])]\n if (!found.signals.length && !listed.length) return { outcome: 'serve' }\n\n const nowMs = request.nowMs ?? Date.now()\n try {\n const [host, owner] = await Promise.all([\n request.host !== undefined ? request.host : getHostDocAdmin(request.hostId),\n request.org !== undefined\n ? { orgId: request.orgId ?? null, org: request.org }\n : getOrgForHost(request.hostId),\n ])\n const org = (owner?.org as Record<string, unknown> | null | undefined) ?? null\n const orgId = owner?.orgId ?? null\n // Again, now knowing whose page it is: a brand in the workspace's own\n // name or domain is its own, and a link to its own site is not away.\n const identity = workspaceScreenIdentity({ org, host })\n const verdict = screenHostedPage({ nodes: request.nodes, ...identity })\n // A listing of the workspace's own host is not a link away from it.\n const screened = [\n ...verdict.signals,\n ...listed.filter(\n (signal) => !('host' in signal) || !isOwnHost(signal.host, identity.ownDomains),\n ),\n ]\n const ageDays = orgAgeDays((org as { createdAt?: unknown } | null)?.createdAt, nowMs)\n // Every workspace is screened, however old: an established account can\n // be compromised, and a compromised account publishing a lure is the\n // same harm as a new one. What differs is what a hold may take DOWN.\n // Holding content that is already live serves its last clean version or\n // nothing, and on a real business's home page that is an outage the\n // screen caused (aglyn.com, 2026-09-28, a page that had served clean\n // until a rule changed). So for an established workspace:\n // - NEW content (a new version of a live page, or a page that has\n // never been live) holds, exactly as for a young workspace; the live\n // page keeps serving its last clean version, or the new page waits;\n // - content ALREADY LIVE keeps serving, and is flagged to staff as\n // urgent, who lock the site if it is impersonation.\n if (!isYoungWorkspaceAge(ageDays)) {\n const strong = signalsThatHold(screened, { ageDays })\n if (!strong.length) return { outcome: 'serve' }\n if (await isAlreadyLive(request.hostId, request.screenId, request.versionId, nowMs)) {\n await flagLivePage({\n hostId: request.hostId,\n screenId: request.screenId,\n versionId: request.versionId,\n orgId,\n host,\n signals: strong,\n nowMs,\n page: request.page ?? null,\n identity,\n })\n return { outcome: 'serve' }\n }\n }\n const signals = signalsThatHold(screened, { ageDays })\n if (!signals.length) return { outcome: 'serve' }\n\n const reviewId = pageReviewId(request.hostId, request.screenId, signals)\n const reference = heldOutboundReference(reviewId)\n const memo = pageMemo.get(reviewId)\n if (memo && nowMs - memo.atMs < PAGE_MEMO_TTL_MS) {\n return memo.state === 'released'\n ? { outcome: 'serve' }\n : { outcome: memo.state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n }\n // The page by its real name and route (AGL-3374): a template, a list,\n // a variant, or a page whose flagged content lives in its layout.\n const subject = await subjectForRow(reviewId, async () =>\n describeHeldPage({\n hostId: request.hostId,\n screenId: request.screenId,\n versionId: request.versionId,\n host,\n page: request.page,\n signals,\n identity,\n live: false,\n previousVersionId: await servedPageVersion(request.hostId, request.screenId),\n }),\n )\n const url = subject.entryUrl ?? subject.url\n const flagged = flaggedHostOf(signals)\n const siteName =\n typeof host?.['name'] === 'string' && host['name'] ? `\"${host['name']}\"` : 'a site'\n const label = heldPageLabel(subject)\n const state = await fileOutboundHold({\n reviewId,\n heldSend: {\n kind: 'page',\n path: `hosts/${request.hostId}/screens/${request.screenId}`,\n hostId: request.hostId,\n orgId,\n contentHash: outboundContentHash([signals]),\n subject: label.slice(0, 300),\n fromName: null,\n signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays,\n },\n extra: {\n heldPage: { screenId: request.screenId, versionId: request.versionId, url, subject },\n },\n headline:\n `Held ${pageHeadline(subject, siteName)} Version ${request.versionId}, from a ` +\n `workspace ${ageDays ?? '?'} day(s) old. ${heldPageVisitorSentence(subject)}`,\n alertTitle: 'Published page held for review — possible phishing',\n alertBody: `${label.charAt(0).toUpperCase()}${label.slice(1)} was held before it went live.`,\n // The owners' words for it, and where they fix it (AGL-3368, AGL-3374).\n item: { label, path: heldPageConsolePath(subject, request.hostId) },\n page: subject,\n // The page itself is what was reported; the flagged host, when there\n // is one, is named in the details and on `reportedHostname`.\n url,\n reportedHostname: flagged,\n })\n // A hold from a workspace in its first fortnight also holds the\n // workspace, the site and the account that published it (AGL-3450).\n if (state === 'held') {\n await requestPageSecurityHold({\n orgId: orgId ?? '',\n org,\n hostId: request.hostId,\n screenId: request.screenId,\n versionId: request.versionId,\n reviewId,\n reference,\n signals,\n ageDays,\n pageLabel: label,\n pageUrl: url,\n siteName: typeof host?.['name'] === 'string' ? host['name'] : null,\n nowMs,\n })\n }\n pageMemo.set(reviewId, { state, atMs: nowMs })\n return state === 'released'\n ? { outcome: 'serve' }\n : { outcome: state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n } catch (error) {\n // Closed: the screen has already found a signal on this page, and a\n // review that could not finish has not cleared it. Not remembered, so\n // the next render reviews it again.\n console.error('[page-review] a flagged page could not be reviewed — holding it', error)\n const reviewId = pageReviewId(request.hostId, request.screenId, [...found.signals, ...listed])\n return { outcome: 'held', reviewId, reference: heldOutboundReference(reviewId) }\n }\n}\n\nexport interface SiteRedirectReviewRequest {\n hostId: string\n /** The rule's document id under `hosts/{hostId}/redirects`. */\n ruleId: string\n /** The path, or the pattern, the rule answers. */\n source: string\n /** Where it sends the visitor, after any `$n` substitution. */\n destination: string\n /** The host and org documents, when the caller already holds them. */\n host?: Record<string, unknown> | null\n org?: Record<string, unknown> | null\n orgId?: string | null\n nowMs?: number\n}\n\n/** The review row's id for a redirect rule and the signals that held. */\nexport function redirectReviewId(\n hostId: string,\n ruleId: string,\n signals: readonly PhishingScreenSignal[],\n): string {\n const key = signals\n .map((signal) => canonicalJson(signal))\n .sort()\n .join('\\n')\n return heldOutboundReviewId(`hosts/${hostId}/redirects/${ruleId}`, outboundContentHash([key]))\n}\n\n/**\n * Screen one redirect rule before it sends a visitor off the site\n * (AGL-3447), and say whether it may fire. See the module header.\n *\n * Every workspace is screened and the tiers decide, as for a page: a\n * destination that wears a brand holds for all of them, a lure path sent\n * off the site only for a workspace in its first fortnight. A held rule is\n * filed once per (rule, signal set) and decided by staff on that row:\n * dismissed, it fires; actioned, it never does with those signals.\n */\nexport async function reviewSiteRedirect(\n request: SiteRedirectReviewRequest,\n): Promise<HostedPageReviewOutcome> {\n const found = screenSiteRedirect({ source: request.source, destination: request.destination })\n // Where it sends visitors, against the reputation list (AGL-3451): a\n // listed destination holds for every workspace, as a listed link does.\n const reputation = await pageLinkReputation(found.linkUrls)\n if (!found.signals.length && !reputation.signals.length) return { outcome: 'serve' }\n\n const nowMs = request.nowMs ?? Date.now()\n try {\n const [host, owner] = await Promise.all([\n request.host !== undefined ? request.host : getHostDocAdmin(request.hostId),\n request.org !== undefined\n ? { orgId: request.orgId ?? null, org: request.org }\n : getOrgForHost(request.hostId),\n ])\n const org = (owner?.org as Record<string, unknown> | null | undefined) ?? null\n const orgId = owner?.orgId ?? null\n const identity = workspaceScreenIdentity({ org, host })\n const verdict = screenSiteRedirect({\n source: request.source,\n destination: request.destination,\n ...identity,\n })\n const screened = [\n ...verdict.signals,\n ...reputation.signals.filter(\n (signal) => !('host' in signal) || !isOwnHost(signal.host, identity.ownDomains),\n ),\n ]\n const ageDays = orgAgeDays((org as { createdAt?: unknown } | null)?.createdAt, nowMs)\n const signals = signalsThatHold(screened, { ageDays })\n if (!signals.length) return { outcome: 'serve' }\n\n const reviewId = redirectReviewId(request.hostId, request.ruleId, signals)\n const reference = heldOutboundReference(reviewId)\n const memo = pageMemo.get(reviewId)\n if (memo && nowMs - memo.atMs < PAGE_MEMO_TTL_MS) {\n return memo.state === 'released'\n ? { outcome: 'serve' }\n : { outcome: memo.state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n }\n const source = request.source.slice(0, 200)\n const destination = request.destination.slice(0, 500)\n const label = `the redirect from ${source}`\n const siteName =\n typeof host?.['name'] === 'string' && host['name'] ? `\"${host['name']}\"` : 'a site'\n const [subdomainHost, customDomain] = identity.ownDomains ?? []\n const siteHost = customDomain || subdomainHost\n const state = await fileOutboundHold({\n reviewId,\n heldSend: {\n kind: 'page',\n path: `hosts/${request.hostId}/redirects/${request.ruleId}`,\n hostId: request.hostId,\n orgId,\n contentHash: outboundContentHash([signals]),\n subject: label.slice(0, 300),\n fromName: null,\n signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays,\n },\n headline:\n `Held ${label} of ${siteName}, which sends visitors to ${destination}, from a ` +\n `workspace ${ageDays ?? '?'} day(s) old. The rule does not fire until this is decided.`,\n alertTitle: 'Site redirect held for review — possible phishing',\n alertBody: `The redirect from ${source} was held before it sent anyone off the site.`,\n // Where the owners fix it: the site's Redirects page.\n item: { label, path: `/${request.hostId}/redirects` },\n url: siteHost && source.startsWith('/') ? `https://${siteHost}${source}` : destination,\n reportedHostname: flaggedHostOf(signals),\n })\n pageMemo.set(reviewId, { state, atMs: nowMs })\n return state === 'released'\n ? { outcome: 'serve' }\n : { outcome: state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n } catch (error) {\n // Closed, as a page is: the screen found a signal and nothing cleared it.\n console.error('[page-review] a flagged redirect could not be reviewed — holding it', error)\n const reviewId = redirectReviewId(request.hostId, request.ruleId, [\n ...found.signals,\n ...reputation.signals,\n ])\n return { outcome: 'held', reviewId, reference: heldOutboundReference(reviewId) }\n }\n}\n\nfunction pageReviewRef(hostId: string, screenId: string) {\n return firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection(PAGE_REVIEW_SUBCOLLECTION)\n .doc(screenId)\n}\n\n/**\n * Is this version of the page already what visitors receive? It is when it\n * is the version the page last served clean, or when the page has no record\n * at all and is not new: it went live before the screen existed. A page\n * created in the last {@link NEW_PAGE_DAYS} days with no record has never\n * been live, so holding it takes nothing down.\n */\nconst NEW_PAGE_DAYS = 7\nasync function isAlreadyLive(\n hostId: string,\n screenId: string,\n versionId: string,\n nowMs: number,\n): Promise<boolean> {\n const lastClean = await servedPageVersion(hostId, screenId)\n if (lastClean) return lastClean === versionId\n try {\n const screen = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('screens')\n .doc(screenId)\n .get()\n const created = screen.get('createdAt') as { toMillis?: () => number } | number | undefined\n const createdMs =\n typeof created === 'number' ? created : typeof created?.toMillis === 'function' ? created.toMillis() : null\n // No readable creation time: an old page, so live.\n return createdMs === null || nowMs - createdMs >= NEW_PAGE_DAYS * 24 * 60 * 60 * 1000\n } catch {\n return true\n }\n}\n\n/**\n * A page ALREADY LIVE carries a strong signal: file one urgent abuse row for\n * it (keyed on the page and its signals, so a busy page is one row) and, the\n * first time, tell staff and the owners through the `page-flagged` notice.\n * The page keeps serving; staff lock the site if it is impersonation or the\n * account looks compromised. Once per page and signal set per process.\n */\nasync function flagLivePage(input: {\n hostId: string\n screenId: string\n versionId: string\n orgId: string | null\n host: Record<string, unknown> | null\n signals: readonly PhishingScreenSignal[]\n nowMs: number\n page?: HostedPageContext | null\n identity?: ReturnType<typeof workspaceScreenIdentity>\n}): Promise<void> {\n const key = `${input.hostId}\\n${input.screenId}\\n${input.signals.map((signal) => canonicalJson(signal)).sort().join('\\n')}`\n const at = flaggedMemo.get(key)\n if (at !== undefined && input.nowMs - at < FLAGGED_MEMO_TTL_MS) return\n flaggedMemo.set(key, input.nowMs)\n try {\n const reviewId = pageReviewId(input.hostId, `${input.screenId}/live`, input.signals)\n const reference = heldOutboundReference(reviewId)\n const evidence = describePhishingScreenSignals(input.signals).join(' ')\n const flagged = flaggedHostOf(input.signals)\n const ref = firebaseAdmin.app().firestore().collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const existing = await ref.get()\n const first = !existing.exists\n // Named once, when the row is new (AGL-3374).\n const subject =\n (existing.exists ? storedSubject(existing.get('heldPage')) : null) ??\n (await describeHeldPage({\n hostId: input.hostId,\n screenId: input.screenId,\n versionId: input.versionId,\n host: input.host,\n page: input.page,\n signals: input.signals,\n identity: input.identity ?? workspaceScreenIdentity({ host: input.host }),\n live: true,\n previousVersionId: null,\n }))\n const url = subject.entryUrl ?? subject.url\n const siteName =\n typeof input.host?.['name'] === 'string' && input.host['name']\n ? `\"${input.host['name']}\"`\n : 'a site'\n await ref.set(\n {\n reference,\n category: 'phishing',\n severity: 'urgent',\n source: 'outbound-screen',\n url,\n reportedHostname: flagged,\n hostId: input.hostId,\n orgId: input.orgId,\n details: [\n `LIVE: ${pageHeadline(subject, siteName)} It was flagged by the phishing screen and is still serving.`,\n evidence,\n 'Lock the site if it is impersonation or the account looks compromised; dismiss this if the content is theirs.',\n ].join('\\n'),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n heldPage: { screenId: input.screenId, versionId: input.versionId, url, subject },\n riskNotice: { kind: 'page-flagged' },\n reportCount: FieldValue.increment(1),\n updatedAt: FieldValue.serverTimestamp(),\n ...(first ? { status: 'open', createdAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n if (!first) return\n // Inside the page's render: bounded, so a mail or webhook outage cannot\n // hold the page (AGL-3565).\n await boundedAwait(\n notifyRiskEvent({\n kind: 'page-flagged',\n orgId: input.orgId,\n hostId: input.hostId,\n reviewId,\n reference,\n item: { label: heldPageLabel(subject), path: heldPageConsolePath(subject, input.hostId) },\n page: subject,\n staffEvidence: [...heldPageDetails(subject), evidence].join(' '),\n }),\n RISK_NOTICE_RENDER_DEADLINE_MS,\n null,\n 'risk-notice.page-flagged',\n )\n } catch (error) {\n console.error('[page-review] a live page could not be flagged', error)\n }\n}\n\n/**\n * The version this page last served clean, or null. A HINT, not a\n * clearance: the caller composes and screens it again before serving it,\n * because a version document can be edited after it was served.\n */\nexport async function servedPageVersion(hostId: string, screenId: string): Promise<string | null> {\n try {\n const snapshot = await pageReviewRef(hostId, screenId).get()\n const value = snapshot.get('servedVersionId')\n return typeof value === 'string' && value ? value : null\n } catch {\n return null\n }\n}\n\n/**\n * Note that this page served `versionId` clean. One read the first time a\n * process renders a page, a write only when the version moved; nothing\n * after that, however often the page is rendered.\n */\nexport async function recordServedPageVersion(\n hostId: string,\n screenId: string,\n versionId: string,\n): Promise<void> {\n const key = `${hostId}/${screenId}`\n // The foreign hosts this version links to, and the addresses on them, as\n // its review found them (AGL-3451, AGL-3459): noted beside it so the daily\n // link re-check can walk them. An address carries no query string.\n const foreign = reviewedForeignHosts.get(`${key}/${versionId}`)\n const linksKey = foreign ? `${foreign.hosts.join(',')}|${foreign.urls.join(',')}` : ''\n const memoValue = foreign ? `${versionId}|${linksKey}` : versionId\n if (!versionId || servedMemo.get(key) === memoValue) return\n try {\n const ref = pageReviewRef(hostId, screenId)\n const snapshot = await ref.get()\n const versionMoved = snapshot.get('servedVersionId') !== versionId\n const storedHosts = snapshot.get('foreignHosts')\n const storedLinks = snapshot.get('foreignLinks')\n const linksMoved =\n foreign !== undefined &&\n `${Array.isArray(storedHosts) ? storedHosts.join(',') : ''}|${\n Array.isArray(storedLinks) ? storedLinks.join(',') : ''\n }` !== linksKey\n if (versionMoved || linksMoved) {\n await ref.set(\n {\n ...(versionMoved ? { servedVersionId: versionId, servedAtMs: Date.now() } : {}),\n ...(foreign\n ? {\n foreignHosts: foreign.hosts,\n foreignHostCount: foreign.hosts.length,\n foreignLinks: foreign.urls,\n }\n : {}),\n },\n { merge: true },\n )\n }\n servedMemo.set(key, memoValue)\n } catch (error) {\n console.warn('[page-review] the served version could not be noted', error)\n }\n}\n\n/**\n * A domain a workspace attached that wears a brand (AGL-3356, AGL-3362):\n * filed in the abuse queue as an urgent `phishing` row for staff, not\n * refused. Two kinds, one rule:\n *\n * - `site` — a custom domain a site is served on (the attach route);\n * - `sending` — a custom domain a workspace sends email as, when it is\n * added and again when it verifies (`sending-domains.ts`);\n * - `link` — a link a merchant configured that a buyer or a server would\n * follow: a product's paid download or video hotlink, a supplier's order\n * webhook (AGL-3363). The link itself is refused where it is followed; this\n * files it, and tells the site's managers once ({@link notifyLookalikeLink}).\n *\n * Not refused, because the domain is the merchant's own registration and\n * the platform cannot tell a brand's own agency attaching `paypal-promo.com`\n * from a kit — but a brand's lookalike on a site or in a `From:` line is\n * exactly what staff look at first. The site's pages are still screened as\n * they are served, and mail FROM a lookalike domain is held at the send seam\n * for every workspace. The test is the phishing screen's own lookalike rule\n * (`lookalikeBrandForHost`), the one that holds a link in an email. One row\n * per (owner, kind, domain), so a re-attach or a re-verify counts rather than\n * re-alerts.\n *\n * Never throws: the write it rides on has already happened.\n */\nexport async function flagLookalikeDomain(input: {\n kind: 'site' | 'sending' | 'link'\n hostId: string | null\n orgId: string | null\n domain: string\n /** For a `link`: where it was configured, as staff read it. */\n where?: string\n}): Promise<'flagged' | 'clean' | 'failed'> {\n const domain = String(input.domain ?? '').trim().toLowerCase()\n const brand = lookalikeBrandForHost(domain)\n if (!brand) return 'clean'\n const label = phishingScreenBrandLabel(brand.id)\n const sending = input.kind === 'sending'\n const link = input.kind === 'link'\n try {\n const firestore = firebaseAdmin.app().firestore()\n const owner = sending\n ? `orgs/${input.orgId ?? ''}/sendingDomains`\n : link\n ? `hosts/${input.hostId ?? ''}/links`\n : `hosts/${input.hostId ?? ''}/cname`\n const reviewId = heldOutboundReviewId(owner, domain)\n const reference = heldOutboundReference(reviewId)\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n await ref.set(\n {\n reference,\n category: 'phishing',\n severity: 'urgent',\n source: 'outbound-screen',\n url: `https://${domain}/`,\n reportedHostname: domain,\n hostId: input.hostId,\n orgId: input.orgId,\n details: [\n sending\n ? `A workspace added ${domain} as a domain to send email from.`\n : link\n ? `A site configured a link to ${domain} (${input.where ?? 'a commerce link'}).`\n : `A site attached the custom domain ${domain}.`,\n `${domain} wears the ${label} name but is not ${label}'s domain.`,\n sending\n ? 'Email from it is held at the send seam for every workspace. Mark this actioned ' +\n 'and lock the workspace if it is impersonation; dismiss it if the brand is theirs.'\n : link\n ? 'Nobody is sent to it: the link is refused wherever it is followed. Mark this ' +\n 'actioned and lock the site if it is impersonation.'\n : 'Its pages are still screened as they are served. Mark this actioned ' +\n 'and lock the site if it is impersonation; dismiss it if the brand is theirs.',\n ].join('\\n'),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: FieldValue.increment(1),\n updatedAt: FieldValue.serverTimestamp(),\n ...(first ? { status: 'open', createdAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n if (first && link) {\n await notifyLookalikeLink({\n hostId: input.hostId,\n orgId: input.orgId,\n domain,\n label,\n where: input.where ?? 'a commerce link',\n reference,\n reviewId,\n })\n } else if (first) {\n // The owners hear that the domain is waiting on a routine review — not\n // which name it resembles; staff get the brand and a link to the row.\n await notifyRiskEvent({\n kind: 'domain-flagged',\n orgId: input.orgId,\n hostId: input.hostId,\n reviewId,\n reference,\n item: {\n label: sending ? `the sending domain ${domain}` : `the custom domain ${domain}`,\n // Emails → Sending, or the site's Domain settings.\n path: sending ? '/org/emails/sending' : input.hostId ? `/${input.hostId}/admin/domain` : null,\n },\n staffEvidence:\n `${sending ? 'Sending domain' : 'Custom domain'} ${domain} looks like ${label}.`,\n })\n }\n return 'flagged'\n } catch (error) {\n console.error('[page-review] a lookalike domain could not be flagged', error)\n return 'failed'\n }\n}\n\n/**\n * Tell staff and the site's managers and the workspace's owners, once per\n * (site, domain), that a link the merchant configured was refused because it\n * wears another brand's name (AGL-3363), through the risk notice seam\n * (AGL-3368). The merchant reads the catalog's `link-blocked` words — what\n * was refused and how to fix it, never how the check works; staff get the\n * brand and a link to the row. Never throws.\n */\nexport async function notifyLookalikeLink(input: {\n hostId: string | null\n orgId?: string | null\n domain: string\n label: string\n where: string\n reference: string\n reviewId?: string | null\n}): Promise<void> {\n await notifyRiskEvent({\n kind: 'link-blocked',\n orgId: input.orgId ?? null,\n hostId: input.hostId,\n reviewId: input.reviewId ?? null,\n reference: input.reference,\n item: {\n label: `${input.where} pointing to ${input.domain}`,\n path: input.hostId ? `/${input.hostId}/products` : null,\n },\n staffEvidence:\n `A site configured ${input.where} to ${input.domain}, which looks like ${input.label}. ` +\n 'It is refused wherever it is followed.',\n }).catch(() => undefined)\n}\n\n/** A site's custom domain, through {@link flagLookalikeDomain}. */\nexport function flagLookalikeCustomDomain(input: {\n hostId: string\n orgId: string | null\n domain: string\n}): Promise<'flagged' | 'clean' | 'failed'> {\n return flagLookalikeDomain({ kind: 'site', ...input })\n}\n"],"names":["ABUSE_REPORT_COLLECTION","heldPageConsolePath","heldPageDetails","heldPageLabel","heldPageVisitorSentence","screenHostedPage","screenSiteRedirect","lookalikeBrandForHost","phishingScreenBrandLabel","describePhishingScreenSignals","isYoungWorkspaceAge","signalsThatHold","foreignLinksForReputation","webRiskSignals","boundedAwait","FieldValue","firebaseAdmin","describeHeldPage","notifyRiskEvent","RISK_NOTICE_RENDER_DEADLINE_MS","orgAgeDays","requestPageSecurityHold","getHostDocAdmin","getOrgForHost","lookupLinkReputation","platformReputationExclusions","canonicalJson","fileOutboundHold","flaggedHostOf","heldOutboundReference","heldOutboundReviewId","outboundContentHash","workspaceScreenIdentity","PAGE_REVIEW_SUBCOLLECTION","pageReviewId","hostId","screenId","signals","key","map","signal","sort","join","PAGE_MEMO_TTL_MS","pageMemo","Map","servedMemo","flaggedMemo","FLAGGED_MEMO_TTL_MS","reviewedForeignHosts","REVIEWED_FOREIGN_HOSTS_MAX","resetHostedPageReviewMemoForTests","clear","pageLinkReputation","linkUrls","foreign","excludeDomains","hosts","length","answer","urls","hits","error","console","warn","isOwnHost","host","ownDomains","some","own","domain","String","trim","toLowerCase","Boolean","endsWith","subjectForRow","reviewId","describe","stored","app","firestore","collection","doc","get","then","row","storedSubject","catch","heldPage","subject","pageHeadline","siteName","reviewHostedPage","request","found","nodes","reputation","size","set","versionId","listed","extraSignals","outcome","nowMs","Date","now","owner","Promise","all","undefined","org","orgId","identity","verdict","screened","filter","ageDays","createdAt","strong","isAlreadyLive","flagLivePage","page","reference","memo","atMs","state","live","previousVersionId","servedPageVersion","url","entryUrl","flagged","label","heldSend","kind","path","contentHash","slice","fromName","heldAtMs","extra","headline","alertTitle","alertBody","charAt","toUpperCase","item","reportedHostname","pageLabel","pageUrl","redirectReviewId","ruleId","reviewSiteRedirect","source","destination","subdomainHost","customDomain","siteHost","startsWith","pageReviewRef","NEW_PAGE_DAYS","lastClean","screen","created","createdMs","toMillis","input","at","existing","evidence","ref","first","exists","category","severity","details","reporterEmail","reporterName","dmca","riskNotice","reportCount","increment","updatedAt","serverTimestamp","status","merge","staffEvidence","snapshot","value","recordServedPageVersion","linksKey","memoValue","versionMoved","storedHosts","storedLinks","linksMoved","Array","isArray","servedVersionId","servedAtMs","foreignHosts","foreignHostCount","foreignLinks","flagLookalikeDomain","brand","id","sending","link","where","notifyLookalikeLink","flagLookalikeCustomDomain"],"mappings":"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"}
|
|
@@ -56,10 +56,11 @@ import { TENANT_APEX } from "@aglyn/aglyn/app-utils/host-naming";
|
|
|
56
56
|
import { linkReputationSignals } from "@aglyn/shared-util-email/link-reputation";
|
|
57
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
|
+
import { boundedAwait } from "@aglyn/shared-util-http/bounded-await";
|
|
59
60
|
import { FieldValue } from "firebase-admin/firestore";
|
|
60
61
|
import { orgAgeDays } from "./org-age.js";
|
|
61
62
|
import firebaseAdmin from "./firebase-admin.js";
|
|
62
|
-
import { notifyRiskEvent } from "./risk-notice.js";
|
|
63
|
+
import { notifyRiskEvent, RISK_NOTICE_RENDER_DEADLINE_MS } from "./risk-notice.js";
|
|
63
64
|
import { installLinkReputationLookup } from "./web-risk.js";
|
|
64
65
|
/** A workspace younger than this many days has the soft rules applied. */ export { OUTBOUND_REVIEW_YOUNG_DAYS };
|
|
65
66
|
/**
|
|
@@ -246,7 +247,9 @@ import { installLinkReputationLookup } from "./web-risk.js";
|
|
|
246
247
|
var _filing_item, _filing_page;
|
|
247
248
|
// The owners learn what was held and how to ask for a review; staff get
|
|
248
249
|
// the alert with the evidence and a link to this row. One seam, once.
|
|
249
|
-
|
|
250
|
+
// A page or redirect review files its hold inside a page render:
|
|
251
|
+
// bounded, so a mail or webhook outage cannot hold the page (AGL-3565).
|
|
252
|
+
await boundedAwait(notifyRiskEvent({
|
|
250
253
|
kind: filing.heldSend.kind === 'page' ? 'page-held' : filing.heldSend.kind === 'listing' ? 'listing-held' : 'email-held',
|
|
251
254
|
orgId: filing.heldSend.orgId,
|
|
252
255
|
hostId: filing.heldSend.hostId,
|
|
@@ -264,7 +267,7 @@ import { installLinkReputationLookup } from "./web-risk.js";
|
|
|
264
267
|
filing.alertBody,
|
|
265
268
|
...describePhishingScreenSignals(filing.heldSend.signals)
|
|
266
269
|
].join(' ')
|
|
267
|
-
});
|
|
270
|
+
}), RISK_NOTICE_RENDER_DEADLINE_MS, null, 'risk-notice.held');
|
|
268
271
|
}
|
|
269
272
|
return state;
|
|
270
273
|
}
|
|
@@ -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 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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"}
|
|
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 { boundedAwait } from '@aglyn/shared-util-http/bounded-await'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { orgAgeDays } from './org-age'\nimport firebaseAdmin from './firebase-admin'\nimport {\n notifyRiskEvent,\n RISK_NOTICE_RENDER_DEADLINE_MS,\n type RiskEventInput,\n type RiskEventItem,\n} 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 // A page or redirect review files its hold inside a page render:\n // bounded, so a mail or webhook outage cannot hold the page (AGL-3565).\n await boundedAwait(\n 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 RISK_NOTICE_RENDER_DEADLINE_MS,\n null,\n 'risk-notice.held',\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","boundedAwait","FieldValue","orgAgeDays","firebaseAdmin","notifyRiskEvent","RISK_NOTICE_RENDER_DEADLINE_MS","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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"}
|