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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/package.json +8 -8
  2. package/src/lib/server/api-http.js +5 -1
  3. package/src/lib/server/api-http.js.map +1 -1
  4. package/src/lib/server/api-v1-kit.d.ts +7 -0
  5. package/src/lib/server/api-v1-kit.js.map +1 -1
  6. package/src/lib/server/api-v1-resources.d.ts +1 -2
  7. package/src/lib/server/api-v1-resources.js +0 -1
  8. package/src/lib/server/api-v1-resources.js.map +1 -1
  9. package/src/lib/server/campaign-conversion-attribution.d.ts +1 -1
  10. package/src/lib/server/campaign-conversion-attribution.js +3 -3
  11. package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
  12. package/src/lib/server/card-payment-velocity.d.ts +9 -0
  13. package/src/lib/server/card-payment-velocity.js +28 -2
  14. package/src/lib/server/card-payment-velocity.js.map +1 -1
  15. package/src/lib/server/consent-group-carry.js +1 -1
  16. package/src/lib/server/consent-group-carry.js.map +1 -1
  17. package/src/lib/server/consent-group-change.js +2 -2
  18. package/src/lib/server/consent-group-change.js.map +1 -1
  19. package/src/lib/server/dynamic-list-materialize.js +3 -3
  20. package/src/lib/server/dynamic-list-materialize.js.map +1 -1
  21. package/src/lib/server/email-flow-gate.js +2 -2
  22. package/src/lib/server/email-flow-gate.js.map +1 -1
  23. package/src/lib/server/email-suppression.js +1 -1
  24. package/src/lib/server/email-suppression.js.map +1 -1
  25. package/src/lib/server/host-visitor-records.d.ts +32 -4
  26. package/src/lib/server/host-visitor-records.js +73 -14
  27. package/src/lib/server/host-visitor-records.js.map +1 -1
  28. package/src/lib/server/hosted-page-review.d.ts +34 -0
  29. package/src/lib/server/hosted-page-review.js +270 -22
  30. package/src/lib/server/hosted-page-review.js.map +1 -1
  31. package/src/lib/server/leaving-notice.d.ts +98 -0
  32. package/src/lib/server/leaving-notice.js +250 -0
  33. package/src/lib/server/leaving-notice.js.map +1 -0
  34. package/src/lib/server/link-reputation-review.d.ts +54 -0
  35. package/src/lib/server/link-reputation-review.js +144 -0
  36. package/src/lib/server/link-reputation-review.js.map +1 -0
  37. package/src/lib/server/media-cdn-rate-limit.d.ts +6 -3
  38. package/src/lib/server/media-cdn-rate-limit.js +6 -3
  39. package/src/lib/server/media-cdn-rate-limit.js.map +1 -1
  40. package/src/lib/server/member-email-aliases.d.ts +9 -1
  41. package/src/lib/server/member-email-aliases.js +25 -1
  42. package/src/lib/server/member-email-aliases.js.map +1 -1
  43. package/src/lib/server/notifications.d.ts +14 -0
  44. package/src/lib/server/notifications.js +29 -0
  45. package/src/lib/server/notifications.js.map +1 -1
  46. package/src/lib/server/operator-alerts.d.ts +7 -1
  47. package/src/lib/server/operator-alerts.js +5 -3
  48. package/src/lib/server/operator-alerts.js.map +1 -1
  49. package/src/lib/server/org-age.d.ts +15 -6
  50. package/src/lib/server/org-age.js +20 -8
  51. package/src/lib/server/org-age.js.map +1 -1
  52. package/src/lib/server/org-containers.d.ts +66 -0
  53. package/src/lib/server/org-containers.js +95 -0
  54. package/src/lib/server/org-containers.js.map +1 -0
  55. package/src/lib/server/org-member-notice.js +3 -2
  56. package/src/lib/server/org-member-notice.js.map +1 -1
  57. package/src/lib/server/outbound-send-review.js +23 -6
  58. package/src/lib/server/outbound-send-review.js.map +1 -1
  59. package/src/lib/server/page-security-hold.d.ts +93 -0
  60. package/src/lib/server/page-security-hold.js +180 -0
  61. package/src/lib/server/page-security-hold.js.map +1 -0
  62. package/src/lib/server/payment-fraud-signal.js +5 -1
  63. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  64. package/src/lib/server/payment-provider-stripe-connect-payouts.js +35 -1
  65. package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -1
  66. package/src/lib/server/payment-risk-record.d.ts +1 -1
  67. package/src/lib/server/payment-risk-record.js +3 -1
  68. package/src/lib/server/payment-risk-record.js.map +1 -1
  69. package/src/lib/server/platform-marketing-consent.d.ts +1 -1
  70. package/src/lib/server/platform-marketing-consent.js +1 -1
  71. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  72. package/src/lib/server/risk-notice.d.ts +32 -1
  73. package/src/lib/server/risk-notice.js +90 -28
  74. package/src/lib/server/risk-notice.js.map +1 -1
  75. package/src/lib/server/sending-domains.d.ts +17 -0
  76. package/src/lib/server/sending-domains.js +28 -4
  77. package/src/lib/server/sending-domains.js.map +1 -1
  78. package/src/lib/server/sso-enforcement.js +7 -2
  79. package/src/lib/server/sso-enforcement.js.map +1 -1
  80. package/src/lib/server/staff-alert-email.js +4 -2
  81. package/src/lib/server/staff-alert-email.js.map +1 -1
  82. package/src/lib/server/{email-topic-confirmation.js → topic-subscriptions.js} +15 -11
  83. package/src/lib/server/topic-subscriptions.js.map +1 -0
  84. package/src/lib/server/upsert-contact.js +2 -2
  85. package/src/lib/server/upsert-contact.js.map +1 -1
  86. package/src/lib/server/web-risk.d.ts +100 -0
  87. package/src/lib/server/web-risk.js +383 -0
  88. package/src/lib/server/web-risk.js.map +1 -0
  89. package/src/tenancy.d.ts +1 -3
  90. package/src/tenancy.js +1 -7
  91. package/src/tenancy.js.map +1 -1
  92. package/src/lib/server/contact-email-engagement.d.ts +0 -32
  93. package/src/lib/server/contact-email-engagement.js +0 -145
  94. package/src/lib/server/contact-email-engagement.js.map +0 -1
  95. package/src/lib/server/crm-booking-activity.d.ts +0 -86
  96. package/src/lib/server/crm-booking-activity.js +0 -257
  97. package/src/lib/server/crm-booking-activity.js.map +0 -1
  98. package/src/lib/server/crm-inbound-email.d.ts +0 -168
  99. package/src/lib/server/crm-inbound-email.js +0 -282
  100. package/src/lib/server/crm-inbound-email.js.map +0 -1
  101. package/src/lib/server/email-topic-confirmation.js.map +0 -1
  102. /package/src/lib/server/{email-topic-confirmation.d.ts → topic-subscriptions.d.ts} +0 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-visitor-records.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 * The durable half of the visitor-record ceilings (AGL-1529) — the lead\n * writer that enforces `LEADS_MAX_PER_HOST`, and the bookkeeping that makes a\n * trip of EITHER ceiling something a site's owner can see.\n *\n * The policy is pure and lives in `@aglyn/aglyn/server`\n * (`visitor-record-ceiling.ts`); this is the part that needs Firestore. Same\n * split as `visitorWriteRateLimitRefusal`, and for the same reason.\n */\n\nimport {\n CAPTURED_BY_HOST_FIELD,\n checkVisitorRecordCeiling,\n LEADS_MAX_PER_HOST,\n marketingConsentFieldsForGroup,\n personKey,\n readMarketingBasis,\n submissionMonthKey,\n visitorRecordRefusedCounterId,\n type VisitorRecordKind,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n attributeCampaignConversion,\n type ResolvedCampaignTouch,\n} from './campaign-conversion-attribution'\nimport { notifyHostManagers } from './notifications'\nimport { scheduleCapturedEmailCheck } from './capture-email-check'\nimport firebaseAdmin from './firebase-admin'\nimport {\n consentGroupForSite,\n orgDataCollectionForHost,\n resolveOrgIdForHost,\n scopedToHost,\n} from './organizations'\nimport { crmLeadListFields, crmReadTokens, crmScopeTokens } from '@aglyn/aglyn/server'\nimport type { ConsentGroup, ScopeToken } from '@aglyn/aglyn/server'\n// The module path rather than the barrel, as `upsert-contact.ts` does: a spec\n// that substitutes the barrel keeps the real rule for which group a grant is.\nimport { consentGroupForGrant } from '@aglyn/aglyn/app-utils/consent-groups'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/**\n * A lead's one home: `orgs/{orgId}/leads/{personKey}` (AGL-3275).\n *\n * ## Why leads moved\n *\n * A lead used to live at `hosts/{hostId}/leads/{personKey}` while the\n * contact for the same human lived on the org with a `visibleTo` array. The\n * path was doing the job `visibleTo` does, and doing it worse:\n *\n * - An AGENCY was isolated by the path. It is isolated by `visibleTo` too,\n * with no configuration — `consentGroupScope` on a group of one is\n * `['host:{id}']`.\n * - A MULTI-BRAND org could not be served at all. `personKey` is derived\n * from the address, so the same person captured on two sibling brands\n * produced two documents with byte-identical ids under different parents.\n * One person was two records by construction, which is the thing AGL-3232\n * had just finished eliminating for contacts.\n * - The two answers could disagree. A person's contact was visible to every\n * brand in the consent group they actually consented to — the disclosure\n * names those brands — while their lead was visible to whichever brand's\n * form they happened to land on. The lead was scoped more narrowly than\n * what the person was told, which is the safe direction to be wrong in and\n * still the wrong answer.\n *\n * So a lead is stamped with `crmScopeTokens` and read with `crmReadTokens`,\n * the same pair the contacts surface uses. Nothing here is a new mechanism;\n * this is leads arriving at the one the rest of the CRM already had.\n *\n * ## The fallback is READ-ONLY, and temporary\n *\n * This repo has run this migration once already — AGL-237 moved datasets,\n * contacts and media to the org, AGL-1040 backfilled, AGL-1050 deleted the\n * fallback — and `orgDataCollectionForHost` carries what it learned:\n *\n * \"a second storage path that can still be WRITTEN is a second boundary to\n * enforce forever, which undoes the premise of scoped sharing: one home\n * per resource plus an explicit scope.\"\n *\n * So the host path is never written here. It is read, once, to carry a\n * not-yet-migrated lead onto the org as a side effect of the next write that\n * touches it ({@link leadForWrite}) — which shrinks what AGL-3276's backfill\n * has left to fold, and means no read can be answered by a stale host row\n * after a write has moved on. AGL-3277 deletes the fallback and the rules\n * block behind it once that backfill reports nothing left to plan.\n */\n\n\n/**\n * The org collection a lead is written to. Always the org — see the\n * read-only note above.\n */\nexport async function orgLeadsForHost(\n hostId: string,\n): Promise<FirebaseFirestore.CollectionReference> {\n return orgDataCollectionForHost(hostId, 'leads')\n}\n\n/**\n * The leads one site may LIST, narrowed by `visibleTo`.\n *\n * The Admin SDK does not evaluate rules, so an unnarrowed read here would\n * serve one agency client's leads on another's site — the same reason\n * `scopedToHost` exists for every other org-owned collection.\n */\nexport async function orgLeadsQueryForHost(hostId: string): Promise<{\n ref: FirebaseFirestore.CollectionReference\n query: FirebaseFirestore.Query\n}> {\n const ref = await orgLeadsForHost(hostId)\n return { ref, query: scopedToHost(ref, hostId) }\n}\n\n/**\n * The tokens a reader holding this group may ask `array-contains-any` for.\n * Capped at 30 by `crmReadTokens`, as the contacts list is.\n */\nexport async function leadReadTokensForHost(\n hostId: string,\n group?: ConsentGroup,\n): Promise<ScopeToken[]> {\n return crmReadTokens(group ?? (await consentGroupForSite(hostId)))\n}\n\n/**\n * The `visibleTo` a capture on this site stamps: the consent group's sites,\n * or `['org']` where the org set `defaultResourceScope`.\n *\n * Widened by the capture, never by the lookup — a site that has never\n * captured this person gains nothing by finding them, which is what keeps an\n * agency's clients apart on a record they share. Callers UPDATING an\n * existing lead union these in rather than replacing, exactly as\n * `upsert-contact` does.\n */\nexport async function leadScopeForHost(\n hostId: string,\n org?: Record<string, unknown> | null,\n): Promise<ScopeToken[]> {\n const group = await consentGroupForSite(hostId, org)\n return crmScopeTokens(org ?? null, group)\n}\n\n/**\n * The lead for an address, wherever it currently lives.\n *\n * ## The lookup is UNSCOPED, and has to be\n *\n * One human who touched two sibling brands is one person. Narrowing this to\n * what the capturing site may already see would let a second submission on a\n * sibling brand mint a SECOND record for the same address — the duplication\n * this migration exists to end, reintroduced one layer up. Recognizing\n * somebody and being allowed to read their row are different acts:\n * this finds the person, {@link leadScopeForHost} decides who may see them.\n *\n * `upsert-contact` states the same rule at its own dedupe lookup, and for the\n * same reason. A caller that wants only what a site may SEE wants\n * {@link orgLeadsQueryForHost}.\n *\n * @returns the org row; else the not-yet-migrated host row; else `null`.\n */\nexport async function readLeadForHost(\n hostId: string,\n key: string,\n): Promise<FirebaseFirestore.DocumentSnapshot | null> {\n const orgRow = await (await orgLeadsForHost(hostId)).doc(key).get()\n return orgRow.exists ? orgRow : null\n}\n\n/**\n * The ref a write should target.\n *\n * This carried a not-yet-backfilled host row onto the org first, and reported\n * whether it had (`carried`). AGL-3276 emptied the host path and AGL-3277\n * removed it, so there is nothing left to carry: the org row is the only row.\n * `carried` stays on the shape, always `false`, because several callers\n * destructure it and a lie is cheaper to read than a signature change that\n * says nothing.\n */\nexport async function leadForWrite(\n hostId: string,\n key: string,\n): Promise<{\n ref: FirebaseFirestore.DocumentReference\n existed: boolean\n carried: boolean\n}> {\n const ref = (await orgLeadsForHost(hostId)).doc(key)\n return { ref, existed: (await ref.get()).exists, carried: false }\n}\n\n\n/**\n * Make a tripped ceiling OBSERVABLE rather than a silent drop — the * standing rule that a control which is not visible in the console does not\n * count as shipped.\n *\n * Two audiences, one call, in exactly `recordAbuseCeilingTrip`'s shape\n * (`apps/tenant/app/api/forms/submit/route.ts`, AGL-1655):\n *\n * - A durable per-month refusal count at\n * `hosts/{id}/counters/{siteMembers|leads}Refused`. Counters are excluded\n * from every client write in `cloud/firebase-firestore.rules` (AGL-1367),\n * so the record cannot be edited away by the site it describes, and host\n * admins can already READ it — which is what lets the inbox console page\n * render it with no rules change.\n * - One in-app notification to the site's managers, on the FIRST refusal of\n * the month only. A notification per refused bot request would be the\n * flood again, delivered.\n *\n * The counter is month-keyed even though the CEILING is a total, because the\n * two facts are different: the ceiling is \"how many records exist\" and this is\n * \"how many were turned away recently\". Only the second is worth a notice.\n *\n * Best-effort throughout: bookkeeping that failed must never turn a contained\n * refusal into a 500, because a 500 is an invitation to retry.\n */\nexport async function recordVisitorRecordCeilingTrip(options: {\n hostRef: FirebaseFirestore.DocumentReference\n hostId: string\n kind: VisitorRecordKind\n ceiling: number\n monthKey?: string\n}): Promise<void> {\n const { hostRef, hostId, kind, ceiling } = options\n const monthKey = options.monthKey ?? submissionMonthKey()\n try {\n const refusedRef = hostRef\n .collection('counters')\n .doc(visitorRecordRefusedCounterId(kind))\n const refusedSnapshot = await refusedRef.get()\n const alreadyRefused = Number(refusedSnapshot.get(monthKey) ?? 0)\n await refusedRef.set(\n {\n [monthKey]: FieldValue.increment(1),\n // Explicit values only — Firestore rejects `undefined`.\n ceiling,\n lastRefusedAtMs: Date.now(),\n },\n { merge: true },\n )\n if (alreadyRefused === 0) {\n const leads = kind === 'leads'\n await notifyHostManagers(hostId, {\n type: 'system.visitorRecordsPaused',\n title: leads\n ? 'Lead capture paused — this site is at the platform limit'\n : 'Sign-ups paused — this site is at the platform limit',\n body:\n `This site holds ${ceiling.toLocaleString()} ` +\n `${leads ? 'leads' : 'member accounts'}, which is the platform ` +\n `safety limit, so further ${leads ? 'leads' : 'sign-ups'} are ` +\n 'being refused. This is not part of your plan — every plan ' +\n `includes unlimited ${leads ? 'leads' : 'member accounts'}. ` +\n `Remove some ${leads ? 'leads' : 'members'}, or contact support ` +\n 'if this is real traffic.',\n link: `/${hostId}/inbox`,\n })\n }\n } catch (error) {\n console.error('visitor record ceiling bookkeeping failed', error)\n }\n}\n\n/** What a caller wants stored on the lead. `createdAt` is stamped here. */\nexport interface HostLeadInput {\n email: string\n /** The name the person typed, when they typed one (AGL-2303). */\n name?: string\n /** `signup`, `booking`, `form:{formId}` — the surface that produced it. */\n source: string\n /**\n * Explicit marketing opt-in, with a consent timestamp — the same shape\n * `upsertHostContact` already carries (AGL-301). Omitted or `false` writes\n * nothing: a lead is a side effect of an action the visitor DID take\n * (sign up, book), which is not by itself consent to be emailed\n * marketing, so this is only set when the caller captured an explicit\n * checkbox.\n */\n marketingConsent?: boolean\n /**\n * The `consentGroupDisclosureKey` the capture surface rendered beside that\n * checkbox (AGL-3320). The grant pools across the site's consent group only\n * when it is the group's current key, and is this site's alone otherwise —\n * the same rule `upsertHostContact` applies.\n */\n disclosedConsentGroup?: string | null\n}\n\n/**\n * Record one lead at `hosts/{hostId}/leads/{personKey}`, bounded by\n * `LEADS_MAX_PER_HOST` (AGL-1529).\n *\n * ## One person is one document\n *\n * `docs/specs/reusable-forms.md` §4b. This used to be `tx.create(ref.doc())`\n * — an auto-id per capture event — so one returning customer who signed up\n * and booked twice was three \"leads\". The Members & leads tab presented a\n * list of events as a list of people, and the only thing holding two rows for\n * one person together was string equality on the address at render time.\n *\n * The events are not lost, they are just no longer the record: `sources`\n * carries every surface that produced a capture, `submissionCount` counts\n * them, and `firstSeenAtMs`/`lastSeenAtMs` bracket them. The submissions, the\n * bookings and the member document are still there and still one row each.\n *\n * The id is {@link personKey} — `sha256(normalizeContactEmail(email))`, the\n * SAME derivation a list membership's `memberKey` uses, imported rather than\n * restated. Two specs named this function and the rule both stated is that\n * whichever ships second imports the first's helper: a second copy is how\n * `emailSuppressionKey` and `suppressionId` came to disagree.\n *\n * ## Why every lead writer goes through here\n *\n * There are three lead writes in the repo — the sign-up handler and the two\n * bookings paths — and all three were `hostRef.collection('leads').add(…)`\n * with a `.catch()` on the end. A cap enforced at two of three call sites is\n * not a cap, and the fourth writer somebody adds next month would not have\n * one either. One function is the only shape that survives that.\n *\n * ## The count is evaluated INSIDE the transaction that writes\n *\n * A create-time quota can be laundered by WHEN it is evaluated, not by the\n * counting rule (AGL-2231/2265/2266). Read-then-decide-then-`add()` lets N\n * concurrent visitors each read the same pre-count, each find room, and each\n * land — and nothing re-counts afterwards, so the extra rows are permanent.\n * `Transaction.get(AggregateQuery)` serialises the count against a concurrent\n * create into the same collection: the loser retries, re-reads the higher\n * count, and is refused. The count is of LIVE documents, so triaging leads in\n * the inbox frees the slots.\n *\n * ## Refusing a lead never fails the visitor's action\n *\n * A lead is a SIDE EFFECT — of a sign-up, or of a booking. The visitor did\n * not ask for it and cannot see it, so refusing one must not refuse the thing\n * they did ask for. This returns a boolean and never throws; the trip is\n * recorded and the caller carries on. That is the honest split, and it is why\n * `SITE_MEMBERS_MAX_PER_HOST` (which governs an action a visitor DID take) is\n * enforced with a 429 in the sign-up handler and this is not.\n */\nexport async function addHostLeadOutcome(options: {\n hostRef: FirebaseFirestore.DocumentReference\n hostId: string\n lead: HostLeadInput\n /**\n * The ceiling to compare against. Defaults to `LEADS_MAX_PER_HOST` and is\n * passed by NOTHING in production — it exists so the suite can re-drive the\n * same count against a ceiling one higher and require the write to succeed.\n * A refusal that survives its own ceiling being raised was never that\n * ceiling's refusal, and there is exactly ONE comparison below, so knifing\n * it cannot be absorbed by a fallback branch.\n */\n ceiling?: number\n /**\n * The campaign this person came from, already resolved by the door.\n *\n * Resolved rather than raw, and passed rather than looked up, because one\n * visitor action reaches several writers: a form submission that creates a\n * submission, a contact AND a lead must pay for the touch lookup once. A\n * door that hands none — every order path, every import — attributes\n * nothing, which is how a lead that no campaign caused stays uncredited.\n */\n touch?: ResolvedCampaignTouch | null\n}): Promise<HostLeadOutcome> {\n const { hostRef, hostId, lead } = options\n const maxPerHost = options.ceiling ?? LEADS_MAX_PER_HOST\n try {\n const leadsRef = await orgLeadsForHost(hostId)\n const firestore = hostRef.firestore\n /*\n * `null` for anything that is not a usable address — a lead captured\n * against a malformed one keeps an auto-id and stays its own row. Keying\n * several unusable addresses under one guessed id would merge two\n * different people, which is worse than two rows for one.\n */\n const key = personKey(lead.email)\n /*\n * A lead the org has not taken over yet is carried across BEFORE the\n * transaction opens (AGL-3275). It has to happen outside: the carry is\n * itself a read-then-write, and a transaction that also counted the\n * collection would be reading a row it was about to create. Two doors\n * carrying the same person in the same second both write the legacy\n * fields under `merge`, so the race is a no-op rather than a conflict.\n */\n const leadRef = key\n ? (await leadForWrite(hostId, key)).ref\n : leadsRef.doc()\n /*\n * The scope this capture stamps, and the group whose terms its consent is\n * recorded under. Both resolved HERE rather than in the transaction body:\n * a contended transaction re-runs that body, and neither of these can\n * change between attempts, so resolving them inside would pay for the org\n * read again on every retry.\n */\n const scope = await leadScopeForHost(hostId)\n const group = await consentGroupForSite(hostId)\n const now = Date.now()\n const seen = {\n // `arrayUnion`, so a person who books twice has `['booking']` and one\n // who signed up and then submitted a form has both. Bounded by the\n // number of surfaces, not by the number of captures.\n sources: FieldValue.arrayUnion(lead.source),\n lastSeenAtMs: now,\n submissionCount: FieldValue.increment(1),\n ...(lead.name ? { name: lead.name } : {}),\n }\n let created = false\n let sourceAdded = false\n let predicted = false\n const refused = await firestore.runTransaction(async (tx) => {\n // Reset per attempt: a contended transaction re-runs its body, and a\n // flag left standing from an aborted attempt would credit a campaign\n // with a person who turned out to exist already.\n created = false\n sourceAdded = false\n // ALL READS BEFORE THE WRITE, which Firestore requires.\n const existing = await tx.get(leadRef)\n // Whether the lead already reads \"Would bounce\", for the check below.\n predicted = (existing.data() ?? {})['emailStatus'] === 'undeliverable'\n /*\n * ⛔ THE CEILING GATES A NEW PERSON, NEVER AN EXISTING ONE.\n *\n * A returning visitor's capture is an UPDATE — it does not grow the\n * collection, so refusing it buys no capacity and costs the customer\n * the source and the timestamp they would have learned. That is the\n * enforcement-at-use shape the capacity rule exists to forbid: a limit\n * must refuse the addition, never a person already recorded or the\n * data attached to them.\n *\n * It also means the count is only paid on a genuinely new person,\n * which is the case that can move it.\n */\n if (!existing.exists) {\n /*\n * COUNTED OVER WHAT THIS SITE MAY SEE, not over the org (AGL-3275).\n *\n * The collection is org-wide now, and `LEADS_MAX_PER_HOST` is a\n * per-SITE ceiling. An unfiltered count would charge every brand in\n * an agency's account for every other brand's leads, and the first\n * client to fill its allowance would refuse captures on sites that\n * had taken none. `scopedToHost` is the same narrowing every other\n * org-owned read uses, so a single-site org counts exactly what it\n * counted before this moved.\n *\n * A lead shared by two brands in one consent group is counted by\n * both, which is the honest answer: each of them holds it.\n */\n const used = (\n await tx.get(scopedToHost(leadsRef, hostId).count())\n ).data().count\n if (checkVisitorRecordCeiling(used, maxPerHost).exceeded) return true\n }\n /*\n * Consent is carried forward and never cleared.\n *\n * A basis is written only when this capture carried an explicit\n * opt-in, so a later booking by someone who did not tick the box\n * leaves an earlier grant standing — absent-or-granted, the shape\n * every other writer uses. The TIMESTAMP is carried over rather than\n * restamped, for the reason given at the read below.\n */\n /*\n * The EARLIEST grant is the one that happened, so a later capture\n * carrying the same checkbox keeps the original date rather than\n * restamping when this person opted in. Read back through the shared\n * reader so \"already consented\" means the same thing here as it does\n * at send time.\n */\n /*\n * THE SITE'S REAL GROUP, because the silo that justified a group of one\n * is gone (AGL-3275).\n *\n * This read `soloConsentGroup(hostId)` for as long as a lead lived at\n * `hosts/{hostId}/leads`, and the reason was the path: private by\n * construction, so pooling a lead's basis would have recorded a\n * disclosure that reached nothing, while the contact written by the\n * same capture door was org-shared and pooled.\n *\n * A lead is now org-shared on exactly the terms the contact is, so the\n * premise is false and keeping the group of one would leave a\n * multi-brand org holding a lead its sibling brand can SEE but may not\n * MAIL — a narrower basis than the one `consentGroupDisclosure` named\n * beside the checkbox this person ticked. Pooling here records what\n * they were actually told, and only that: the grant reaches the group\n * when the capture surface sent back the group's current disclosure\n * key (AGL-3320), and this site alone otherwise. An undeclared group is\n * still a group of one, so an agency is unchanged and configures\n * nothing.\n *\n * Resolved above the transaction — see `group`'s declaration.\n */\n const prior = readMarketingBasis(existing.data() ?? null, group)\n const consentAtMs =\n prior.basis === 'granted' && prior.basisAtMs !== null\n ? prior.basisAtMs\n : now\n created = !existing.exists\n /*\n * Whether this capture puts its surface on the person for the FIRST\n * time: a new lead, or one first met through another door. A form\n * counts its leads by this (AGL-3330) — the people it filed, which is\n * what a recount of `sources` finds — so a returning visitor's second\n * submission moves no form's lead count.\n */\n const storedSources = (existing.data() ?? {})['sources']\n sourceAdded =\n created || !(Array.isArray(storedSources) && storedSources.includes(lead.source))\n /*\n * THE FIELDS THE LEADS LIST QUERIES (AGL-3321), from the lead as it\n * will stand: what the transaction read, the address and name this\n * capture brings and the scope it widens. A new lead is written\n * `status: 'new'`, so the Open view's `status in [new, working]`\n * finds a lead nobody has touched — an absent field matches no query.\n */\n const stored = existing.data() ?? {}\n const storedScope = Array.isArray(stored['visibleTo']) ? (stored['visibleTo'] as unknown[]) : []\n const listFields = crmLeadListFields({\n ...stored,\n email: lead.email,\n ...(lead.name ? { name: lead.name } : {}),\n visibleTo: [...new Set([...storedScope, ...scope])],\n })\n tx.set(\n leadRef,\n {\n email: lead.email,\n ...seen,\n ...listFields,\n /*\n * WIDENED BY THE CAPTURE, NEVER BY THE LOOKUP (AGL-3275).\n *\n * `arrayUnion` rather than a replace, exactly as `upsert-contact`\n * stamps a contact: this site just collected this person, so it may\n * see the row. A site that merely FOUND them — the unscoped dedupe\n * lookup in `readLeadForHost` — gains nothing, which is what keeps\n * an agency's clients apart on a record they share.\n */\n visibleTo: FieldValue.arrayUnion(...scope),\n /*\n * EVERY SITE THAT CAPTURED THIS PERSON, not just the first\n * (AGL-3275).\n *\n * This was written once, on create, and that was sound while the\n * collection sat under one host and could hold only that host's\n * name. On the org the row is shared, so a sibling brand capturing\n * a person the first brand already holds has to be recorded here or\n * the \"Known by\" answer silently omits it — the same `arrayUnion`\n * the contact door has always used for this field, now that a lead\n * has the same question to answer.\n */\n [CAPTURED_BY_HOST_FIELD]: FieldValue.arrayUnion(hostId),\n ...(existing.exists\n ? {}\n : {\n firstSeenAtMs: now,\n createdAt: FieldValue.serverTimestamp(),\n }),\n /*\n * The basis is recorded under the GROUP, keyed by its host, because\n * {@link readMarketingBasis} is one function over four silos and a\n * silo whose basis lived somewhere else would need the reader to\n * know which collection it was handed. A reader that has to be told\n * the shape is a reader that can be told the wrong one.\n */\n ...(lead.marketingConsent\n ? marketingConsentFieldsForGroup(\n consentGroupForGrant(group, lead.disclosedConsentGroup),\n consentAtMs,\n )\n : {}),\n },\n { merge: true },\n )\n return false\n })\n if (refused) {\n await recordVisitorRecordCeilingTrip({\n hostRef,\n hostId,\n kind: 'leads',\n ceiling: maxPerHost,\n })\n return NOT_STORED\n }\n /*\n * ATTRIBUTED ON CREATION ONLY.\n *\n * A returning visitor's capture is an update — the campaign did not\n * produce a lead, it produced another visit by a person the site already\n * held — and crediting it would let whichever campaign ran most recently\n * re-earn every lead on the list. `created` is set inside the transaction\n * that decides it, so the attribution and the write agree about whether\n * this person is new.\n *\n * Awaited rather than fired off: `addHostLead` already returns only after\n * its own write, and a caller that `void`s it (every one of them) is\n * unaffected. Never throws, so a failure here cannot cost the lead.\n */\n /*\n * THE ADDRESS'S DOMAIN, checked after the response (AGL-3328): a lead\n * whose domain takes no mail reads \"Would bounce\". Queued, never\n * awaited — the write above is the capture, and this costs it nothing.\n */\n scheduleCapturedEmailCheck({\n orgId: leadsRef.parent?.id ?? null,\n hostId,\n email: lead.email,\n predicted,\n })\n if (created && options.touch) {\n await attributeCampaignConversion({\n hostId,\n kind: 'lead',\n refId: leadRef.id,\n touch: options.touch,\n convertedAtMs: now,\n })\n }\n return { stored: true, created, sourceAdded }\n } catch (error) {\n // Same posture the three original call sites had (`.catch(() => undefined)`\n // / `.catch(console.error)`): a lead that failed to store must not fail\n // the sign-up or the booking that produced it.\n console.error('lead write failed', error)\n return NOT_STORED\n }\n}\n\n/** What {@link addHostLeadOutcome} did with one capture. */\nexport interface HostLeadOutcome {\n /** Whether the lead was written — false when refused or failed. */\n stored: boolean\n /** Whether it was a new person. */\n created: boolean\n /** Whether this capture's `source` was new on the lead — see the write. */\n sourceAdded: boolean\n}\n\nconst NOT_STORED: HostLeadOutcome = { stored: false, created: false, sourceAdded: false }\n\n/**\n * {@link addHostLeadOutcome}, answering only whether the lead was stored —\n * the question every door but a form's asks.\n */\nexport async function addHostLead(\n options: Parameters<typeof addHostLeadOutcome>[0],\n): Promise<boolean> {\n return (await addHostLeadOutcome(options)).stored\n}\n"],"names":["CAPTURED_BY_HOST_FIELD","checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","marketingConsentFieldsForGroup","personKey","readMarketingBasis","submissionMonthKey","visitorRecordRefusedCounterId","FieldValue","attributeCampaignConversion","notifyHostManagers","scheduleCapturedEmailCheck","firebaseAdmin","consentGroupForSite","orgDataCollectionForHost","scopedToHost","crmLeadListFields","crmReadTokens","crmScopeTokens","consentGroupForGrant","firestore","app","orgLeadsForHost","hostId","orgLeadsQueryForHost","ref","query","leadReadTokensForHost","group","leadScopeForHost","org","readLeadForHost","key","orgRow","doc","get","exists","leadForWrite","existed","carried","recordVisitorRecordCeilingTrip","options","hostRef","kind","ceiling","monthKey","refusedSnapshot","refusedRef","collection","alreadyRefused","Number","set","increment","lastRefusedAtMs","Date","now","merge","leads","type","title","body","toLocaleString","link","error","console","addHostLeadOutcome","lead","maxPerHost","leadsRef","email","leadRef","scope","seen","sources","arrayUnion","source","lastSeenAtMs","submissionCount","name","created","sourceAdded","predicted","refused","runTransaction","tx","existing","data","used","count","exceeded","prior","consentAtMs","basis","basisAtMs","storedSources","Array","isArray","includes","stored","storedScope","listFields","visibleTo","Set","firstSeenAtMs","createdAt","serverTimestamp","marketingConsent","disclosedConsentGroup","NOT_STORED","orgId","parent","id","touch","refId","convertedAtMs","addHostLead"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-visitor-records.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 * The durable half of the visitor-record ceilings (AGL-1529) — the lead\n * writer that enforces `LEADS_MAX_PER_HOST`, and the bookkeeping that makes a\n * trip of EITHER ceiling something a site's owner can see.\n *\n * The policy is pure and lives in `@aglyn/aglyn/server`\n * (`visitor-record-ceiling.ts`); this is the part that needs Firestore. Same\n * split as `visitorWriteRateLimitRefusal`, and for the same reason.\n */\n\nimport {\n CAPTURED_BY_HOST_FIELD,\n checkVisitorRecordCeiling,\n LEADS_MAX_PER_HOST,\n marketingConsentFieldsForGroup,\n personKey,\n readMarketingBasis,\n submissionMonthKey,\n VISITOR_RECORD_NOTICE_SENT_FIELD,\n visitorRecordAcceptFillsCeiling,\n visitorRecordCeilingAnnounced,\n visitorRecordRefusedCounterId,\n type VisitorRecordKind,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n attributeCampaignConversion,\n type ResolvedCampaignTouch,\n} from './campaign-conversion-attribution'\nimport { notifyHostManagers } from './notifications'\nimport { scheduleCapturedEmailCheck } from './capture-email-check'\nimport firebaseAdmin from './firebase-admin'\nimport {\n consentGroupForSite,\n orgDataCollectionForHost,\n resolveOrgIdForHost,\n scopedToHost,\n} from './organizations'\nimport { crmLeadListFields, crmReadTokens, crmScopeTokens } from '@aglyn/aglyn/server'\nimport type { ConsentGroup, ScopeToken } from '@aglyn/aglyn/server'\n// The module path rather than the barrel, as `upsert-contact.ts` does: a spec\n// that substitutes the barrel keeps the real rule for which group a grant is.\nimport { consentGroupForGrant } from '@aglyn/aglyn/app-utils/consent-groups'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/**\n * A lead's one home: `orgs/{orgId}/leads/{personKey}` (AGL-3275).\n *\n * ## Why leads moved\n *\n * A lead used to live at `hosts/{hostId}/leads/{personKey}` while the\n * contact for the same human lived on the org with a `visibleTo` array. The\n * path was doing the job `visibleTo` does, and doing it worse:\n *\n * - An AGENCY was isolated by the path. It is isolated by `visibleTo` too,\n * with no configuration — `consentGroupScope` on a group of one is\n * `['host:{id}']`.\n * - A MULTI-BRAND org could not be served at all. `personKey` is derived\n * from the address, so the same person captured on two sibling brands\n * produced two documents with byte-identical ids under different parents.\n * One person was two records by construction, which is the thing AGL-3232\n * had just finished eliminating for contacts.\n * - The two answers could disagree. A person's contact was visible to every\n * brand in the consent group they actually consented to — the disclosure\n * names those brands — while their lead was visible to whichever brand's\n * form they happened to land on. The lead was scoped more narrowly than\n * what the person was told, which is the safe direction to be wrong in and\n * still the wrong answer.\n *\n * So a lead is stamped with `crmScopeTokens` and read with `crmReadTokens`,\n * the same pair the contacts surface uses. Nothing here is a new mechanism;\n * this is leads arriving at the one the rest of the CRM already had.\n *\n * ## The fallback is READ-ONLY, and temporary\n *\n * This repo has run this migration once already — AGL-237 moved datasets,\n * contacts and media to the org, AGL-1040 backfilled, AGL-1050 deleted the\n * fallback — and `orgDataCollectionForHost` carries what it learned:\n *\n * \"a second storage path that can still be WRITTEN is a second boundary to\n * enforce forever, which undoes the premise of scoped sharing: one home\n * per resource plus an explicit scope.\"\n *\n * So the host path is never written here. It is read, once, to carry a\n * not-yet-migrated lead onto the org as a side effect of the next write that\n * touches it ({@link leadForWrite}) — which shrinks what AGL-3276's backfill\n * has left to fold, and means no read can be answered by a stale host row\n * after a write has moved on. AGL-3277 deletes the fallback and the rules\n * block behind it once that backfill reports nothing left to plan.\n */\n\n\n/**\n * The org collection a lead is written to. Always the org — see the\n * read-only note above.\n */\nexport async function orgLeadsForHost(\n hostId: string,\n): Promise<FirebaseFirestore.CollectionReference> {\n return orgDataCollectionForHost(hostId, 'leads')\n}\n\n/**\n * The leads one site may LIST, narrowed by `visibleTo`.\n *\n * The Admin SDK does not evaluate rules, so an unnarrowed read here would\n * serve one agency client's leads on another's site — the same reason\n * `scopedToHost` exists for every other org-owned collection.\n */\nexport async function orgLeadsQueryForHost(hostId: string): Promise<{\n ref: FirebaseFirestore.CollectionReference\n query: FirebaseFirestore.Query\n}> {\n const ref = await orgLeadsForHost(hostId)\n return { ref, query: scopedToHost(ref, hostId) }\n}\n\n/**\n * The tokens a reader holding this group may ask `array-contains-any` for.\n * Capped at 30 by `crmReadTokens`, as the contacts list is.\n */\nexport async function leadReadTokensForHost(\n hostId: string,\n group?: ConsentGroup,\n): Promise<ScopeToken[]> {\n return crmReadTokens(group ?? (await consentGroupForSite(hostId)))\n}\n\n/**\n * The `visibleTo` a capture on this site stamps: the consent group's sites,\n * or `['org']` where the org set `defaultResourceScope`.\n *\n * Widened by the capture, never by the lookup — a site that has never\n * captured this person gains nothing by finding them, which is what keeps an\n * agency's clients apart on a record they share. Callers UPDATING an\n * existing lead union these in rather than replacing, exactly as\n * `upsert-contact` does.\n */\nexport async function leadScopeForHost(\n hostId: string,\n org?: Record<string, unknown> | null,\n): Promise<ScopeToken[]> {\n const group = await consentGroupForSite(hostId, org)\n return crmScopeTokens(org ?? null, group)\n}\n\n/**\n * The lead for an address, wherever it currently lives.\n *\n * ## The lookup is UNSCOPED, and has to be\n *\n * One human who touched two sibling brands is one person. Narrowing this to\n * what the capturing site may already see would let a second submission on a\n * sibling brand mint a SECOND record for the same address — the duplication\n * this migration exists to end, reintroduced one layer up. Recognizing\n * somebody and being allowed to read their row are different acts:\n * this finds the person, {@link leadScopeForHost} decides who may see them.\n *\n * `upsert-contact` states the same rule at its own dedupe lookup, and for the\n * same reason. A caller that wants only what a site may SEE wants\n * {@link orgLeadsQueryForHost}.\n *\n * @returns the org row; else the not-yet-migrated host row; else `null`.\n */\nexport async function readLeadForHost(\n hostId: string,\n key: string,\n): Promise<FirebaseFirestore.DocumentSnapshot | null> {\n const orgRow = await (await orgLeadsForHost(hostId)).doc(key).get()\n return orgRow.exists ? orgRow : null\n}\n\n/**\n * The ref a write should target.\n *\n * This carried a not-yet-backfilled host row onto the org first, and reported\n * whether it had (`carried`). AGL-3276 emptied the host path and AGL-3277\n * removed it, so there is nothing left to carry: the org row is the only row.\n * `carried` stays on the shape, always `false`, because several callers\n * destructure it and a lie is cheaper to read than a signature change that\n * says nothing.\n */\nexport async function leadForWrite(\n hostId: string,\n key: string,\n): Promise<{\n ref: FirebaseFirestore.DocumentReference\n existed: boolean\n carried: boolean\n}> {\n const ref = (await orgLeadsForHost(hostId)).doc(key)\n return { ref, existed: (await ref.get()).exists, carried: false }\n}\n\n\n/**\n * Make a tripped ceiling OBSERVABLE rather than a silent drop — the * standing rule that a control which is not visible in the console does not\n * count as shipped.\n *\n * Two audiences, one call, in exactly `recordAbuseCeilingTrip`'s shape\n * (`apps/tenant/app/api/forms/submit/route.ts`, AGL-1655):\n *\n * - A durable per-month refusal count at\n * `hosts/{id}/counters/{siteMembers|leads}Refused`. Counters are excluded\n * from every client write in `cloud/firebase-firestore.rules` (AGL-1367),\n * so the record cannot be edited away by the site it describes, and host\n * admins can already READ it — which is what lets the inbox console page\n * render it with no rules change.\n * - One notification to the site's managers per CROSSING (AGL-3442): on the\n * first refusal after the site reaches the ceiling, and not again while it\n * stays there. A notification per refused bot request would be the flood\n * again, delivered, and one a month would repeat an alarm about a state a\n * site can sit in for months. {@link rearmVisitorRecordCeilingNotice}\n * re-arms it once the site is seen below the ceiling, so reaching it again\n * is announced again.\n *\n * The counter is month-keyed even though the CEILING is a total, because the\n * two facts are different: the ceiling is \"how many records exist\" and this is\n * \"how many were turned away recently\". The inbox reports the second; the\n * notice announces the first, which is why it follows the crossing and not\n * the month.\n *\n * Best-effort throughout: bookkeeping that failed must never turn a contained\n * refusal into a 500, because a 500 is an invitation to retry.\n */\nexport async function recordVisitorRecordCeilingTrip(options: {\n hostRef: FirebaseFirestore.DocumentReference\n hostId: string\n kind: VisitorRecordKind\n ceiling: number\n monthKey?: string\n}): Promise<void> {\n const { hostRef, hostId, kind, ceiling } = options\n const monthKey = options.monthKey ?? submissionMonthKey()\n try {\n const refusedRef = hostRef\n .collection('counters')\n .doc(visitorRecordRefusedCounterId(kind))\n const refusedSnapshot = await refusedRef.get()\n // Announced already for this crossing, by this writer or by the\n // month-scoped one before it — see `visitorRecordCeilingAnnounced`.\n const announced = visitorRecordCeilingAnnounced(refusedSnapshot.data())\n const now = Date.now()\n await refusedRef.set(\n {\n [monthKey]: FieldValue.increment(1),\n // Explicit values only — Firestore rejects `undefined`.\n ceiling,\n lastRefusedAtMs: now,\n // Marked in the same write as the refusal, so the next refusal reads\n // the crossing as announced without a write of its own.\n ...(announced ? {} : { [VISITOR_RECORD_NOTICE_SENT_FIELD]: now }),\n },\n { merge: true },\n )\n if (!announced) {\n const leads = kind === 'leads'\n /*\n * What is refused, said exactly (AGL-3432). A lead door — a form\n * routed to leads, a booking — stores its own record BEFORE it files\n * the lead, so at the ceiling the visitor's submission or booking is\n * still kept and only the new lead is not. A member sign-up is refused\n * whole: no account is created. `{site}` is the site's name, filled by\n * `notifyHostManagers` from the host doc it already reads.\n */\n await notifyHostManagers(hostId, {\n type: 'system.visitorRecordsPaused',\n title: leads\n ? 'Lead capture paused on {site} — platform limit reached'\n : 'Sign-ups paused on {site} — platform limit reached',\n body: leads\n ? `{site} holds ${ceiling.toLocaleString()} leads, the platform ` +\n 'safety limit, so new people are no longer added as leads. What ' +\n 'they send, such as a form submission or a booking, is still ' +\n 'saved; only the new lead is not. This limit is not part of your ' +\n 'plan — every plan includes unlimited leads — and nothing is ' +\n 'charged. Remove some leads, or contact support if this is real ' +\n 'traffic.'\n : `{site} has ${ceiling.toLocaleString()} member accounts, the ` +\n 'platform safety limit, so new sign-ups are refused and no ' +\n 'account is created. This limit is not part of your plan — every ' +\n 'plan includes unlimited member accounts — and nothing is ' +\n 'charged. Remove some members, or contact support if this is ' +\n 'real traffic.',\n link: `/${hostId}/inbox`,\n })\n }\n } catch (error) {\n console.error('visitor record ceiling bookkeeping failed', error)\n }\n}\n\n/**\n * Re-arm the ceiling notice when an accepted record shows the site was below\n * the ceiling (AGL-3442).\n *\n * Called by every writer that judges a new record against the ceiling, with\n * the count it judged. It does nothing unless the record it just accepted took\n * the site's LAST free slot: a site that dropped below the ceiling can reach\n * it again only through that accept, so it is where the re-arm is observed,\n * and every other accept pays nothing for it. That accept reads the refusal\n * counter, and clears the notice marker only when the previous crossing was\n * announced, so a site that never reached the ceiling gains no document.\n *\n * Best-effort and never throws, for the reason the trip's bookkeeping is: the\n * record is already stored, and a failure here only costs a notice.\n */\nexport async function rearmVisitorRecordCeilingNotice(options: {\n hostRef: FirebaseFirestore.DocumentReference\n kind: VisitorRecordKind\n /** The live count the accept was judged against, before its own record. */\n used: number\n ceiling: number\n}): Promise<void> {\n const { hostRef, kind, used, ceiling } = options\n if (!visitorRecordAcceptFillsCeiling(used, ceiling)) return\n try {\n const refusedRef = hostRef\n .collection('counters')\n .doc(visitorRecordRefusedCounterId(kind))\n const refusedSnapshot = await refusedRef.get()\n if (!visitorRecordCeilingAnnounced(refusedSnapshot.data())) return\n await refusedRef.set(\n { [VISITOR_RECORD_NOTICE_SENT_FIELD]: null },\n { merge: true },\n )\n } catch (error) {\n console.error('visitor record ceiling re-arm failed', error)\n }\n}\n\n/** What a caller wants stored on the lead. `createdAt` is stamped here. */\nexport interface HostLeadInput {\n email: string\n /** The name the person typed, when they typed one (AGL-2303). */\n name?: string\n /** `signup`, `booking`, `form:{formId}` — the surface that produced it. */\n source: string\n /**\n * Explicit marketing opt-in, with a consent timestamp — the same shape\n * `upsertHostContact` already carries (AGL-301). Omitted or `false` writes\n * nothing: a lead is a side effect of an action the visitor DID take\n * (sign up, book), which is not by itself consent to be emailed\n * marketing, so this is only set when the caller captured an explicit\n * checkbox.\n */\n marketingConsent?: boolean\n /**\n * The `consentGroupDisclosureKey` the capture surface rendered beside that\n * checkbox (AGL-3320). The grant pools across the site's consent group only\n * when it is the group's current key, and is this site's alone otherwise —\n * the same rule `upsertHostContact` applies.\n */\n disclosedConsentGroup?: string | null\n}\n\n/**\n * Record one lead at `hosts/{hostId}/leads/{personKey}`, bounded by\n * `LEADS_MAX_PER_HOST` (AGL-1529).\n *\n * ## One person is one document\n *\n * `docs/specs/reusable-forms.md` §4b. This used to be `tx.create(ref.doc())`\n * — an auto-id per capture event — so one returning customer who signed up\n * and booked twice was three \"leads\". The Members & leads tab presented a\n * list of events as a list of people, and the only thing holding two rows for\n * one person together was string equality on the address at render time.\n *\n * The events are not lost, they are just no longer the record: `sources`\n * carries every surface that produced a capture, `submissionCount` counts\n * them, and `firstSeenAtMs`/`lastSeenAtMs` bracket them. The submissions, the\n * bookings and the member document are still there and still one row each.\n *\n * The id is {@link personKey} — `sha256(normalizeContactEmail(email))`, the\n * SAME derivation a list membership's `memberKey` uses, imported rather than\n * restated. Two specs named this function and the rule both stated is that\n * whichever ships second imports the first's helper: a second copy is how\n * `emailSuppressionKey` and `suppressionId` came to disagree.\n *\n * ## Why every lead writer goes through here\n *\n * There are three lead writes in the repo — the sign-up handler and the two\n * bookings paths — and all three were `hostRef.collection('leads').add(…)`\n * with a `.catch()` on the end. A cap enforced at two of three call sites is\n * not a cap, and the fourth writer somebody adds next month would not have\n * one either. One function is the only shape that survives that.\n *\n * ## The count is evaluated INSIDE the transaction that writes\n *\n * A create-time quota can be laundered by WHEN it is evaluated, not by the\n * counting rule (AGL-2231/2265/2266). Read-then-decide-then-`add()` lets N\n * concurrent visitors each read the same pre-count, each find room, and each\n * land — and nothing re-counts afterwards, so the extra rows are permanent.\n * `Transaction.get(AggregateQuery)` serialises the count against a concurrent\n * create into the same collection: the loser retries, re-reads the higher\n * count, and is refused. The count is of LIVE documents, so triaging leads in\n * the inbox frees the slots.\n *\n * ## Refusing a lead never fails the visitor's action\n *\n * A lead is a SIDE EFFECT — of a sign-up, or of a booking. The visitor did\n * not ask for it and cannot see it, so refusing one must not refuse the thing\n * they did ask for. This returns a boolean and never throws; the trip is\n * recorded and the caller carries on. That is the honest split, and it is why\n * `SITE_MEMBERS_MAX_PER_HOST` (which governs an action a visitor DID take) is\n * enforced with a 429 in the sign-up handler and this is not.\n */\nexport async function addHostLeadOutcome(options: {\n hostRef: FirebaseFirestore.DocumentReference\n hostId: string\n lead: HostLeadInput\n /**\n * The ceiling to compare against. Defaults to `LEADS_MAX_PER_HOST` and is\n * passed by NOTHING in production — it exists so the suite can re-drive the\n * same count against a ceiling one higher and require the write to succeed.\n * A refusal that survives its own ceiling being raised was never that\n * ceiling's refusal, and there is exactly ONE comparison below, so knifing\n * it cannot be absorbed by a fallback branch.\n */\n ceiling?: number\n /**\n * The campaign this person came from, already resolved by the door.\n *\n * Resolved rather than raw, and passed rather than looked up, because one\n * visitor action reaches several writers: a form submission that creates a\n * submission, a contact AND a lead must pay for the touch lookup once. A\n * door that hands none — every order path, every import — attributes\n * nothing, which is how a lead that no campaign caused stays uncredited.\n */\n touch?: ResolvedCampaignTouch | null\n}): Promise<HostLeadOutcome> {\n const { hostRef, hostId, lead } = options\n const maxPerHost = options.ceiling ?? LEADS_MAX_PER_HOST\n try {\n const leadsRef = await orgLeadsForHost(hostId)\n const firestore = hostRef.firestore\n /*\n * `null` for anything that is not a usable address — a lead captured\n * against a malformed one keeps an auto-id and stays its own row. Keying\n * several unusable addresses under one guessed id would merge two\n * different people, which is worse than two rows for one.\n */\n const key = personKey(lead.email)\n /*\n * A lead the org has not taken over yet is carried across BEFORE the\n * transaction opens (AGL-3275). It has to happen outside: the carry is\n * itself a read-then-write, and a transaction that also counted the\n * collection would be reading a row it was about to create. Two doors\n * carrying the same person in the same second both write the legacy\n * fields under `merge`, so the race is a no-op rather than a conflict.\n */\n const leadRef = key\n ? (await leadForWrite(hostId, key)).ref\n : leadsRef.doc()\n /*\n * The scope this capture stamps, and the group whose terms its consent is\n * recorded under. Both resolved HERE rather than in the transaction body:\n * a contended transaction re-runs that body, and neither of these can\n * change between attempts, so resolving them inside would pay for the org\n * read again on every retry.\n */\n const scope = await leadScopeForHost(hostId)\n const group = await consentGroupForSite(hostId)\n const now = Date.now()\n const seen = {\n // `arrayUnion`, so a person who books twice has `['booking']` and one\n // who signed up and then submitted a form has both. Bounded by the\n // number of surfaces, not by the number of captures.\n sources: FieldValue.arrayUnion(lead.source),\n lastSeenAtMs: now,\n submissionCount: FieldValue.increment(1),\n ...(lead.name ? { name: lead.name } : {}),\n }\n let created = false\n let sourceAdded = false\n let predicted = false\n // The count a new person was judged against, for the notice re-arm.\n let usedBeforeCreate: number | null = null\n const refused = await firestore.runTransaction(async (tx) => {\n // Reset per attempt: a contended transaction re-runs its body, and a\n // flag left standing from an aborted attempt would credit a campaign\n // with a person who turned out to exist already.\n created = false\n sourceAdded = false\n usedBeforeCreate = null\n // ALL READS BEFORE THE WRITE, which Firestore requires.\n const existing = await tx.get(leadRef)\n // Whether the lead already reads \"Would bounce\", for the check below.\n predicted = (existing.data() ?? {})['emailStatus'] === 'undeliverable'\n /*\n * ⛔ THE CEILING GATES A NEW PERSON, NEVER AN EXISTING ONE.\n *\n * A returning visitor's capture is an UPDATE — it does not grow the\n * collection, so refusing it buys no capacity and costs the customer\n * the source and the timestamp they would have learned. That is the\n * enforcement-at-use shape the capacity rule exists to forbid: a limit\n * must refuse the addition, never a person already recorded or the\n * data attached to them.\n *\n * It also means the count is only paid on a genuinely new person,\n * which is the case that can move it.\n */\n if (!existing.exists) {\n /*\n * COUNTED OVER WHAT THIS SITE MAY SEE, not over the org (AGL-3275).\n *\n * The collection is org-wide now, and `LEADS_MAX_PER_HOST` is a\n * per-SITE ceiling. An unfiltered count would charge every brand in\n * an agency's account for every other brand's leads, and the first\n * client to fill its allowance would refuse captures on sites that\n * had taken none. `scopedToHost` is the same narrowing every other\n * org-owned read uses, so a single-site org counts exactly what it\n * counted before this moved.\n *\n * A lead shared by two brands in one consent group is counted by\n * both, which is the honest answer: each of them holds it.\n */\n const used = (\n await tx.get(scopedToHost(leadsRef, hostId).count())\n ).data().count\n if (checkVisitorRecordCeiling(used, maxPerHost).exceeded) return true\n usedBeforeCreate = used\n }\n /*\n * Consent is carried forward and never cleared.\n *\n * A basis is written only when this capture carried an explicit\n * opt-in, so a later booking by someone who did not tick the box\n * leaves an earlier grant standing — absent-or-granted, the shape\n * every other writer uses. The TIMESTAMP is carried over rather than\n * restamped, for the reason given at the read below.\n */\n /*\n * The EARLIEST grant is the one that happened, so a later capture\n * carrying the same checkbox keeps the original date rather than\n * restamping when this person opted in. Read back through the shared\n * reader so \"already consented\" means the same thing here as it does\n * at send time.\n */\n /*\n * THE SITE'S REAL GROUP, because the silo that justified a group of one\n * is gone (AGL-3275).\n *\n * This read `soloConsentGroup(hostId)` for as long as a lead lived at\n * `hosts/{hostId}/leads`, and the reason was the path: private by\n * construction, so pooling a lead's basis would have recorded a\n * disclosure that reached nothing, while the contact written by the\n * same capture door was org-shared and pooled.\n *\n * A lead is now org-shared on exactly the terms the contact is, so the\n * premise is false and keeping the group of one would leave a\n * multi-brand org holding a lead its sibling brand can SEE but may not\n * MAIL — a narrower basis than the one `consentGroupDisclosure` named\n * beside the checkbox this person ticked. Pooling here records what\n * they were actually told, and only that: the grant reaches the group\n * when the capture surface sent back the group's current disclosure\n * key (AGL-3320), and this site alone otherwise. An undeclared group is\n * still a group of one, so an agency is unchanged and configures\n * nothing.\n *\n * Resolved above the transaction — see `group`'s declaration.\n */\n const prior = readMarketingBasis(existing.data() ?? null, group)\n const consentAtMs =\n prior.basis === 'granted' && prior.basisAtMs !== null\n ? prior.basisAtMs\n : now\n created = !existing.exists\n /*\n * Whether this capture puts its surface on the person for the FIRST\n * time: a new lead, or one first met through another door. A form\n * counts its leads by this (AGL-3330) — the people it filed, which is\n * what a recount of `sources` finds — so a returning visitor's second\n * submission moves no form's lead count.\n */\n const storedSources = (existing.data() ?? {})['sources']\n sourceAdded =\n created || !(Array.isArray(storedSources) && storedSources.includes(lead.source))\n /*\n * THE FIELDS THE LEADS LIST QUERIES (AGL-3321), from the lead as it\n * will stand: what the transaction read, the address and name this\n * capture brings and the scope it widens. A new lead is written\n * `status: 'new'`, so the Open view's `status in [new, working]`\n * finds a lead nobody has touched — an absent field matches no query.\n */\n const stored = existing.data() ?? {}\n const storedScope = Array.isArray(stored['visibleTo']) ? (stored['visibleTo'] as unknown[]) : []\n const listFields = crmLeadListFields({\n ...stored,\n email: lead.email,\n ...(lead.name ? { name: lead.name } : {}),\n visibleTo: [...new Set([...storedScope, ...scope])],\n })\n tx.set(\n leadRef,\n {\n email: lead.email,\n ...seen,\n ...listFields,\n /*\n * WIDENED BY THE CAPTURE, NEVER BY THE LOOKUP (AGL-3275).\n *\n * `arrayUnion` rather than a replace, exactly as `upsert-contact`\n * stamps a contact: this site just collected this person, so it may\n * see the row. A site that merely FOUND them — the unscoped dedupe\n * lookup in `readLeadForHost` — gains nothing, which is what keeps\n * an agency's clients apart on a record they share.\n */\n visibleTo: FieldValue.arrayUnion(...scope),\n /*\n * EVERY SITE THAT CAPTURED THIS PERSON, not just the first\n * (AGL-3275).\n *\n * This was written once, on create, and that was sound while the\n * collection sat under one host and could hold only that host's\n * name. On the org the row is shared, so a sibling brand capturing\n * a person the first brand already holds has to be recorded here or\n * the \"Known by\" answer silently omits it — the same `arrayUnion`\n * the contact door has always used for this field, now that a lead\n * has the same question to answer.\n */\n [CAPTURED_BY_HOST_FIELD]: FieldValue.arrayUnion(hostId),\n ...(existing.exists\n ? {}\n : {\n firstSeenAtMs: now,\n createdAt: FieldValue.serverTimestamp(),\n }),\n /*\n * The basis is recorded under the GROUP, keyed by its host, because\n * {@link readMarketingBasis} is one function over four silos and a\n * silo whose basis lived somewhere else would need the reader to\n * know which collection it was handed. A reader that has to be told\n * the shape is a reader that can be told the wrong one.\n */\n ...(lead.marketingConsent\n ? marketingConsentFieldsForGroup(\n consentGroupForGrant(group, lead.disclosedConsentGroup),\n consentAtMs,\n )\n : {}),\n },\n { merge: true },\n )\n return false\n })\n if (refused) {\n await recordVisitorRecordCeilingTrip({\n hostRef,\n hostId,\n kind: 'leads',\n ceiling: maxPerHost,\n })\n return NOT_STORED\n }\n if (created && usedBeforeCreate !== null) {\n await rearmVisitorRecordCeilingNotice({\n hostRef,\n kind: 'leads',\n used: usedBeforeCreate,\n ceiling: maxPerHost,\n })\n }\n /*\n * ATTRIBUTED ON CREATION ONLY.\n *\n * A returning visitor's capture is an update — the campaign did not\n * produce a lead, it produced another visit by a person the site already\n * held — and crediting it would let whichever campaign ran most recently\n * re-earn every lead on the list. `created` is set inside the transaction\n * that decides it, so the attribution and the write agree about whether\n * this person is new.\n *\n * Awaited rather than fired off: `addHostLead` already returns only after\n * its own write, and a caller that `void`s it (every one of them) is\n * unaffected. Never throws, so a failure here cannot cost the lead.\n */\n /*\n * THE ADDRESS'S DOMAIN, checked after the response (AGL-3328): a lead\n * whose domain takes no mail reads \"Would bounce\". Queued, never\n * awaited — the write above is the capture, and this costs it nothing.\n */\n scheduleCapturedEmailCheck({\n orgId: leadsRef.parent?.id ?? null,\n hostId,\n email: lead.email,\n predicted,\n })\n if (created && options.touch) {\n await attributeCampaignConversion({\n hostId,\n kind: 'lead',\n refId: leadRef.id,\n touch: options.touch,\n convertedAtMs: now,\n })\n }\n return { stored: true, created, sourceAdded }\n } catch (error) {\n // Same posture the three original call sites had (`.catch(() => undefined)`\n // / `.catch(console.error)`): a lead that failed to store must not fail\n // the sign-up or the booking that produced it.\n console.error('lead write failed', error)\n return NOT_STORED\n }\n}\n\n/** What {@link addHostLeadOutcome} did with one capture. */\nexport interface HostLeadOutcome {\n /** Whether the lead was written — false when refused or failed. */\n stored: boolean\n /** Whether it was a new person. */\n created: boolean\n /** Whether this capture's `source` was new on the lead — see the write. */\n sourceAdded: boolean\n}\n\nconst NOT_STORED: HostLeadOutcome = { stored: false, created: false, sourceAdded: false }\n\n/**\n * {@link addHostLeadOutcome}, answering only whether the lead was stored —\n * the question every door but a form's asks.\n */\nexport async function addHostLead(\n options: Parameters<typeof addHostLeadOutcome>[0],\n): Promise<boolean> {\n return (await addHostLeadOutcome(options)).stored\n}\n"],"names":["CAPTURED_BY_HOST_FIELD","checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","marketingConsentFieldsForGroup","personKey","readMarketingBasis","submissionMonthKey","VISITOR_RECORD_NOTICE_SENT_FIELD","visitorRecordAcceptFillsCeiling","visitorRecordCeilingAnnounced","visitorRecordRefusedCounterId","FieldValue","attributeCampaignConversion","notifyHostManagers","scheduleCapturedEmailCheck","firebaseAdmin","consentGroupForSite","orgDataCollectionForHost","scopedToHost","crmLeadListFields","crmReadTokens","crmScopeTokens","consentGroupForGrant","firestore","app","orgLeadsForHost","hostId","orgLeadsQueryForHost","ref","query","leadReadTokensForHost","group","leadScopeForHost","org","readLeadForHost","key","orgRow","doc","get","exists","leadForWrite","existed","carried","recordVisitorRecordCeilingTrip","options","hostRef","kind","ceiling","monthKey","refusedRef","collection","refusedSnapshot","announced","data","now","Date","set","increment","lastRefusedAtMs","merge","leads","type","title","body","toLocaleString","link","error","console","rearmVisitorRecordCeilingNotice","used","addHostLeadOutcome","lead","maxPerHost","leadsRef","email","leadRef","scope","seen","sources","arrayUnion","source","lastSeenAtMs","submissionCount","name","created","sourceAdded","predicted","usedBeforeCreate","refused","runTransaction","tx","existing","count","exceeded","prior","consentAtMs","basis","basisAtMs","storedSources","Array","isArray","includes","stored","storedScope","listFields","visibleTo","Set","firstSeenAtMs","createdAt","serverTimestamp","marketingConsent","disclosedConsentGroup","NOT_STORED","orgId","parent","id","touch","refId","convertedAtMs","addHostLead"],"mappings":"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"}
