@aglyn/tenant-data-admin 1.0.0-beta.203 → 1.0.0-beta.205
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/capture-email-check.d.ts +109 -0
- package/src/lib/server/capture-email-check.js +362 -0
- package/src/lib/server/capture-email-check.js.map +1 -0
- package/src/lib/server/card-payment-velocity.d.ts +11 -16
- package/src/lib/server/card-payment-velocity.js +24 -28
- package/src/lib/server/card-payment-velocity.js.map +1 -1
- package/src/lib/server/email-deliverability.d.ts +19 -3
- package/src/lib/server/email-deliverability.js +34 -16
- package/src/lib/server/email-deliverability.js.map +1 -1
- package/src/lib/server/held-page-subject.d.ts +93 -0
- package/src/lib/server/held-page-subject.js +206 -0
- package/src/lib/server/held-page-subject.js.map +1 -0
- package/src/lib/server/host-email-tokens.d.ts +42 -1
- package/src/lib/server/host-email-tokens.js +100 -12
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/host-visitor-records.js +21 -5
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +16 -5
- package/src/lib/server/hosted-page-review.js +242 -61
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/list-members.js +16 -0
- package/src/lib/server/list-members.js.map +1 -1
- package/src/lib/server/notifications.d.ts +7 -0
- package/src/lib/server/notifications.js +27 -4
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/outbound-send-review.d.ts +22 -0
- package/src/lib/server/outbound-send-review.js +74 -6
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/payment-fraud-signal.d.ts +20 -12
- package/src/lib/server/payment-fraud-signal.js +41 -11
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/payment-risk-record.d.ts +37 -13
- package/src/lib/server/payment-risk-record.js +23 -8
- package/src/lib/server/payment-risk-record.js.map +1 -1
- package/src/lib/server/render-system-email.d.ts +3 -0
- package/src/lib/server/render-system-email.js +39 -11
- package/src/lib/server/render-system-email.js.map +1 -1
- package/src/lib/server/risk-notice.d.ts +327 -0
- package/src/lib/server/risk-notice.js +1240 -0
- package/src/lib/server/risk-notice.js.map +1 -0
- package/src/lib/server/sender-readiness.d.ts +36 -0
- package/src/lib/server/sender-readiness.js +99 -0
- package/src/lib/server/sender-readiness.js.map +1 -0
- package/src/lib/server/staff-alert-email.d.ts +29 -15
- package/src/lib/server/staff-alert-email.js +131 -28
- package/src/lib/server/staff-alert-email.js.map +1 -1
- package/src/lib/server/test-firestore.js +1 -0
- package/src/lib/server/test-firestore.js.map +1 -1
- package/src/lib/server/upsert-contact.js +19 -3
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +2 -0
- package/src/tenancy.js +3 -0
- package/src/tenancy.js.map +1 -1
|
@@ -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 {\n describePhishingScreenSignals,\n isYoungWorkspaceAge,\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 { notifyStaff } from './notifications'\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\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 await notifyStaff({\n type: 'system.abuseReportUrgent',\n title: filing.alertTitle,\n body: `${filing.alertBody} Reference ${reference}.`,\n link: '/admin/abuse-reports',\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 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 ...workspaceScreenIdentity({ org: request.org, host: request.host }),\n })\n const signals = signalsThatHold(verdict.signals, { 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 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}\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","describePhishingScreenSignals","isYoungWorkspaceAge","OUTBOUND_REVIEW_YOUNG_DAYS","screenOutboundEmail","signalsThatHold","setOutboundScreenGate","FieldValue","orgAgeDays","firebaseAdmin","notifyStaff","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","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","kind","join","reporterEmail","reporterName","dmca","reportCount","increment","heldAtMs","extra","updatedAt","serverTimestamp","status","merge","type","title","alertTitle","body","alertBody","link","flaggedHostOf","flagged","find","signal","screenOutboundSend","request","verdict","subject","fromName","fromAddress","replyTo","preheader","bodies","length","outcome","String","releasedReviewId","error","console","SEAM_MEMO_TTL_MS","seamMemo","Map","resetOutboundSeamMemoForTests","clear","seamReviewId","isReleaseForHost","memoKey","memo","atMs","snapshot","held","released","screenSeamMessage","workspace","context","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 {\n describePhishingScreenSignals,\n isYoungWorkspaceAge,\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'\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 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 ...workspaceScreenIdentity({ org: request.org, host: request.host }),\n })\n const signals = signalsThatHold(verdict.signals, { 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}\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","describePhishingScreenSignals","isYoungWorkspaceAge","OUTBOUND_REVIEW_YOUNG_DAYS","screenOutboundEmail","signalsThatHold","setOutboundScreenGate","FieldValue","orgAgeDays","firebaseAdmin","notifyRiskEvent","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","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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"}
|
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
+
import type { RiskEventInput } from './risk-notice';
|
|
18
|
+
/**
|
|
19
|
+
* The seam every signal here tells people through (AGL-3368): the owners
|
|
20
|
+
* and admins of the workspace, and staff. Passed in, like the Firestore
|
|
21
|
+
* handle, so the webhook's spec substitutes it with the barrel.
|
|
22
|
+
*/
|
|
23
|
+
export type RiskNotifier = (input: RiskEventInput) => Promise<unknown>;
|
|
17
24
|
/** Which Stripe signal this is. */
|
|
18
25
|
export type PaymentFraudSignalKind = 'early-fraud-warning' | 'radar-review' | 'dispute';
|
|
19
26
|
/** The Stripe object type each signal arrives as, for the queue's text. */
|
|
@@ -48,6 +55,17 @@ export declare function paymentFraudSignalReviewId(kind: PaymentFraudSignalKind,
|
|
|
48
55
|
export declare function paymentFraudSignalReference(reviewId: string): string;
|
|
49
56
|
/** Where staff act on the workspace's billing: its Subscription card. */
|
|
50
57
|
export declare function staffSubscriptionCardPath(orgId: string): string;
|
|
58
|
+
/**
|
|
59
|
+
* The Stripe Dashboard page for a signal: the dispute itself, else the
|
|
60
|
+
* payment it is about.
|
|
61
|
+
*/
|
|
62
|
+
export declare function stripeSignalDashboardUrl(signal: {
|
|
63
|
+
kind: PaymentFraudSignalKind;
|
|
64
|
+
stripeObjectId: string;
|
|
65
|
+
chargeId: string | null;
|
|
66
|
+
paymentIntentId: string | null;
|
|
67
|
+
livemode: boolean;
|
|
68
|
+
}): string | null;
|
|
51
69
|
/** `$56.00 USD`, or a sentence saying the amount is not known. */
|
|
52
70
|
export declare function formatSignalAmount(amountCents: number | null, currency: string): string;
|
|
53
71
|
/**
|
|
@@ -86,12 +104,7 @@ export declare function describePaymentFraudSignal(signal: PaymentFraudSignal):
|
|
|
86
104
|
*/
|
|
87
105
|
export declare function recordPaymentFraudSignal(signal: PaymentFraudSignal, deps: {
|
|
88
106
|
firestore: FirebaseFirestore.Firestore;
|
|
89
|
-
|
|
90
|
-
type: 'system.abuseReportUrgent';
|
|
91
|
-
title: string;
|
|
92
|
-
body: string;
|
|
93
|
-
link: string;
|
|
94
|
-
}) => Promise<void>;
|
|
107
|
+
notifyRisk: RiskNotifier;
|
|
95
108
|
}): Promise<{
|
|
96
109
|
reviewId: string;
|
|
97
110
|
reference: string;
|
|
@@ -139,12 +152,7 @@ export declare function recordSellerFraudSignal(input: {
|
|
|
139
152
|
orgIds: string[];
|
|
140
153
|
}, deps: {
|
|
141
154
|
firestore: FirebaseFirestore.Firestore;
|
|
142
|
-
|
|
143
|
-
type: 'system.abuseReportUrgent';
|
|
144
|
-
title: string;
|
|
145
|
-
body: string;
|
|
146
|
-
link: string;
|
|
147
|
-
}) => Promise<void>;
|
|
155
|
+
notifyRisk: RiskNotifier;
|
|
148
156
|
nowMs?: number;
|
|
149
157
|
}): Promise<{
|
|
150
158
|
first: boolean;
|
|
@@ -53,6 +53,17 @@ const KIND_LABEL = {
|
|
|
53
53
|
/** Where staff act on the workspace's billing: its Subscription card. */ export function staffSubscriptionCardPath(orgId) {
|
|
54
54
|
return `/admin/orgs/${encodeURIComponent(orgId)}#subscription`;
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* The Stripe Dashboard page for a signal: the dispute itself, else the
|
|
58
|
+
* payment it is about.
|
|
59
|
+
*/ export function stripeSignalDashboardUrl(signal) {
|
|
60
|
+
const base = `https://dashboard.stripe.com/${signal.livemode ? '' : 'test/'}`;
|
|
61
|
+
if (signal.kind === 'dispute' && signal.stripeObjectId) {
|
|
62
|
+
return `${base}disputes/${encodeURIComponent(signal.stripeObjectId)}`;
|
|
63
|
+
}
|
|
64
|
+
const payment = signal.paymentIntentId || signal.chargeId;
|
|
65
|
+
return payment ? `${base}payments/${encodeURIComponent(payment)}` : null;
|
|
66
|
+
}
|
|
56
67
|
/** `$56.00 USD`, or a sentence saying the amount is not known. */ export function formatSignalAmount(amountCents, currency) {
|
|
57
68
|
if (amountCents === null || !Number.isFinite(amountCents)) {
|
|
58
69
|
return 'amount not recorded';
|
|
@@ -166,11 +177,20 @@ const KIND_LABEL = {
|
|
|
166
177
|
});
|
|
167
178
|
if (first) {
|
|
168
179
|
var _ref, _signal_chargeId;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
180
|
+
// The workspace's owners are told their subscription payment was
|
|
181
|
+
// flagged and how to confirm it; staff get the row with the evidence.
|
|
182
|
+
await deps.notifyRisk({
|
|
183
|
+
kind: 'billing-payment-flagged',
|
|
184
|
+
orgId: signal.orgId,
|
|
185
|
+
reviewId,
|
|
186
|
+
reference,
|
|
187
|
+
item: {
|
|
188
|
+
label: 'your subscription payment',
|
|
189
|
+
path: '/org/billing/invoices'
|
|
190
|
+
},
|
|
191
|
+
amount: formatSignalAmount(signal.amountCents, signal.currency),
|
|
192
|
+
stripeUrl: stripeSignalDashboardUrl(signal),
|
|
193
|
+
staffEvidence: `${KIND_LABEL[signal.kind]} (${signal.stripeObjectId}) on charge ` + `${(_ref = (_signal_chargeId = signal.chargeId) != null ? _signal_chargeId : signal.paymentIntentId) != null ? _ref : 'not named'}` + (signal.detail ? `; Stripe says: ${signal.detail}` : '') + (signal.livemode ? '' : ' — TEST MODE') + '.'
|
|
174
194
|
}).catch(()=>undefined);
|
|
175
195
|
}
|
|
176
196
|
return {
|
|
@@ -240,7 +260,7 @@ const WINDOW_DAYS = SELLER_FRAUD_PATTERN.windowMs / 86400000;
|
|
|
240
260
|
* ledger crosses the threshold. Idempotent on the Stripe object id: a
|
|
241
261
|
* redelivery changes nothing and files nothing.
|
|
242
262
|
*/ export async function recordSellerFraudSignal(input, deps) {
|
|
243
|
-
var _deps_nowMs, _input_orgIds_, _hostIds_;
|
|
263
|
+
var _deps_nowMs, _input_orgIds_, _hostIds_, _hostIds_1;
|
|
244
264
|
const nowMs = (_deps_nowMs = deps.nowMs) != null ? _deps_nowMs : Date.now();
|
|
245
265
|
const { signal, sellerAccountId } = input;
|
|
246
266
|
const ledgerRef = deps.firestore.collection(SELLER_FRAUD_LEDGER_COLLECTION).doc(sellerAccountId);
|
|
@@ -349,11 +369,21 @@ const WINDOW_DAYS = SELLER_FRAUD_PATTERN.windowMs / 86400000;
|
|
|
349
369
|
}, {
|
|
350
370
|
merge: true
|
|
351
371
|
});
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
372
|
+
// Staff get the pattern with its numbers; the owners get a plain "we are
|
|
373
|
+
// reviewing recent payments" with the way to talk to us, never the count
|
|
374
|
+
// or the window that opened the review.
|
|
375
|
+
await deps.notifyRisk({
|
|
376
|
+
kind: 'seller-review',
|
|
377
|
+
orgId,
|
|
378
|
+
hostId: (_hostIds_1 = hostIds[0]) != null ? _hostIds_1 : null,
|
|
379
|
+
reviewId,
|
|
380
|
+
reference,
|
|
381
|
+
item: {
|
|
382
|
+
label: 'recent card payments on your store',
|
|
383
|
+
path: '/org/settings/holds'
|
|
384
|
+
},
|
|
385
|
+
stripeUrl,
|
|
386
|
+
staffEvidence: `Connected account ${sellerAccountId} drew fraud warnings or disputes on ` + `${chargeIds.length} different charges in ${WINDOW_DAYS} days` + (signal.livemode ? '' : ' — TEST MODE') + '.'
|
|
357
387
|
}).catch(()=>undefined);
|
|
358
388
|
return {
|
|
359
389
|
first: true,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/payment-fraud-signal.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 * STRIPE'S FRAUD SIGNALS, INTO THE ABUSE QUEUE (AGL-3356).\n *\n * The 9/26 incident paid for Pro with a card whose CVC check came back\n * unavailable and which never met 3-D Secure. Stripe had three ways to tell\n * us about a card like that — an early fraud warning from the issuer, a\n * Radar review, a dispute — and the platform webhook either did not\n * subscribe to them or filed them where nobody triages abuse.\n *\n * Each signal now files an urgent row in the abuse queue that names the\n * workspace, the charge and the amount, links the staff Subscription card\n * on the org page (AGL-3359, where billing is canceled), and notifies staff\n * once. NOTHING IS DONE TO THE MONEY OR THE SUBSCRIPTION: no refund, no\n * cancel. An early fraud warning is not a chargeback, a review may clear, and\n * a refund issued before staff look can be the wrong half of a dispute. The\n * row is the prompt; staff decide.\n *\n * Dependencies are passed in rather than imported — the Firestore handle the\n * webhook observes its writes through, and its staff notifier — so the\n * webhook's \"did this delivery do anything\" ledger sees the write, and a spec\n * substituting the admin barrel substitutes these too.\n *=========================================*/\n\nimport { createHash } from 'crypto'\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/** Which Stripe signal this is. */\nexport type PaymentFraudSignalKind =\n | 'early-fraud-warning'\n | 'radar-review'\n | 'dispute'\n\nconst KIND_LABEL: Record<PaymentFraudSignalKind, string> = {\n 'early-fraud-warning': 'Early fraud warning',\n 'radar-review': 'Radar review opened',\n dispute: 'Card dispute opened',\n}\n\n/** The Stripe object type each signal arrives as, for the queue's text. */\nexport const PAYMENT_FRAUD_SIGNAL_LABELS = KIND_LABEL\n\n/** What the card's own checks said, when the charge could be read. */\nexport interface PaymentFraudCardChecks {\n cvcCheck: string | null\n addressPostalCodeCheck: string | null\n cardCountry: string | null\n riskLevel: string | null\n threeDSecure: string | null\n}\n\nexport interface PaymentFraudSignal {\n kind: PaymentFraudSignalKind\n /** The EFW, review or dispute id: one row per signal, whatever redelivers. */\n stripeObjectId: string\n chargeId: string | null\n paymentIntentId: string | null\n /** The workspace the charge billed, or null when it resolved to none. */\n orgId: string | null\n /** Smallest currency unit, or null when nothing recorded the amount. */\n amountCents: number | null\n currency: string\n /** `fraud_type`, the review's `reason`, the dispute's `reason`. */\n detail: string\n checks: PaymentFraudCardChecks | null\n livemode: boolean\n}\n\n/** The row id: hex, so the admin route's id pattern addresses it. */\nexport function paymentFraudSignalReviewId(\n kind: PaymentFraudSignalKind,\n stripeObjectId: string,\n): string {\n return createHash('sha256')\n .update(`stripe-fraud:${kind}:${stripeObjectId}`)\n .digest('hex')\n .slice(0, 40)\n}\n\n/** The staff-facing reference, beside the intake's `AR-` and the screen's `HS-`. */\nexport function paymentFraudSignalReference(reviewId: string): string {\n return `PF-${reviewId.slice(0, 10).toUpperCase()}`\n}\n\n/** Where staff act on the workspace's billing: its Subscription card. */\nexport function staffSubscriptionCardPath(orgId: string): string {\n return `/admin/orgs/${encodeURIComponent(orgId)}#subscription`\n}\n\n/** `$56.00 USD`, or a sentence saying the amount is not known. */\nexport function formatSignalAmount(\n amountCents: number | null,\n currency: string,\n): string {\n if (amountCents === null || !Number.isFinite(amountCents)) {\n return 'amount not recorded'\n }\n return `${(amountCents / 100).toFixed(2)} ${String(currency || 'usd').toUpperCase()}`\n}\n\n/**\n * The one-line summary of a charge Stripe gives back, for a signal whose\n * charge is not on a platform revenue row. A READ — the only Stripe call\n * this module makes — and it answers null rather than throwing, because a\n * fraud alert that fails for want of an amount is worse than one that says\n * the amount is unknown.\n */\nexport async function readStripeChargeForSignal(\n chargeId: string,\n options: { secretKey: string | undefined; fetchImpl?: typeof fetch },\n): Promise<{\n amountCents: number | null\n currency: string\n customerId: string | null\n checks: PaymentFraudCardChecks\n /**\n * The connected account the charge paid (AGL-3360): a destination\n * charge's `transfer_data.destination`. Every sale Aglyn takes for a site\n * is a destination charge on the platform account — the facilitator\n * model (AGL-1956) — so this is the seller. Null for a charge that paid\n * only the platform, a workspace's own subscription.\n */\n sellerAccountId: string | null\n} | null> {\n if (!chargeId || !options.secretKey) return null\n try {\n const response = await (options.fetchImpl ?? fetch)(\n `https://api.stripe.com/v1/charges/${encodeURIComponent(chargeId)}`,\n { headers: { Authorization: `Bearer ${options.secretKey}` } },\n )\n if (!response.ok) return null\n const charge = (await response.json()) as Record<string, any>\n const card = charge?.['payment_method_details']?.['card'] ?? {}\n const text = (value: unknown) =>\n typeof value === 'string' && value ? value : null\n return {\n amountCents: Number.isFinite(Number(charge?.['amount']))\n ? Number(charge['amount'])\n : null,\n currency: String(charge?.['currency'] ?? 'usd'),\n customerId: text(charge?.['customer']),\n checks: {\n cvcCheck: text(card?.['checks']?.['cvc_check']),\n addressPostalCodeCheck: text(card?.['checks']?.['address_postal_code_check']),\n cardCountry: text(card?.['country']),\n riskLevel: text(charge?.['outcome']?.['risk_level']),\n threeDSecure: text(card?.['three_d_secure']?.['result']),\n },\n sellerAccountId: accountRef(charge?.['transfer_data']?.['destination']),\n }\n } catch {\n return null\n }\n}\n\n/** A Stripe account reference, expanded or not, as an id or null. */\nfunction accountRef(value: unknown): string | null {\n const id =\n typeof value === 'string'\n ? value\n : String((value as { id?: unknown } | null)?.id ?? '')\n return id.startsWith('acct_') ? id : null\n}\n\n/** The row's prose: what arrived, about which workspace, and what to do. */\nexport function describePaymentFraudSignal(signal: PaymentFraudSignal): string {\n const lines = [\n `${KIND_LABEL[signal.kind]} from Stripe (${signal.stripeObjectId})` +\n (signal.livemode ? '' : ' — TEST MODE') +\n '.',\n `Workspace: ${signal.orgId ?? 'none — the charge did not resolve to a workspace subscription'}.`,\n `Charge: ${signal.chargeId ?? signal.paymentIntentId ?? 'not named'} · ${formatSignalAmount(signal.amountCents, signal.currency)}.`,\n ]\n if (signal.detail) lines.push(`Stripe says: ${signal.detail}.`)\n const checks = signal.checks\n if (checks) {\n lines.push(\n `Card: CVC ${checks.cvcCheck ?? 'unknown'}, postal code ${checks.addressPostalCodeCheck ?? 'unknown'}, ` +\n `issued in ${checks.cardCountry ?? 'unknown'}, 3DS ${checks.threeDSecure ?? 'not used'}, ` +\n `Radar risk ${checks.riskLevel ?? 'unknown'}.`,\n )\n }\n lines.push(\n signal.kind === 'radar-review'\n ? 'The payment waits in Stripe until the review is closed there.'\n : signal.kind === 'dispute'\n ? 'Answer the dispute in Stripe before its evidence deadline.'\n : 'An early fraud warning is not yet a chargeback; refunding now can avoid one.',\n 'Nothing has been refunded or canceled. Decide on the org’s Subscription card, lock the workspace if it is fraud, and close this row with what you did.',\n )\n return lines.join('\\n').slice(0, 5000)\n}\n\n/**\n * File one signal and tell staff, once per signal.\n *\n * Idempotent on the Stripe object id: a redelivery bumps `reportCount` and\n * leaves the row's status — which staff own from the moment they touch it —\n * where it was. Throws only if the row write throws, which is the webhook's\n * to answer; the notification never throws.\n */\nexport async function recordPaymentFraudSignal(\n signal: PaymentFraudSignal,\n deps: {\n firestore: FirebaseFirestore.Firestore\n notify: (payload: {\n type: 'system.abuseReportUrgent'\n title: string\n body: string\n link: string\n }) => Promise<void>\n },\n): Promise<{ reviewId: string; reference: string; first: boolean }> {\n const reviewId = paymentFraudSignalReviewId(signal.kind, signal.stripeObjectId)\n const reference = paymentFraudSignalReference(reviewId)\n const ref = deps.firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n const subscriptionCard = signal.orgId\n ? staffSubscriptionCardPath(signal.orgId)\n : null\n await ref.set(\n {\n reference,\n // \"Phishing or fraud\" — the queue's urgent fraud category.\n category: 'phishing',\n severity: 'urgent',\n source: 'stripe-fraud-signal',\n url: null,\n reportedHostname: null,\n hostId: null,\n orgId: signal.orgId,\n details: describePaymentFraudSignal(signal),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: FieldValue.increment(1),\n paymentSignal: {\n kind: signal.kind,\n stripeObjectId: signal.stripeObjectId,\n chargeId: signal.chargeId,\n paymentIntentId: signal.paymentIntentId,\n amountCents: signal.amountCents,\n currency: signal.currency,\n detail: signal.detail,\n checks: signal.checks,\n livemode: signal.livemode,\n subscriptionCard,\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(first\n ? { status: 'open', createdAt: FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n if (first) {\n await deps\n .notify({\n type: 'system.abuseReportUrgent',\n title: `${KIND_LABEL[signal.kind]} — ${formatSignalAmount(signal.amountCents, signal.currency)}`,\n body:\n `${signal.orgId ? `Workspace ${signal.orgId}` : 'A charge that is not a workspace subscription'}, ` +\n `charge ${signal.chargeId ?? signal.paymentIntentId ?? 'not named'}. ` +\n `Nothing has been refunded or canceled. Reference ${reference}.`,\n link: '/admin/abuse-reports',\n })\n .catch(() => undefined)\n }\n return { reviewId, reference, first }\n}\n\n/*==========================================\n * THE SELLER PATTERN: WHEN THE MERCHANT MAY BE THE FRAUDSTER (AGL-3360).\n *\n * A site's own sales are destination charges on the platform account, so\n * Stripe's fraud signals about a shopper's card arrive here too. One of them\n * is the merchant's business — their customer, their order, their decision\n * (`payment-risk-record.ts` puts it on the record and tells them). Staff are\n * not paged for it.\n *\n * What IS staff's business is the shape a phishing actor leaves when they\n * run stolen cards through a storefront of their own: several different\n * charges on ONE connected account drawing issuer fraud reports or\n * chargebacks in a short time. So every signal on a seller's charge is kept\n * in a small per-account ledger, and one urgent abuse-queue row is filed when\n *\n * ≥ 3 DISTINCT charges carry an early fraud warning or a dispute\n * within 7 days.\n *\n * Why those numbers. An early fraud warning is an issuer's report that the\n * cardholder did not make the payment, and a dispute is a formal\n * chargeback; a legitimate small merchant sees either rarely — card-network\n * dispute monitoring starts near 0.9% of a month's transactions, single\n * digits a MONTH for a shop doing hundreds of sales. One or two in a week is\n * ordinary friendly fraud and must never page staff. Three separate charges\n * in one week is a rate a real small shop does not produce, and is what a\n * card tester or a stolen-card storefront produces within days. Counted by\n * CHARGE, because a warning is routinely followed by a dispute on the same\n * payment, and that is one bad payment, not two. Radar reviews are kept in\n * the ledger for the row's prose but never counted: a review is Stripe's\n * suspicion, not an issuer's report, and a busy shop can collect several\n * that clear.\n *\n * One row per window: once filed, the account is not filed again until 7\n * days have passed, so a burst is one row with everything in it rather than\n * a row per signal. NOTHING IS DONE TO THE ACCOUNT OR THE MONEY: staff\n * decide (lock the workspace, pause the account's payouts in Stripe).\n *=========================================*/\n\nexport const SELLER_FRAUD_PATTERN = {\n /** Distinct charges with an early fraud warning or a dispute. */\n minDistinctCharges: 3,\n /** …within this window. */\n windowMs: 7 * 24 * 60 * 60 * 1000,\n /** How long the ledger keeps an entry at all. */\n retainMs: 30 * 24 * 60 * 60 * 1000,\n} as const\n\nconst WINDOW_DAYS = SELLER_FRAUD_PATTERN.windowMs / 86_400_000\n\n/** Admin-SDK only; no client rule opens it (default deny). */\nexport const SELLER_FRAUD_LEDGER_COLLECTION = 'paymentFraudLedger'\n\nexport interface SellerFraudLedgerEntry {\n kind: PaymentFraudSignalKind\n stripeObjectId: string\n chargeId: string | null\n amountCents: number | null\n currency: string\n detail: string\n /** The sites a plugin recognised the charge as belonging to. */\n hostIds: string[]\n atMs: number\n}\n\n/** Whether a seller's ledger shows the pattern at `nowMs`. Pure. */\nexport function sellerFraudPattern(\n entries: readonly SellerFraudLedgerEntry[],\n nowMs: number,\n): { matched: boolean; chargeIds: string[] } {\n const since = nowMs - SELLER_FRAUD_PATTERN.windowMs\n const chargeIds = [\n ...new Set(\n entries\n .filter(\n (entry) =>\n (entry.kind === 'early-fraud-warning' || entry.kind === 'dispute') &&\n entry.atMs >= since,\n )\n .map((entry) => entry.chargeId || entry.stripeObjectId),\n ),\n ]\n return {\n matched: chargeIds.length >= SELLER_FRAUD_PATTERN.minDistinctCharges,\n chargeIds,\n }\n}\n\n/** The connected account's page in the Stripe Dashboard. */\nexport function stripeConnectedAccountUrl(\n accountId: string,\n livemode: boolean,\n): string {\n return `https://dashboard.stripe.com/${livemode ? '' : 'test/'}connect/accounts/${encodeURIComponent(accountId)}`\n}\n\n/**\n * Keep one signal on a seller's charge, and file the pattern row when the\n * ledger crosses the threshold. Idempotent on the Stripe object id: a\n * redelivery changes nothing and files nothing.\n */\nexport async function recordSellerFraudSignal(\n input: {\n signal: PaymentFraudSignal\n sellerAccountId: string\n /** The sites a plugin recognised the charge as belonging to. */\n hostIds: string[]\n /** Workspaces owning those sites, for the row. */\n orgIds: string[]\n },\n deps: {\n firestore: FirebaseFirestore.Firestore\n notify: (payload: {\n type: 'system.abuseReportUrgent'\n title: string\n body: string\n link: string\n }) => Promise<void>\n nowMs?: number\n },\n): Promise<{\n first: boolean\n matched: boolean\n filed: { reviewId: string; reference: string } | null\n}> {\n const nowMs = deps.nowMs ?? Date.now()\n const { signal, sellerAccountId } = input\n const ledgerRef = deps.firestore\n .collection(SELLER_FRAUD_LEDGER_COLLECTION)\n .doc(sellerAccountId)\n const outcome = await deps.firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(ledgerRef)\n const stored = (\n Array.isArray(fresh.get('entries')) ? fresh.get('entries') : []\n ) as SellerFraudLedgerEntry[]\n if (stored.some((entry) => entry.stripeObjectId === signal.stripeObjectId)) {\n return { first: false, matched: false, fileAtMs: 0, entries: stored }\n }\n const entries: SellerFraudLedgerEntry[] = [\n ...stored.filter(\n (entry) => entry.atMs >= nowMs - SELLER_FRAUD_PATTERN.retainMs,\n ),\n {\n kind: signal.kind,\n stripeObjectId: signal.stripeObjectId,\n chargeId: signal.chargeId,\n amountCents: signal.amountCents,\n currency: signal.currency,\n detail: signal.detail,\n hostIds: input.hostIds,\n atMs: nowMs,\n },\n ]\n const { matched } = sellerFraudPattern(entries, nowMs)\n const lastFiledAtMs = Number(fresh.get('lastFiledAtMs') ?? 0)\n const fileAtMs =\n matched &&\n !(lastFiledAtMs && nowMs - lastFiledAtMs < SELLER_FRAUD_PATTERN.windowMs)\n ? nowMs\n : 0\n transaction.set(\n ledgerRef,\n {\n sellerAccountId,\n entries,\n livemode: signal.livemode,\n updatedAtMs: nowMs,\n ...(fileAtMs ? { lastFiledAtMs: fileAtMs } : {}),\n },\n { merge: true },\n )\n return { first: true, matched, fileAtMs, entries }\n })\n if (!outcome.fileAtMs) {\n return { first: outcome.first, matched: outcome.matched, filed: null }\n }\n\n const { chargeIds } = sellerFraudPattern(outcome.entries, nowMs)\n const inWindow = outcome.entries.filter(\n (entry) => entry.atMs >= nowMs - SELLER_FRAUD_PATTERN.windowMs,\n )\n const reviewId = createHash('sha256')\n .update(`stripe-seller-pattern:${sellerAccountId}:${outcome.fileAtMs}`)\n .digest('hex')\n .slice(0, 40)\n const reference = paymentFraudSignalReference(reviewId)\n const orgId = input.orgIds[0] ?? null\n const hostIds = [...new Set(inWindow.flatMap((entry) => entry.hostIds ?? []))]\n const stripeUrl = stripeConnectedAccountUrl(sellerAccountId, signal.livemode)\n const details = [\n `${chargeIds.length} different charges paid to connected account ${sellerAccountId} drew an issuer fraud warning or a dispute within ${WINDOW_DAYS} days` +\n (signal.livemode ? '' : ' — TEST MODE') +\n '.',\n `Workspace(s): ${input.orgIds.length ? input.orgIds.join(', ') : 'not resolved'}. Site(s): ${hostIds.length ? hostIds.join(', ') : 'not resolved'}.`,\n ...inWindow.map(\n (entry) =>\n `• ${KIND_LABEL[entry.kind]} ${entry.stripeObjectId} on ${entry.chargeId ?? 'an unnamed charge'} · ${formatSignalAmount(entry.amountCents, entry.currency)}` +\n (entry.detail ? ` · ${entry.detail}` : ''),\n ),\n 'A shop taking stolen cards through its own storefront leaves this shape. Each merchant has already been shown the signal on its own order or booking.',\n `Nothing has been refunded, canceled or paused. If this is the seller's fraud, lock the workspace and pause the account's payouts in Stripe (${stripeUrl}), then close this row with what you did.`,\n ]\n .join('\\n')\n .slice(0, 5000)\n await deps.firestore\n .collection(ABUSE_REPORT_COLLECTION)\n .doc(reviewId)\n .set(\n {\n reference,\n // \"Phishing or fraud\" — the queue's urgent fraud category.\n category: 'phishing',\n severity: 'urgent',\n source: 'stripe-seller-fraud-pattern',\n url: null,\n reportedHostname: null,\n hostId: hostIds[0] ?? null,\n orgId,\n details,\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: 1,\n sellerPattern: {\n sellerAccountId,\n stripeAccountUrl: stripeUrl,\n chargeIds,\n orgIds: input.orgIds,\n hostIds,\n threshold: SELLER_FRAUD_PATTERN.minDistinctCharges,\n windowDays: WINDOW_DAYS,\n livemode: signal.livemode,\n },\n status: 'open',\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n await deps\n .notify({\n type: 'system.abuseReportUrgent',\n title: `Seller fraud pattern — ${chargeIds.length} charges on one account`,\n body:\n `Connected account ${sellerAccountId}` +\n (orgId ? ` (workspace ${orgId})` : '') +\n ` drew fraud warnings or disputes on ${chargeIds.length} different charges in ${WINDOW_DAYS} days. ` +\n `Nothing has been refunded, canceled or paused. Reference ${reference}.`,\n link: '/admin/abuse-reports',\n })\n .catch(() => undefined)\n return { first: true, matched: true, filed: { reviewId, reference } }\n}\n"],"names":["createHash","ABUSE_REPORT_COLLECTION","FieldValue","KIND_LABEL","dispute","PAYMENT_FRAUD_SIGNAL_LABELS","paymentFraudSignalReviewId","kind","stripeObjectId","update","digest","slice","paymentFraudSignalReference","reviewId","toUpperCase","staffSubscriptionCardPath","orgId","encodeURIComponent","formatSignalAmount","amountCents","currency","Number","isFinite","toFixed","String","readStripeChargeForSignal","chargeId","options","secretKey","charge","card","response","fetchImpl","fetch","headers","Authorization","ok","json","text","value","customerId","checks","cvcCheck","addressPostalCodeCheck","cardCountry","riskLevel","threeDSecure","sellerAccountId","accountRef","id","startsWith","describePaymentFraudSignal","signal","lines","livemode","paymentIntentId","detail","push","join","recordPaymentFraudSignal","deps","reference","ref","firestore","collection","doc","first","get","exists","subscriptionCard","set","category","severity","source","url","reportedHostname","hostId","details","reporterEmail","reporterName","dmca","reportCount","increment","paymentSignal","updatedAt","serverTimestamp","status","createdAt","merge","notify","type","title","body","link","catch","undefined","SELLER_FRAUD_PATTERN","minDistinctCharges","windowMs","retainMs","WINDOW_DAYS","SELLER_FRAUD_LEDGER_COLLECTION","sellerFraudPattern","entries","nowMs","since","chargeIds","Set","filter","entry","atMs","map","matched","length","stripeConnectedAccountUrl","accountId","recordSellerFraudSignal","input","hostIds","Date","now","ledgerRef","outcome","runTransaction","transaction","fresh","stored","Array","isArray","some","fileAtMs","lastFiledAtMs","updatedAtMs","filed","inWindow","orgIds","flatMap","stripeUrl","sellerPattern","stripeAccountUrl","threshold","windowDays"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/payment-fraud-signal.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 * STRIPE'S FRAUD SIGNALS, INTO THE ABUSE QUEUE (AGL-3356).\n *\n * The 9/26 incident paid for Pro with a card whose CVC check came back\n * unavailable and which never met 3-D Secure. Stripe had three ways to tell\n * us about a card like that — an early fraud warning from the issuer, a\n * Radar review, a dispute — and the platform webhook either did not\n * subscribe to them or filed them where nobody triages abuse.\n *\n * Each signal now files an urgent row in the abuse queue that names the\n * workspace, the charge and the amount, links the staff Subscription card\n * on the org page (AGL-3359, where billing is canceled), and notifies staff\n * once. NOTHING IS DONE TO THE MONEY OR THE SUBSCRIPTION: no refund, no\n * cancel. An early fraud warning is not a chargeback, a review may clear, and\n * a refund issued before staff look can be the wrong half of a dispute. The\n * row is the prompt; staff decide.\n *\n * Dependencies are passed in rather than imported — the Firestore handle the\n * webhook observes its writes through, and its staff notifier — so the\n * webhook's \"did this delivery do anything\" ledger sees the write, and a spec\n * substituting the admin barrel substitutes these too.\n *=========================================*/\n\nimport { createHash } from 'crypto'\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type { RiskEventInput } from './risk-notice'\n\n/**\n * The seam every signal here tells people through (AGL-3368): the owners\n * and admins of the workspace, and staff. Passed in, like the Firestore\n * handle, so the webhook's spec substitutes it with the barrel.\n */\nexport type RiskNotifier = (input: RiskEventInput) => Promise<unknown>\n\n/** Which Stripe signal this is. */\nexport type PaymentFraudSignalKind =\n | 'early-fraud-warning'\n | 'radar-review'\n | 'dispute'\n\nconst KIND_LABEL: Record<PaymentFraudSignalKind, string> = {\n 'early-fraud-warning': 'Early fraud warning',\n 'radar-review': 'Radar review opened',\n dispute: 'Card dispute opened',\n}\n\n/** The Stripe object type each signal arrives as, for the queue's text. */\nexport const PAYMENT_FRAUD_SIGNAL_LABELS = KIND_LABEL\n\n/** What the card's own checks said, when the charge could be read. */\nexport interface PaymentFraudCardChecks {\n cvcCheck: string | null\n addressPostalCodeCheck: string | null\n cardCountry: string | null\n riskLevel: string | null\n threeDSecure: string | null\n}\n\nexport interface PaymentFraudSignal {\n kind: PaymentFraudSignalKind\n /** The EFW, review or dispute id: one row per signal, whatever redelivers. */\n stripeObjectId: string\n chargeId: string | null\n paymentIntentId: string | null\n /** The workspace the charge billed, or null when it resolved to none. */\n orgId: string | null\n /** Smallest currency unit, or null when nothing recorded the amount. */\n amountCents: number | null\n currency: string\n /** `fraud_type`, the review's `reason`, the dispute's `reason`. */\n detail: string\n checks: PaymentFraudCardChecks | null\n livemode: boolean\n}\n\n/** The row id: hex, so the admin route's id pattern addresses it. */\nexport function paymentFraudSignalReviewId(\n kind: PaymentFraudSignalKind,\n stripeObjectId: string,\n): string {\n return createHash('sha256')\n .update(`stripe-fraud:${kind}:${stripeObjectId}`)\n .digest('hex')\n .slice(0, 40)\n}\n\n/** The staff-facing reference, beside the intake's `AR-` and the screen's `HS-`. */\nexport function paymentFraudSignalReference(reviewId: string): string {\n return `PF-${reviewId.slice(0, 10).toUpperCase()}`\n}\n\n/** Where staff act on the workspace's billing: its Subscription card. */\nexport function staffSubscriptionCardPath(orgId: string): string {\n return `/admin/orgs/${encodeURIComponent(orgId)}#subscription`\n}\n\n/**\n * The Stripe Dashboard page for a signal: the dispute itself, else the\n * payment it is about.\n */\nexport function stripeSignalDashboardUrl(signal: {\n kind: PaymentFraudSignalKind\n stripeObjectId: string\n chargeId: string | null\n paymentIntentId: string | null\n livemode: boolean\n}): string | null {\n const base = `https://dashboard.stripe.com/${signal.livemode ? '' : 'test/'}`\n if (signal.kind === 'dispute' && signal.stripeObjectId) {\n return `${base}disputes/${encodeURIComponent(signal.stripeObjectId)}`\n }\n const payment = signal.paymentIntentId || signal.chargeId\n return payment ? `${base}payments/${encodeURIComponent(payment)}` : null\n}\n\n/** `$56.00 USD`, or a sentence saying the amount is not known. */\nexport function formatSignalAmount(\n amountCents: number | null,\n currency: string,\n): string {\n if (amountCents === null || !Number.isFinite(amountCents)) {\n return 'amount not recorded'\n }\n return `${(amountCents / 100).toFixed(2)} ${String(currency || 'usd').toUpperCase()}`\n}\n\n/**\n * The one-line summary of a charge Stripe gives back, for a signal whose\n * charge is not on a platform revenue row. A READ — the only Stripe call\n * this module makes — and it answers null rather than throwing, because a\n * fraud alert that fails for want of an amount is worse than one that says\n * the amount is unknown.\n */\nexport async function readStripeChargeForSignal(\n chargeId: string,\n options: { secretKey: string | undefined; fetchImpl?: typeof fetch },\n): Promise<{\n amountCents: number | null\n currency: string\n customerId: string | null\n checks: PaymentFraudCardChecks\n /**\n * The connected account the charge paid (AGL-3360): a destination\n * charge's `transfer_data.destination`. Every sale Aglyn takes for a site\n * is a destination charge on the platform account — the facilitator\n * model (AGL-1956) — so this is the seller. Null for a charge that paid\n * only the platform, a workspace's own subscription.\n */\n sellerAccountId: string | null\n} | null> {\n if (!chargeId || !options.secretKey) return null\n try {\n const response = await (options.fetchImpl ?? fetch)(\n `https://api.stripe.com/v1/charges/${encodeURIComponent(chargeId)}`,\n { headers: { Authorization: `Bearer ${options.secretKey}` } },\n )\n if (!response.ok) return null\n const charge = (await response.json()) as Record<string, any>\n const card = charge?.['payment_method_details']?.['card'] ?? {}\n const text = (value: unknown) =>\n typeof value === 'string' && value ? value : null\n return {\n amountCents: Number.isFinite(Number(charge?.['amount']))\n ? Number(charge['amount'])\n : null,\n currency: String(charge?.['currency'] ?? 'usd'),\n customerId: text(charge?.['customer']),\n checks: {\n cvcCheck: text(card?.['checks']?.['cvc_check']),\n addressPostalCodeCheck: text(card?.['checks']?.['address_postal_code_check']),\n cardCountry: text(card?.['country']),\n riskLevel: text(charge?.['outcome']?.['risk_level']),\n threeDSecure: text(card?.['three_d_secure']?.['result']),\n },\n sellerAccountId: accountRef(charge?.['transfer_data']?.['destination']),\n }\n } catch {\n return null\n }\n}\n\n/** A Stripe account reference, expanded or not, as an id or null. */\nfunction accountRef(value: unknown): string | null {\n const id =\n typeof value === 'string'\n ? value\n : String((value as { id?: unknown } | null)?.id ?? '')\n return id.startsWith('acct_') ? id : null\n}\n\n/** The row's prose: what arrived, about which workspace, and what to do. */\nexport function describePaymentFraudSignal(signal: PaymentFraudSignal): string {\n const lines = [\n `${KIND_LABEL[signal.kind]} from Stripe (${signal.stripeObjectId})` +\n (signal.livemode ? '' : ' — TEST MODE') +\n '.',\n `Workspace: ${signal.orgId ?? 'none — the charge did not resolve to a workspace subscription'}.`,\n `Charge: ${signal.chargeId ?? signal.paymentIntentId ?? 'not named'} · ${formatSignalAmount(signal.amountCents, signal.currency)}.`,\n ]\n if (signal.detail) lines.push(`Stripe says: ${signal.detail}.`)\n const checks = signal.checks\n if (checks) {\n lines.push(\n `Card: CVC ${checks.cvcCheck ?? 'unknown'}, postal code ${checks.addressPostalCodeCheck ?? 'unknown'}, ` +\n `issued in ${checks.cardCountry ?? 'unknown'}, 3DS ${checks.threeDSecure ?? 'not used'}, ` +\n `Radar risk ${checks.riskLevel ?? 'unknown'}.`,\n )\n }\n lines.push(\n signal.kind === 'radar-review'\n ? 'The payment waits in Stripe until the review is closed there.'\n : signal.kind === 'dispute'\n ? 'Answer the dispute in Stripe before its evidence deadline.'\n : 'An early fraud warning is not yet a chargeback; refunding now can avoid one.',\n 'Nothing has been refunded or canceled. Decide on the org’s Subscription card, lock the workspace if it is fraud, and close this row with what you did.',\n )\n return lines.join('\\n').slice(0, 5000)\n}\n\n/**\n * File one signal and tell staff, once per signal.\n *\n * Idempotent on the Stripe object id: a redelivery bumps `reportCount` and\n * leaves the row's status — which staff own from the moment they touch it —\n * where it was. Throws only if the row write throws, which is the webhook's\n * to answer; the notification never throws.\n */\nexport async function recordPaymentFraudSignal(\n signal: PaymentFraudSignal,\n deps: {\n firestore: FirebaseFirestore.Firestore\n notifyRisk: RiskNotifier\n },\n): Promise<{ reviewId: string; reference: string; first: boolean }> {\n const reviewId = paymentFraudSignalReviewId(signal.kind, signal.stripeObjectId)\n const reference = paymentFraudSignalReference(reviewId)\n const ref = deps.firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n const subscriptionCard = signal.orgId\n ? staffSubscriptionCardPath(signal.orgId)\n : null\n await ref.set(\n {\n reference,\n // \"Phishing or fraud\" — the queue's urgent fraud category.\n category: 'phishing',\n severity: 'urgent',\n source: 'stripe-fraud-signal',\n url: null,\n reportedHostname: null,\n hostId: null,\n orgId: signal.orgId,\n details: describePaymentFraudSignal(signal),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: FieldValue.increment(1),\n paymentSignal: {\n kind: signal.kind,\n stripeObjectId: signal.stripeObjectId,\n chargeId: signal.chargeId,\n paymentIntentId: signal.paymentIntentId,\n amountCents: signal.amountCents,\n currency: signal.currency,\n detail: signal.detail,\n checks: signal.checks,\n livemode: signal.livemode,\n subscriptionCard,\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(first\n ? { status: 'open', createdAt: FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n if (first) {\n // The workspace's owners are told their subscription payment was\n // flagged and how to confirm it; staff get the row with the evidence.\n await deps\n .notifyRisk({\n kind: 'billing-payment-flagged',\n orgId: signal.orgId,\n reviewId,\n reference,\n item: { label: 'your subscription payment', path: '/org/billing/invoices' },\n amount: formatSignalAmount(signal.amountCents, signal.currency),\n stripeUrl: stripeSignalDashboardUrl(signal),\n staffEvidence:\n `${KIND_LABEL[signal.kind]} (${signal.stripeObjectId}) on charge ` +\n `${signal.chargeId ?? signal.paymentIntentId ?? 'not named'}` +\n (signal.detail ? `; Stripe says: ${signal.detail}` : '') +\n (signal.livemode ? '' : ' — TEST MODE') +\n '.',\n })\n .catch(() => undefined)\n }\n return { reviewId, reference, first }\n}\n\n/*==========================================\n * THE SELLER PATTERN: WHEN THE MERCHANT MAY BE THE FRAUDSTER (AGL-3360).\n *\n * A site's own sales are destination charges on the platform account, so\n * Stripe's fraud signals about a shopper's card arrive here too. One of them\n * is the merchant's business — their customer, their order, their decision\n * (`payment-risk-record.ts` puts it on the record and tells them). Staff are\n * not paged for it.\n *\n * What IS staff's business is the shape a phishing actor leaves when they\n * run stolen cards through a storefront of their own: several different\n * charges on ONE connected account drawing issuer fraud reports or\n * chargebacks in a short time. So every signal on a seller's charge is kept\n * in a small per-account ledger, and one urgent abuse-queue row is filed when\n *\n * ≥ 3 DISTINCT charges carry an early fraud warning or a dispute\n * within 7 days.\n *\n * Why those numbers. An early fraud warning is an issuer's report that the\n * cardholder did not make the payment, and a dispute is a formal\n * chargeback; a legitimate small merchant sees either rarely — card-network\n * dispute monitoring starts near 0.9% of a month's transactions, single\n * digits a MONTH for a shop doing hundreds of sales. One or two in a week is\n * ordinary friendly fraud and must never page staff. Three separate charges\n * in one week is a rate a real small shop does not produce, and is what a\n * card tester or a stolen-card storefront produces within days. Counted by\n * CHARGE, because a warning is routinely followed by a dispute on the same\n * payment, and that is one bad payment, not two. Radar reviews are kept in\n * the ledger for the row's prose but never counted: a review is Stripe's\n * suspicion, not an issuer's report, and a busy shop can collect several\n * that clear.\n *\n * One row per window: once filed, the account is not filed again until 7\n * days have passed, so a burst is one row with everything in it rather than\n * a row per signal. NOTHING IS DONE TO THE ACCOUNT OR THE MONEY: staff\n * decide (lock the workspace, pause the account's payouts in Stripe).\n *=========================================*/\n\nexport const SELLER_FRAUD_PATTERN = {\n /** Distinct charges with an early fraud warning or a dispute. */\n minDistinctCharges: 3,\n /** …within this window. */\n windowMs: 7 * 24 * 60 * 60 * 1000,\n /** How long the ledger keeps an entry at all. */\n retainMs: 30 * 24 * 60 * 60 * 1000,\n} as const\n\nconst WINDOW_DAYS = SELLER_FRAUD_PATTERN.windowMs / 86_400_000\n\n/** Admin-SDK only; no client rule opens it (default deny). */\nexport const SELLER_FRAUD_LEDGER_COLLECTION = 'paymentFraudLedger'\n\nexport interface SellerFraudLedgerEntry {\n kind: PaymentFraudSignalKind\n stripeObjectId: string\n chargeId: string | null\n amountCents: number | null\n currency: string\n detail: string\n /** The sites a plugin recognised the charge as belonging to. */\n hostIds: string[]\n atMs: number\n}\n\n/** Whether a seller's ledger shows the pattern at `nowMs`. Pure. */\nexport function sellerFraudPattern(\n entries: readonly SellerFraudLedgerEntry[],\n nowMs: number,\n): { matched: boolean; chargeIds: string[] } {\n const since = nowMs - SELLER_FRAUD_PATTERN.windowMs\n const chargeIds = [\n ...new Set(\n entries\n .filter(\n (entry) =>\n (entry.kind === 'early-fraud-warning' || entry.kind === 'dispute') &&\n entry.atMs >= since,\n )\n .map((entry) => entry.chargeId || entry.stripeObjectId),\n ),\n ]\n return {\n matched: chargeIds.length >= SELLER_FRAUD_PATTERN.minDistinctCharges,\n chargeIds,\n }\n}\n\n/** The connected account's page in the Stripe Dashboard. */\nexport function stripeConnectedAccountUrl(\n accountId: string,\n livemode: boolean,\n): string {\n return `https://dashboard.stripe.com/${livemode ? '' : 'test/'}connect/accounts/${encodeURIComponent(accountId)}`\n}\n\n/**\n * Keep one signal on a seller's charge, and file the pattern row when the\n * ledger crosses the threshold. Idempotent on the Stripe object id: a\n * redelivery changes nothing and files nothing.\n */\nexport async function recordSellerFraudSignal(\n input: {\n signal: PaymentFraudSignal\n sellerAccountId: string\n /** The sites a plugin recognised the charge as belonging to. */\n hostIds: string[]\n /** Workspaces owning those sites, for the row. */\n orgIds: string[]\n },\n deps: {\n firestore: FirebaseFirestore.Firestore\n notifyRisk: RiskNotifier\n nowMs?: number\n },\n): Promise<{\n first: boolean\n matched: boolean\n filed: { reviewId: string; reference: string } | null\n}> {\n const nowMs = deps.nowMs ?? Date.now()\n const { signal, sellerAccountId } = input\n const ledgerRef = deps.firestore\n .collection(SELLER_FRAUD_LEDGER_COLLECTION)\n .doc(sellerAccountId)\n const outcome = await deps.firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(ledgerRef)\n const stored = (\n Array.isArray(fresh.get('entries')) ? fresh.get('entries') : []\n ) as SellerFraudLedgerEntry[]\n if (stored.some((entry) => entry.stripeObjectId === signal.stripeObjectId)) {\n return { first: false, matched: false, fileAtMs: 0, entries: stored }\n }\n const entries: SellerFraudLedgerEntry[] = [\n ...stored.filter(\n (entry) => entry.atMs >= nowMs - SELLER_FRAUD_PATTERN.retainMs,\n ),\n {\n kind: signal.kind,\n stripeObjectId: signal.stripeObjectId,\n chargeId: signal.chargeId,\n amountCents: signal.amountCents,\n currency: signal.currency,\n detail: signal.detail,\n hostIds: input.hostIds,\n atMs: nowMs,\n },\n ]\n const { matched } = sellerFraudPattern(entries, nowMs)\n const lastFiledAtMs = Number(fresh.get('lastFiledAtMs') ?? 0)\n const fileAtMs =\n matched &&\n !(lastFiledAtMs && nowMs - lastFiledAtMs < SELLER_FRAUD_PATTERN.windowMs)\n ? nowMs\n : 0\n transaction.set(\n ledgerRef,\n {\n sellerAccountId,\n entries,\n livemode: signal.livemode,\n updatedAtMs: nowMs,\n ...(fileAtMs ? { lastFiledAtMs: fileAtMs } : {}),\n },\n { merge: true },\n )\n return { first: true, matched, fileAtMs, entries }\n })\n if (!outcome.fileAtMs) {\n return { first: outcome.first, matched: outcome.matched, filed: null }\n }\n\n const { chargeIds } = sellerFraudPattern(outcome.entries, nowMs)\n const inWindow = outcome.entries.filter(\n (entry) => entry.atMs >= nowMs - SELLER_FRAUD_PATTERN.windowMs,\n )\n const reviewId = createHash('sha256')\n .update(`stripe-seller-pattern:${sellerAccountId}:${outcome.fileAtMs}`)\n .digest('hex')\n .slice(0, 40)\n const reference = paymentFraudSignalReference(reviewId)\n const orgId = input.orgIds[0] ?? null\n const hostIds = [...new Set(inWindow.flatMap((entry) => entry.hostIds ?? []))]\n const stripeUrl = stripeConnectedAccountUrl(sellerAccountId, signal.livemode)\n const details = [\n `${chargeIds.length} different charges paid to connected account ${sellerAccountId} drew an issuer fraud warning or a dispute within ${WINDOW_DAYS} days` +\n (signal.livemode ? '' : ' — TEST MODE') +\n '.',\n `Workspace(s): ${input.orgIds.length ? input.orgIds.join(', ') : 'not resolved'}. Site(s): ${hostIds.length ? hostIds.join(', ') : 'not resolved'}.`,\n ...inWindow.map(\n (entry) =>\n `• ${KIND_LABEL[entry.kind]} ${entry.stripeObjectId} on ${entry.chargeId ?? 'an unnamed charge'} · ${formatSignalAmount(entry.amountCents, entry.currency)}` +\n (entry.detail ? ` · ${entry.detail}` : ''),\n ),\n 'A shop taking stolen cards through its own storefront leaves this shape. Each merchant has already been shown the signal on its own order or booking.',\n `Nothing has been refunded, canceled or paused. If this is the seller's fraud, lock the workspace and pause the account's payouts in Stripe (${stripeUrl}), then close this row with what you did.`,\n ]\n .join('\\n')\n .slice(0, 5000)\n await deps.firestore\n .collection(ABUSE_REPORT_COLLECTION)\n .doc(reviewId)\n .set(\n {\n reference,\n // \"Phishing or fraud\" — the queue's urgent fraud category.\n category: 'phishing',\n severity: 'urgent',\n source: 'stripe-seller-fraud-pattern',\n url: null,\n reportedHostname: null,\n hostId: hostIds[0] ?? null,\n orgId,\n details,\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: 1,\n sellerPattern: {\n sellerAccountId,\n stripeAccountUrl: stripeUrl,\n chargeIds,\n orgIds: input.orgIds,\n hostIds,\n threshold: SELLER_FRAUD_PATTERN.minDistinctCharges,\n windowDays: WINDOW_DAYS,\n livemode: signal.livemode,\n },\n status: 'open',\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n // Staff get the pattern with its numbers; the owners get a plain \"we are\n // reviewing recent payments\" with the way to talk to us, never the count\n // or the window that opened the review.\n await deps\n .notifyRisk({\n kind: 'seller-review',\n orgId,\n hostId: hostIds[0] ?? null,\n reviewId,\n reference,\n item: { label: 'recent card payments on your store', path: '/org/settings/holds' },\n stripeUrl,\n staffEvidence:\n `Connected account ${sellerAccountId} drew fraud warnings or disputes on ` +\n `${chargeIds.length} different charges in ${WINDOW_DAYS} days` +\n (signal.livemode ? '' : ' — TEST MODE') +\n '.',\n })\n .catch(() => undefined)\n return { first: true, matched: true, filed: { reviewId, reference } }\n}\n"],"names":["createHash","ABUSE_REPORT_COLLECTION","FieldValue","KIND_LABEL","dispute","PAYMENT_FRAUD_SIGNAL_LABELS","paymentFraudSignalReviewId","kind","stripeObjectId","update","digest","slice","paymentFraudSignalReference","reviewId","toUpperCase","staffSubscriptionCardPath","orgId","encodeURIComponent","stripeSignalDashboardUrl","signal","base","livemode","payment","paymentIntentId","chargeId","formatSignalAmount","amountCents","currency","Number","isFinite","toFixed","String","readStripeChargeForSignal","options","secretKey","charge","card","response","fetchImpl","fetch","headers","Authorization","ok","json","text","value","customerId","checks","cvcCheck","addressPostalCodeCheck","cardCountry","riskLevel","threeDSecure","sellerAccountId","accountRef","id","startsWith","describePaymentFraudSignal","lines","detail","push","join","recordPaymentFraudSignal","deps","reference","ref","firestore","collection","doc","first","get","exists","subscriptionCard","set","category","severity","source","url","reportedHostname","hostId","details","reporterEmail","reporterName","dmca","reportCount","increment","paymentSignal","updatedAt","serverTimestamp","status","createdAt","merge","notifyRisk","item","label","path","amount","stripeUrl","staffEvidence","catch","undefined","SELLER_FRAUD_PATTERN","minDistinctCharges","windowMs","retainMs","WINDOW_DAYS","SELLER_FRAUD_LEDGER_COLLECTION","sellerFraudPattern","entries","nowMs","since","chargeIds","Set","filter","entry","atMs","map","matched","length","stripeConnectedAccountUrl","accountId","recordSellerFraudSignal","input","hostIds","Date","now","ledgerRef","outcome","runTransaction","transaction","fresh","stored","Array","isArray","some","fileAtMs","lastFiledAtMs","updatedAtMs","filed","inWindow","orgIds","flatMap","sellerPattern","stripeAccountUrl","threshold","windowDays"],"mappings":"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"}
|
|
@@ -14,26 +14,50 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
-
import { type PaymentRiskSignal } from '@aglyn/aglyn/app-utils/payment-risk';
|
|
17
|
+
import { type PaymentRiskSignal, type PaymentRiskSignalKind } from '@aglyn/aglyn/app-utils/payment-risk';
|
|
18
18
|
import type { AglynNotificationType } from '@aglyn/aglyn/app-utils/notifications';
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
import type { RiskEventKind } from '@aglyn/shared-util-email/risk-notice-catalog';
|
|
20
|
+
import type { RiskEventInput } from './risk-notice';
|
|
21
|
+
/**
|
|
22
|
+
* The notifier's shape: `notifyRiskEvent` from the admin barrel (AGL-3368),
|
|
23
|
+
* which tells the site's managers and the workspace's owners and admins what
|
|
24
|
+
* arrived, what it means, and how to refund, keep or answer it.
|
|
25
|
+
*/
|
|
26
|
+
export type PaymentRiskNotifier = (input: RiskEventInput) => Promise<unknown>;
|
|
27
|
+
/** The risk notice each Stripe signal on a sale is told as. */
|
|
28
|
+
export declare const PAYMENT_RISK_NOTICE_KINDS: Record<PaymentRiskSignalKind, RiskEventKind>;
|
|
26
29
|
export interface RecordPaymentRiskInput {
|
|
27
30
|
ref: FirebaseFirestore.DocumentReference;
|
|
28
31
|
signal: PaymentRiskSignal;
|
|
29
|
-
/** The site whose managers are told. Empty tells
|
|
32
|
+
/** The site whose managers are told. Empty tells only `orgId`'s owners. */
|
|
30
33
|
hostId: string;
|
|
34
|
+
/**
|
|
35
|
+
* The workspace told when the record belongs to no site — a marketplace
|
|
36
|
+
* sale's publisher. Resolved from the site otherwise.
|
|
37
|
+
*/
|
|
38
|
+
orgId?: string | null;
|
|
39
|
+
/**
|
|
40
|
+
* The notice to send, when the record's owner cannot act the way a shop
|
|
41
|
+
* does on its own sale (a marketplace publisher cannot refund a charge the
|
|
42
|
+
* platform is merchant of record for). Defaults by signal kind.
|
|
43
|
+
*/
|
|
44
|
+
noticeKind?: RiskEventKind;
|
|
31
45
|
/** What the record is, as the merchant calls it: `Order 1042`, `Booking`. */
|
|
32
46
|
subjectLabel: string;
|
|
33
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* The console path the notice's actions open, in the stored-notification
|
|
49
|
+
* shape (`/{hostId}/…`) — the order's own dialog, the Bookings page.
|
|
50
|
+
*/
|
|
34
51
|
link: string;
|
|
35
|
-
/**
|
|
36
|
-
|
|
52
|
+
/**
|
|
53
|
+
* The plugin's own notification type. Kept for the record's callers; the
|
|
54
|
+
* notice itself is always a `system.riskNotice` now.
|
|
55
|
+
*/
|
|
56
|
+
notificationType?: AglynNotificationType;
|
|
57
|
+
/** `$56.00 USD`, when the plugin knows the amount. */
|
|
58
|
+
amount?: string | null;
|
|
59
|
+
/** A dispute's evidence deadline, when the plugin knows it. */
|
|
60
|
+
evidenceDueByMs?: number | null;
|
|
37
61
|
/**
|
|
38
62
|
* More fields to write in the same transaction, from the record as read —
|
|
39
63
|
* a plugin's own activity line (an order's timeline) rides here.
|
|
@@ -48,7 +72,7 @@ export interface RecordPaymentRiskInput {
|
|
|
48
72
|
*/
|
|
49
73
|
export declare function recordPaymentRiskOnRecord(input: RecordPaymentRiskInput, deps: {
|
|
50
74
|
firestore: FirebaseFirestore.Firestore;
|
|
51
|
-
|
|
75
|
+
notifyRisk: PaymentRiskNotifier;
|
|
52
76
|
}): Promise<boolean>;
|
|
53
77
|
/**
|
|
54
78
|
* Record a dispute's outcome on the signal already stamped for it. Silent:
|