@@ -36,6 +36,13 @@ export interface HostedPageReviewRequest {
36
36
  * to name the page — never to decide whether it serves.
37
37
  */
38
38
  page?: HostedPageContext | null;
39
+ /**
40
+ * Signals found about this page elsewhere, judged with the page's own
41
+ * (AGL-3451): the daily link re-check passes a link Web Risk has listed
42
+ * since the page went live, so the hold, its row and the young
43
+ * workspace's security hold all come from this one path.
44
+ */
45
+ extraSignals?: readonly PhishingScreenSignal[];
39
46
  }
40
47
  export type HostedPageReviewOutcome = {
41
48
  outcome: 'serve';
@@ -53,6 +60,33 @@ export declare function resetHostedPageReviewMemoForTests(): void;
53
60
  * served. See the module header.
54
61
  */
55
62
  export declare function reviewHostedPage(request: HostedPageReviewRequest): Promise<HostedPageReviewOutcome>;
63
+ export interface SiteRedirectReviewRequest {
64
+ hostId: string;
65
+ /** The rule's document id under `hosts/{hostId}/redirects`. */
66
+ ruleId: string;
67
+ /** The path, or the pattern, the rule answers. */
68
+ source: string;
69
+ /** Where it sends the visitor, after any `$n` substitution. */
70
+ destination: string;
71
+ /** The host and org documents, when the caller already holds them. */
72
+ host?: Record<string, unknown> | null;
73
+ org?: Record<string, unknown> | null;
74
+ orgId?: string | null;
75
+ nowMs?: number;
76
+ }
77
+ /** The review row's id for a redirect rule and the signals that held. */
78
+ export declare function redirectReviewId(hostId: string, ruleId: string, signals: readonly PhishingScreenSignal[]): string;
79
+ /**
80
+ * Screen one redirect rule before it sends a visitor off the site
81
+ * (AGL-3447), and say whether it may fire. See the module header.
82
+ *
83
+ * Every workspace is screened and the tiers decide, as for a page: a
84
+ * destination that wears a brand holds for all of them, a lure path sent
85
+ * off the site only for a workspace in its first fortnight. A held rule is
86
+ * filed once per (rule, signal set) and decided by staff on that row:
87
+ * dismissed, it fires; actioned, it never does with those signals.
88
+ */
89
+ export declare function reviewSiteRedirect(request: SiteRedirectReviewRequest): Promise<HostedPageReviewOutcome>;
56
90
  /**
57
91
  * The version this page last served clean, or null. A HINT, not a
58
92
  * clearance: the caller composes and screens it again before serving it,
@@ -51,6 +51,9 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
51
51
  * site's cache and the next render serves it) or mark it actioned to
52
52
  * REJECT (it stays unserved; any later version carrying the same signals
53
53
  * is refused without a new row).
54
+ * - A hold from a workspace in its first fortnight also asks for an
55
+ * automatic `security` hold on the workspace, the site and the publishing
56
+ * account (`page-security-hold.ts`, AGL-3450), which the console places.
54
57
  * - Keyed on the SIGNALS, as the send seam's rows are: a composed page
55
58
  * carries per-render values (dates, collection rows) that no content hash
56
59
  * would survive, while what makes it phishing does not change between
@@ -58,26 +61,48 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
58
61
  *
59
62
  * ## Tiers
60
63
  *
61
- * `signalsThatHold`: a lookalike link or embed and an author-defined
62
- * credential field hold for EVERY workspace; a brand's call to action only
63
- * for a workspace in its first fortnight. The workspace's documents are read
64
- * only when the pure screen has found something, so a clean page costs no
65
- * read for this.
64
+ * `signalsThatHold`: a lookalike link or embed, an author-defined
65
+ * credential field and a link to a host Google Web Risk lists
66
+ * (`web-risk-link`, AGL-3451) hold for EVERY workspace; a brand's call to
67
+ * action or lure, and a call to action that leaves the site beside a lure,
68
+ * only for a workspace in its first fortnight. The workspace's documents are
69
+ * read only when something was found, so a clean page costs no read for
70
+ * this. The page's foreign hosts are looked up first, against a cache
71
+ * (`web-risk.ts`), and noted beside the version it serves so the daily
72
+ * re-check can look again: a harvester is often listed only after it is
73
+ * published. A lookup that fails finds nothing, so it never holds a page.
66
74
  *
67
- * Fails CLOSED, unlike the send path. Composition itself reads the store, so
68
- * an outage here is an outage on the render already, and the only cost of
69
- * not serving a page the screen flagged is a false positive's wait; serving
70
- * it is the phishing page this exists to stop.
75
+ * Fails CLOSED, unlike the send path: a page the pure screen flagged whose
76
+ * review cannot finish — the workspace read, the row write — is answered
77
+ * `held`, and the composer serves its last clean version or nothing. The
78
+ * answer is not remembered, so the next render reviews it again. Composition
79
+ * itself reads the store, so an outage here is an outage on the render
80
+ * already, and the only cost of not serving a page the screen flagged is a
81
+ * false positive's wait; serving it is the phishing page this exists to
82
+ * stop. A page the screen found nothing on never reaches this.
83
+ *
84
+ * ## Site redirects
85
+ *
86
+ * A redirect rule that sends a path off the site is a page whose one call to
87
+ * action fires on arrival, so the redirects plugin asks
88
+ * {@link reviewSiteRedirect} before it serves one. Same screen
89
+ * (`screenSiteRedirect`), same tiers, same row and the same staff decision:
90
+ * a held rule does not fire, and is filed as a `page` hold on its rule's
91
+ * document. Its destination is looked up against the reputation list too
92
+ * (AGL-3451), so a redirect to a listed site holds for every workspace.
71
93
  *=========================================*/ import { ABUSE_REPORT_COLLECTION } from "@aglyn/aglyn/app-utils/abuse-report";
72
94
  import { heldPageConsolePath, heldPageDetails, heldPageLabel, heldPageVisitorSentence } from "@aglyn/shared-util-email/held-page";
73
- import { screenHostedPage } from "@aglyn/shared-util-email/hosted-page-screen";
95
+ import { screenHostedPage, screenSiteRedirect } from "@aglyn/shared-util-email/hosted-page-screen";
74
96
  import { lookalikeBrandForHost, phishingScreenBrandLabel, describePhishingScreenSignals, isYoungWorkspaceAge, signalsThatHold } from "@aglyn/shared-util-email/outbound-phishing-screen";
97
+ import { foreignHostsForReputation, webRiskSignals } from "@aglyn/shared-util-email/link-reputation";
75
98
  import { FieldValue } from "firebase-admin/firestore";
76
99
  import firebaseAdmin from "./firebase-admin.js";
77
100
  import { describeHeldPage } from "./held-page-subject.js";
78
101
  import { notifyRiskEvent } from "./risk-notice.js";
79
102
  import { orgAgeDays } from "./org-age.js";
103
+ import { requestPageSecurityHold } from "./page-security-hold.js";
80
104
  import { getHostDocAdmin, getOrgForHost } from "./organizations.js";
105
+ import { lookupHostReputation, platformReputationExclusions } from "./web-risk.js";
81
106
  import { canonicalJson, fileOutboundHold, flaggedHostOf, heldOutboundReference, heldOutboundReviewId, outboundContentHash, workspaceScreenIdentity } from "./outbound-send-review.js";
82
107
  /** Where the last version a page served clean is noted: a hint, re-screened before use. */ export const PAGE_REVIEW_SUBCOLLECTION = 'pageReviews';
83
108
  /** The review row's id for a page and the signals that held. */ export function pageReviewId(hostId, screenId, signals) {
@@ -91,10 +116,53 @@ const pageMemo = new Map();
91
116
  /** The version each page last served clean, as this process last wrote it. */ const servedMemo = new Map();
92
117
  /** Lookalike hosts this process already flagged for an established page, and when. */ const flaggedMemo = new Map();
93
118
  const FLAGGED_MEMO_TTL_MS = 60 * 60000;
119
+ /**
120
+ * The foreign hosts each reviewed page version links to (AGL-3451), for
121
+ * {@link recordServedPageVersion} to note beside the version it served, which
122
+ * is what the daily link re-check walks. Bounded: a process that reviews more
123
+ * pages than this starts again.
124
+ */ const reviewedForeignHosts = new Map();
125
+ const REVIEWED_FOREIGN_HOSTS_MAX = 10000;
94
126
  /** Test seam: forget what this process learned. */ export function resetHostedPageReviewMemoForTests() {
95
127
  pageMemo.clear();
96
128
  servedMemo.clear();
97
129
  flaggedMemo.clear();
130
+ reviewedForeignHosts.clear();
131
+ }
132
+ /**
133
+ * Every foreign host the page links to, and the ones Google Web Risk lists
134
+ * (AGL-3451). Foreign before the workspace is known: not the platform's
135
+ * own, not a brand's own domain, not a common social link. Never throws; a
136
+ * lookup that failed lists nothing.
137
+ */ async function pageLinkReputation(linkHosts) {
138
+ // Runs before the review's fail-closed `try`, so it must not throw: an
139
+ // answer it cannot give is no listing, never a reason to hold.
140
+ try {
141
+ const hosts = foreignHostsForReputation(linkHosts, {
142
+ excludeDomains: platformReputationExclusions()
143
+ });
144
+ if (!hosts.length) return {
145
+ hosts,
146
+ signals: []
147
+ };
148
+ const answer = await lookupHostReputation(hosts);
149
+ return {
150
+ hosts,
151
+ signals: webRiskSignals(answer.hits)
152
+ };
153
+ } catch (error) {
154
+ console.warn('[page-review] link reputation could not be read — no listing assumed', error);
155
+ return {
156
+ hosts: [],
157
+ signals: []
158
+ };
159
+ }
160
+ }
161
+ /** Is this host one of the workspace's own? */ function isOwnHost(host, ownDomains) {
162
+ return (ownDomains != null ? ownDomains : []).some((own)=>{
163
+ const domain = String(own != null ? own : '').trim().toLowerCase();
164
+ return Boolean(domain) && (host === domain || host.endsWith(`.${domain}`));
165
+ });
98
166
  }
99
167
  /**
100
168
  * The subject a row already stored, or a fresh description (AGL-3374). A row
@@ -118,11 +186,21 @@ const FLAGGED_MEMO_TTL_MS = 60 * 60000;
118
186
  * Screen one page as a visitor would receive it, and say whether it may be
119
187
  * served. See the module header.
120
188
  */ export async function reviewHostedPage(request) {
121
- var _request_nowMs;
189
+ var _request_extraSignals, _request_nowMs;
122
190
  const found = screenHostedPage({
123
191
  nodes: request.nodes
124
192
  });
125
- if (!found.signals.length) return {
193
+ // Every foreign host it points at, against the reputation list
194
+ // (AGL-3451): a harvester known to be bad anywhere holds however plain the
195
+ // page around it is. Strong, so it holds at any workspace age.
196
+ const reputation = await pageLinkReputation(found.linkHosts);
197
+ if (reviewedForeignHosts.size >= REVIEWED_FOREIGN_HOSTS_MAX) reviewedForeignHosts.clear();
198
+ reviewedForeignHosts.set(`${request.hostId}/${request.screenId}/${request.versionId}`, reputation.hosts);
199
+ const listed = [
200
+ ...reputation.signals,
201
+ ...(_request_extraSignals = request.extraSignals) != null ? _request_extraSignals : []
202
+ ];
203
+ if (!found.signals.length && !listed.length) return {
126
204
  outcome: 'serve'
127
205
  };
128
206
  const nowMs = (_request_nowMs = request.nowMs) != null ? _request_nowMs : Date.now();
@@ -146,6 +224,11 @@ const FLAGGED_MEMO_TTL_MS = 60 * 60000;
146
224
  const verdict = screenHostedPage(_extends({
147
225
  nodes: request.nodes
148
226
  }, identity));
227
+ // A listing of the workspace's own host is not a link away from it.
228
+ const screened = [
229
+ ...verdict.signals,
230
+ ...listed.filter((signal)=>!('host' in signal) || !isOwnHost(signal.host, identity.ownDomains))
231
+ ];
149
232
  const ageDays = orgAgeDays(org == null ? void 0 : org.createdAt, nowMs);
150
233
  // Every workspace is screened, however old: an established account can
151
234
  // be compromised, and a compromised account publishing a lure is the
@@ -160,7 +243,7 @@ const FLAGGED_MEMO_TTL_MS = 60 * 60000;
160
243
  // - content ALREADY LIVE keeps serving, and is flagged to staff as
161
244
  // urgent, who lock the site if it is impersonation.
162
245
  if (!isYoungWorkspaceAge(ageDays)) {
163
- const strong = signalsThatHold(verdict.signals, {
246
+ const strong = signalsThatHold(screened, {
164
247
  ageDays
165
248
  });
166
249
  if (!strong.length) return {
@@ -184,7 +267,7 @@ const FLAGGED_MEMO_TTL_MS = 60 * 60000;
184
267
  };
185
268
  }
186
269
  }
187
- const signals = signalsThatHold(verdict.signals, {
270
+ const signals = signalsThatHold(screened, {
188
271
  ageDays
189
272
  });
190
273
  if (!signals.length) return {
@@ -258,6 +341,25 @@ const FLAGGED_MEMO_TTL_MS = 60 * 60000;
258
341
  url,
259
342
  reportedHostname: flagged
260
343
  });
344
+ // A hold from a workspace in its first fortnight also holds the
345
+ // workspace, the site and the account that published it (AGL-3450).
346
+ if (state === 'held') {
347
+ await requestPageSecurityHold({
348
+ orgId: orgId != null ? orgId : '',
349
+ org,
350
+ hostId: request.hostId,
351
+ screenId: request.screenId,
352
+ versionId: request.versionId,
353
+ reviewId,
354
+ reference,
355
+ signals,
356
+ ageDays,
357
+ pageLabel: label,
358
+ pageUrl: url,
359
+ siteName: typeof (host == null ? void 0 : host['name']) === 'string' ? host['name'] : null,
360
+ nowMs
361
+ });
362
+ }
261
363
  pageMemo.set(reviewId, {
262
364
  state,
263
365
  atMs: nowMs
@@ -270,11 +372,147 @@ const FLAGGED_MEMO_TTL_MS = 60 * 60000;
270
372
  reference
271
373
  };
272
374
  } catch (error) {
273
- // Open: a review that could not run must not take a live page down —
274
- // the screen failing is not evidence about the page.
275
- console.error('[page-review] a flagged page could not be reviewed — serving it', error);
375
+ // Closed: the screen has already found a signal on this page, and a
376
+ // review that could not finish has not cleared it. Not remembered, so
377
+ // the next render reviews it again.
378
+ console.error('[page-review] a flagged page could not be reviewed — holding it', error);
379
+ const reviewId = pageReviewId(request.hostId, request.screenId, [
380
+ ...found.signals,
381
+ ...listed
382
+ ]);
276
383
  return {
384
+ outcome: 'held',
385
+ reviewId,
386
+ reference: heldOutboundReference(reviewId)
387
+ };
388
+ }
389
+ }
390
+ /** The review row's id for a redirect rule and the signals that held. */ export function redirectReviewId(hostId, ruleId, signals) {
391
+ const key = signals.map((signal)=>canonicalJson(signal)).sort().join('\n');
392
+ return heldOutboundReviewId(`hosts/${hostId}/redirects/${ruleId}`, outboundContentHash([
393
+ key
394
+ ]));
395
+ }
396
+ /**
397
+ * Screen one redirect rule before it sends a visitor off the site
398
+ * (AGL-3447), and say whether it may fire. See the module header.
399
+ *
400
+ * Every workspace is screened and the tiers decide, as for a page: a
401
+ * destination that wears a brand holds for all of them, a lure path sent
402
+ * off the site only for a workspace in its first fortnight. A held rule is
403
+ * filed once per (rule, signal set) and decided by staff on that row:
404
+ * dismissed, it fires; actioned, it never does with those signals.
405
+ */ export async function reviewSiteRedirect(request) {
406
+ var _request_nowMs;
407
+ const found = screenSiteRedirect({
408
+ source: request.source,
409
+ destination: request.destination
410
+ });
411
+ // Where it sends visitors, against the reputation list (AGL-3451): a
412
+ // listed destination holds for every workspace, as a listed link does.
413
+ const reputation = await pageLinkReputation(found.linkHosts);
414
+ if (!found.signals.length && !reputation.signals.length) return {
415
+ outcome: 'serve'
416
+ };
417
+ const nowMs = (_request_nowMs = request.nowMs) != null ? _request_nowMs : Date.now();
418
+ try {
419
+ var _request_orgId, _ref, _ref1, _identity_ownDomains;
420
+ const [host, owner] = await Promise.all([
421
+ request.host !== undefined ? request.host : getHostDocAdmin(request.hostId),
422
+ request.org !== undefined ? {
423
+ orgId: (_request_orgId = request.orgId) != null ? _request_orgId : null,
424
+ org: request.org
425
+ } : getOrgForHost(request.hostId)
426
+ ]);
427
+ const org = (_ref = owner == null ? void 0 : owner.org) != null ? _ref : null;
428
+ const orgId = (_ref1 = owner == null ? void 0 : owner.orgId) != null ? _ref1 : null;
429
+ const identity = workspaceScreenIdentity({
430
+ org,
431
+ host
432
+ });
433
+ const verdict = screenSiteRedirect(_extends({
434
+ source: request.source,
435
+ destination: request.destination
436
+ }, identity));
437
+ const screened = [
438
+ ...verdict.signals,
439
+ ...reputation.signals.filter((signal)=>!('host' in signal) || !isOwnHost(signal.host, identity.ownDomains))
440
+ ];
441
+ const ageDays = orgAgeDays(org == null ? void 0 : org.createdAt, nowMs);
442
+ const signals = signalsThatHold(screened, {
443
+ ageDays
444
+ });
445
+ if (!signals.length) return {
446
+ outcome: 'serve'
447
+ };
448
+ const reviewId = redirectReviewId(request.hostId, request.ruleId, signals);
449
+ const reference = heldOutboundReference(reviewId);
450
+ const memo = pageMemo.get(reviewId);
451
+ if (memo && nowMs - memo.atMs < PAGE_MEMO_TTL_MS) {
452
+ return memo.state === 'released' ? {
453
+ outcome: 'serve'
454
+ } : {
455
+ outcome: memo.state === 'rejected' ? 'rejected' : 'held',
456
+ reviewId,
457
+ reference
458
+ };
459
+ }
460
+ const source = request.source.slice(0, 200);
461
+ const destination = request.destination.slice(0, 500);
462
+ const label = `the redirect from ${source}`;
463
+ const siteName = typeof (host == null ? void 0 : host['name']) === 'string' && host['name'] ? `"${host['name']}"` : 'a site';
464
+ const [subdomainHost, customDomain] = (_identity_ownDomains = identity.ownDomains) != null ? _identity_ownDomains : [];
465
+ const siteHost = customDomain || subdomainHost;
466
+ const state = await fileOutboundHold({
467
+ reviewId,
468
+ heldSend: {
469
+ kind: 'page',
470
+ path: `hosts/${request.hostId}/redirects/${request.ruleId}`,
471
+ hostId: request.hostId,
472
+ orgId,
473
+ contentHash: outboundContentHash([
474
+ signals
475
+ ]),
476
+ subject: label.slice(0, 300),
477
+ fromName: null,
478
+ signals,
479
+ state: 'held',
480
+ heldAtMs: nowMs,
481
+ ageDays
482
+ },
483
+ headline: `Held ${label} of ${siteName}, which sends visitors to ${destination}, from a ` + `workspace ${ageDays != null ? ageDays : '?'} day(s) old. The rule does not fire until this is decided.`,
484
+ alertTitle: 'Site redirect held for review — possible phishing',
485
+ alertBody: `The redirect from ${source} was held before it sent anyone off the site.`,
486
+ // Where the owners fix it: the site's Redirects page.
487
+ item: {
488
+ label,
489
+ path: `/${request.hostId}/redirects`
490
+ },
491
+ url: siteHost && source.startsWith('/') ? `https://${siteHost}${source}` : destination,
492
+ reportedHostname: flaggedHostOf(signals)
493
+ });
494
+ pageMemo.set(reviewId, {
495
+ state,
496
+ atMs: nowMs
497
+ });
498
+ return state === 'released' ? {
277
499
  outcome: 'serve'
500
+ } : {
501
+ outcome: state === 'rejected' ? 'rejected' : 'held',
502
+ reviewId,
503
+ reference
504
+ };
505
+ } catch (error) {
506
+ // Closed, as a page is: the screen found a signal and nothing cleared it.
507
+ console.error('[page-review] a flagged redirect could not be reviewed — holding it', error);
508
+ const reviewId = redirectReviewId(request.hostId, request.ruleId, [
509
+ ...found.signals,
510
+ ...reputation.signals
511
+ ]);
512
+ return {
513
+ outcome: 'held',
514
+ reviewId,
515
+ reference: heldOutboundReference(reviewId)
278
516
  };
279
517
  }
280
518
  }
@@ -412,19 +650,29 @@ async function isAlreadyLive(hostId, screenId, versionId, nowMs) {
412
650
  * after that, however often the page is rendered.
413
651
  */ export async function recordServedPageVersion(hostId, screenId, versionId) {
414
652
  const key = `${hostId}/${screenId}`;
415
- if (!versionId || servedMemo.get(key) === versionId) return;
653
+ // The foreign hosts this version links to, as its review found them
654
+ // (AGL-3451): noted beside it so the daily link re-check can walk them.
655
+ const foreignHosts = reviewedForeignHosts.get(`${key}/${versionId}`);
656
+ const memoValue = foreignHosts ? `${versionId}|${foreignHosts.join(',')}` : versionId;
657
+ if (!versionId || servedMemo.get(key) === memoValue) return;
416
658
  try {
417
659
  const ref = pageReviewRef(hostId, screenId);
418
660
  const snapshot = await ref.get();
419
- if (snapshot.get('servedVersionId') !== versionId) {
420
- await ref.set({
661
+ const versionMoved = snapshot.get('servedVersionId') !== versionId;
662
+ const stored = snapshot.get('foreignHosts');
663
+ const hostsMoved = foreignHosts !== undefined && (Array.isArray(stored) ? stored.join(',') : '') !== foreignHosts.join(',');
664
+ if (versionMoved || hostsMoved) {
665
+ await ref.set(_extends({}, versionMoved ? {
421
666
  servedVersionId: versionId,
422
667
  servedAtMs: Date.now()
423
- }, {
668
+ } : {}, foreignHosts ? {
669
+ foreignHosts,
670
+ foreignHostCount: foreignHosts.length
671
+ } : {}), {
424
672
  merge: true
425
673
  });
426
674
  }
427
- servedMemo.set(key, versionId);
675
+ servedMemo.set(key, memoValue);
428
676
  } catch (error) {
429
677
  console.warn('[page-review] the served version could not be noted', error);
430
678
  }