@aglyn/tenant-data-admin 1.0.0-beta.224 → 1.0.0-beta.225

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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/crm-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\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation'\nimport {\n checkCrmEmailQuota,\n checkCrmRecordsQuota,\n CRM_COLLECTIONS,\n CRM_EMAIL_USAGE_COLLECTION,\n type CrmActivityLink,\n crmActivityCeilingLink,\n type CrmEmailQuotaResult,\n crmEmailUsageDayKey,\n type CrmRecordsQuotaResult,\n} from '@aglyn/aglyn/server'\n/*\n * By module path, not the barrel: the capture door's specs substitute a\n * fixture barrel (see `upsert-contact.ts`), and the restamp is reached from\n * the door.\n */\nimport { type CrmListCollection, crmListFieldsPatch } from '@aglyn/aglyn/app-utils/crm'\nimport {\n type EmailRampVerdict,\n rampedDailyAllowance,\n} from '@aglyn/shared-util-email'\nimport { notifyPluginRecordWritten } from '@aglyn/aglyn/plugin-manager/plugin-record-written'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * The CRM RECORDS band's measurement (AGL-2611): the three collections it\n * counts, and their sum.\n *\n * One shape for every server that asks, because the band is enforced at six\n * doors — contact capture, the console's company and deal drawers (through\n * the client twin of this read), the CSV import, the lead conversion and the\n * two REST creates — and the monthly rollup bills from the same three\n * aggregates. A door that counted contacts alone would admit a company on a\n * band the rollup had already found full.\n */\nexport interface CrmRecordsCount {\n contactsCount: number\n companiesCount: number\n dealsCount: number\n /** The band's own figure: the three above, summed. */\n crmRecordsCount: number\n}\n\n/**\n * Count the records band for one org: three `count()` aggregates, in\n * parallel, no document reads.\n *\n * `contacts` may be handed in by a caller that already holds the collection\n * reference — `upsertHostContact` resolves it through `orgDataCollection`\n * before it decides anything — so the read is the same one whichever door\n * it came through, and the org reference is needed only for the two\n * collections the hub added.\n */\nexport async function countCrmRecords(\n orgRef: FirebaseFirestore.DocumentReference,\n contacts: FirebaseFirestore.CollectionReference = orgRef.collection('contacts'),\n): Promise<CrmRecordsCount> {\n const [contactsSnap, companiesSnap, dealsSnap] = await Promise.all([\n contacts.count().get(),\n orgRef.collection(CRM_COLLECTIONS.companies).count().get(),\n orgRef.collection(CRM_COLLECTIONS.deals).count().get(),\n ])\n const contactsCount = aggregateCount(contactsSnap)\n const companiesCount = aggregateCount(companiesSnap)\n const dealsCount = aggregateCount(dealsSnap)\n return {\n contactsCount,\n companiesCount,\n dealsCount,\n crmRecordsCount: contactsCount + companiesCount + dealsCount,\n }\n}\n\n/**\n * The band's verdict for one org, measured now — the read every create\n * door takes before it writes, so the six doors cannot disagree about\n * whether there is room. `checkCrmRecordsQuota` answers from the org doc\n * alone; this pairs it with the measurement.\n */\nexport async function crmRecordsQuotaForOrg(\n org: Partial<AglynOrgBilling> | null | undefined,\n orgRef: FirebaseFirestore.DocumentReference,\n contacts?: FirebaseFirestore.CollectionReference,\n): Promise<CrmRecordsQuotaResult & CrmRecordsCount> {\n const counts = await countCrmRecords(orgRef, contacts)\n return { ...checkCrmRecordsQuota(org, counts.crmRecordsCount), ...counts }\n}\n\n/** `count()` answers through `.data().count`; a missing figure is zero, never NaN. */\nfunction aggregateCount(snapshot: { data(): { count?: unknown } }): number {\n const value = Number(snapshot.data()?.count ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * How many activities one record already carries — one aggregate on a\n * single-field equality, which Firestore indexes on its own, so this needs\n * no composite index and cannot go FAILED_PRECONDITION in production.\n */\nexport async function countCrmActivitiesForRecord(\n orgRef: FirebaseFirestore.DocumentReference,\n link: CrmActivityLink,\n): Promise<number> {\n const lead = crmActivityCeilingLink(link)\n if (!lead) return 0\n const snapshot = await orgRef\n .collection(CRM_COLLECTIONS.activities)\n .where(lead.field, '==', lead.id)\n .count()\n .get()\n return aggregateCount(snapshot)\n}\n\n/**\n * `orgs/{orgId}/crmEmailUsage/{day}` — one UTC day's one-to-one email\n * counter, the document `checkCrmEmailQuota` is enforced against and the\n * billing page reads.\n */\nfunction crmEmailUsageRefForDay(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n day: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_EMAIL_USAGE_COLLECTION)\n .doc(day)\n}\n\n/** Today's counter document — {@link crmEmailUsageRefForDay} keyed by `now`. */\nexport function crmEmailUsageRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n now: Date = new Date(),\n): FirebaseFirestore.DocumentReference {\n return crmEmailUsageRefForDay(firestore, orgId, crmEmailUsageDayKey(now))\n}\n\n/**\n * The counter a day-document holds, clamped.\n *\n * A malformed or absent counter reads as ZERO SENT, which is the permissive\n * direction — deliberately, and the same clamp `checkApiRequestQuota`'s\n * reader applies: a counter that cannot be read must not refuse a rep who\n * has sent nothing, and the cap is a pace rather than a cost.\n */\nfunction crmEmailCount(snapshot: { get(field: string): unknown }): number {\n const value = Number(snapshot.get('count') ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * How many one-to-one emails the org has sent today, for a meter — never\n * for the cap, which {@link reserveCrmEmailSend} judges inside the\n * transaction that also takes the slot.\n */\nexport async function crmEmailsSentToday(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n now: Date = new Date(),\n): Promise<number> {\n return crmEmailCount(await crmEmailUsageRef(firestore, orgId, now).get())\n}\n\n/** One slot on one day's counter, held from the reservation to the send. */\nexport interface CrmEmailSendReservation {\n orgId: string\n /**\n * The UTC day the slot was taken on, so a release after midnight gives\n * the slot back to the day that lent it rather than to the new day.\n */\n day: string\n}\n\nexport type ReserveCrmEmailSendResult =\n | { ok: true; reservation: CrmEmailSendReservation; quota: CrmEmailQuotaResult }\n | { ok: false; quota: CrmEmailQuotaResult }\n\n/**\n * The plan's day, narrowed to the share of it a young workspace has earned.\n *\n * The new-sender ramp reaches the one-to-one path here and only here, so the\n * ceiling the transaction below judges against is one value with one origin.\n * The alternative — a full-allowance reservation with a ramp consulted beside\n * it — is two ceilings for one decision, and under two concurrent senders it\n * admits against whichever of them was read first.\n *\n * `included` is the only field the ramp moves; `resetsAt` is the day's, which\n * a share does not change, and `used` is what the transaction read.\n */\nfunction rampedCrmEmailQuota(\n plan: CrmEmailQuotaResult,\n ramp: EmailRampVerdict | null | undefined,\n): CrmEmailQuotaResult {\n const included = rampedDailyAllowance(plan.included, ramp)\n if (included >= plan.included) return plan\n return {\n ...plan,\n included,\n allowed: plan.used < included,\n remaining: Math.max(0, included - plan.used),\n }\n}\n\n/**\n * Claims ONE one-to-one send against today's cap, ATOMICALLY (AGL-2645).\n *\n * The counter used to be read before the send and incremented after\n * delivery, so two reps sending in the same window both passed the same\n * reading and both sent — `CRM_EMAIL_SENDS_PER_MINUTE` past the cap per\n * sender, in exactly the conditions the cap exists for. **A read-then-write\n * cap is not a cap**, the lesson `reserveCampaignEmailSends` learned for\n * campaigns; this is the same shape on the day counter.\n *\n * The transaction reads the counter, judges it with `checkCrmEmailQuota` —\n * the real plan table, `UNLIMITED` on Enterprise, zero on a tier with no\n * suite — and writes an ABSOLUTE value derived from that read, deliberately\n * not `FieldValue.increment`. Firestore aborts and re-runs the callback\n * when a document the transaction read has moved, so a second sender that\n * starts inside the first one's window re-reads the raised figure and is\n * refused. An increment would be atomic on the number and useless for the\n * decision, because the decision is made from a value the write never\n * proves it still held.\n *\n * A refused reservation writes NOTHING. A taken one is the count the cap\n * enforces and the billing page shows, from the moment it is taken: a send\n * the provider then refuses hands the slot back through\n * {@link releaseCrmEmailSend}, and a process that dies between the two\n * leaves the org one send short for the rest of the UTC day — conservative\n * in the direction a cap cares about, and healed at midnight, because each\n * day is its own document.\n *\n * ## The new-sender ramp rides the same read\n *\n * `ramp` narrows the plan's day to the share of it a young workspace has\n * earned, INSIDE the transaction and before the counter is judged — see\n * {@link rampedCrmEmailQuota}. Applying it here rather than in the caller is\n * the whole of what makes it safe: the ceiling and the count it is compared\n * against come out of one read, so the retry Firestore forces on a losing\n * transaction re-derives both. A ramp checked outside would be a second\n * ceiling, decided from a snapshot the write never proves it still held —\n * the defect this reservation was built to remove, one field over.\n *\n * The ramp is optional and an absent one is not a throttle: a caller that\n * did not resolve one gets the plan's day, which is what every surface but\n * the send path wants.\n */\nexport async function reserveCrmEmailSend(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n org: Partial<AglynOrgBilling> | null | undefined,\n now: Date = new Date(),\n ramp?: EmailRampVerdict | null,\n): Promise<ReserveCrmEmailSendResult> {\n const day = crmEmailUsageDayKey(now)\n const ref = crmEmailUsageRefForDay(firestore, orgId, day)\n return firestore.runTransaction(async (tx) => {\n const quota = rampedCrmEmailQuota(\n checkCrmEmailQuota(org, crmEmailCount(await tx.get(ref)), now),\n ramp,\n )\n if (!quota.allowed) return { ok: false, quota }\n tx.set(\n ref,\n { count: quota.used + 1, day, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n return { ok: true, reservation: { orgId, day }, quota }\n })\n}\n\n/**\n * Gives back a slot the provider did not use.\n *\n * Also a transaction, and also an absolute write from its own read: a\n * decrement computed from a stale figure would undo a slot another rep took\n * in the meantime, which is the same defect one direction over. Never\n * drives the counter below zero.\n *\n * **Never throws.** This runs on the failure path of a send that was\n * already refused, and the answer the rep gets is the provider's refusal; a\n * bookkeeping failure on top of it is logged, and its cost is one slot the\n * org does not get back until midnight — the safe direction.\n */\nexport async function releaseCrmEmailSend(\n firestore: FirebaseFirestore.Firestore,\n reservation: CrmEmailSendReservation | null | undefined,\n): Promise<void> {\n if (!reservation?.orgId || !reservation.day) return\n const ref = crmEmailUsageRefForDay(firestore, reservation.orgId, reservation.day)\n try {\n await firestore.runTransaction(async (tx) => {\n const used = crmEmailCount(await tx.get(ref))\n tx.set(\n ref,\n {\n count: Math.max(0, used - 1),\n day: reservation.day,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.error('crm email reservation release failed', error)\n }\n}\n\n/*==========================================\n * THE LIST FIELDS, RESTAMPED FROM WHAT WAS STORED (AGL-3321).\n *\n * A CRM list queries fields derived from the record — its search tokens, a\n * lead's status and verdict keys, a contact's facet keys (`crmListFields`\n * in `@aglyn/aglyn`). A writer that holds the whole document spreads them;\n * a writer that PATCHED an input — a dotted facet field, an `arrayUnion` of\n * tags, a merged capture — cannot know the result without reading it, so\n * it restamps here after its own write: the record is read back in a\n * transaction and only the list fields it now carries wrongly are written.\n *\n * Never throws. A restamp is a consequence of a write that already landed,\n * and failing the caller would report the write as failed; a record left\n * stale is what the one-time backfill (`backfill-crm-list-fields.mjs`)\n * brings level, and the next write to it restamps it again.\n *=========================================*/\n\nexport interface CrmListFieldsRestamp {\n /** Records whose list fields were written. */\n restamped: number\n /** Records already current. */\n current: number\n /** Records that no longer exist. */\n missing: number\n}\n\n/**\n * Restamp one record's list fields from the record as stored.\n *\n * Then tell the plugins a record was written (AGL-3336): every server writer\n * of an org record passes here after its write, so this is the one place a\n * plugin that acts on the record AFTER any write — the CRM's sharing rules —\n * is told, without an edit to each writer. Not for a record that no longer\n * exists, and after the restamp's own transaction, never inside it.\n */\nexport async function restampCrmListFieldsAt(\n ref: FirebaseFirestore.DocumentReference,\n collection: CrmListCollection,\n): Promise<'restamped' | 'current' | 'missing'> {\n let outcome: 'restamped' | 'current' | 'missing'\n try {\n outcome = await ref.firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(ref)\n if (!snapshot.exists) return 'missing' as const\n const patch = crmListFieldsPatch(collection, snapshot.data() ?? {})\n if (!Object.keys(patch).length) return 'current' as const\n tx.update(ref, patch)\n return 'restamped' as const\n })\n } catch (error) {\n console.error(`[crm] list fields restamp failed for ${ref.path}`, error)\n return 'current'\n }\n if (outcome !== 'missing') {\n await notifyPluginRecordWritten({ path: ref.path, collection })\n }\n return outcome\n}\n\n/** Restamp several records of one collection, a few at a time. */\nexport async function restampCrmListFieldsOf(\n refs: readonly FirebaseFirestore.DocumentReference[],\n collection: CrmListCollection,\n): Promise<CrmListFieldsRestamp> {\n const result: CrmListFieldsRestamp = { restamped: 0, current: 0, missing: 0 }\n const queue = [...new Map(refs.map((ref) => [ref.path, ref])).values()]\n // Each is its own small transaction on its own record.\n const worker = async () => {\n for (let ref = queue.shift(); ref !== undefined; ref = queue.shift()) {\n const outcome = await restampCrmListFieldsAt(ref, collection)\n result[outcome] += 1\n }\n }\n await Promise.all(Array.from({ length: Math.min(RESTAMP_CONCURRENCY, queue.length) }, worker))\n return result\n}\n\n/** Restamp several records of one collection under `orgs/{orgId}/`, by id. */\nexport async function restampCrmListFields(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n collection: CrmListCollection,\n ids: readonly string[],\n): Promise<CrmListFieldsRestamp> {\n const records = firestore.collection('orgs').doc(orgId).collection(collection)\n return restampCrmListFieldsOf(\n [...new Set(ids.filter(Boolean))].map((id) => records.doc(id)),\n collection,\n )\n}\n\n/** Records restamped at once by {@link restampCrmListFields}. */\nconst RESTAMP_CONCURRENCY = 8\n"],"names":["checkCrmEmailQuota","checkCrmRecordsQuota","CRM_COLLECTIONS","CRM_EMAIL_USAGE_COLLECTION","crmActivityCeilingLink","crmEmailUsageDayKey","crmListFieldsPatch","rampedDailyAllowance","notifyPluginRecordWritten","FieldValue","countCrmRecords","orgRef","contacts","collection","contactsSnap","companiesSnap","dealsSnap","Promise","all","count","get","companies","deals","contactsCount","aggregateCount","companiesCount","dealsCount","crmRecordsCount","crmRecordsQuotaForOrg","org","counts","snapshot","value","Number","data","isFinite","Math","floor","countCrmActivitiesForRecord","link","lead","activities","where","field","id","crmEmailUsageRefForDay","firestore","orgId","day","doc","crmEmailUsageRef","now","Date","crmEmailCount","crmEmailsSentToday","rampedCrmEmailQuota","plan","ramp","included","allowed","used","remaining","max","reserveCrmEmailSend","ref","runTransaction","tx","quota","ok","set","updatedAt","serverTimestamp","merge","reservation","releaseCrmEmailSend","error","console","restampCrmListFieldsAt","outcome","exists","patch","Object","keys","length","update","path","restampCrmListFieldsOf","refs","result","restamped","current","missing","queue","Map","map","values","worker","shift","undefined","Array","from","min","RESTAMP_CONCURRENCY","restampCrmListFields","ids","records","Set","filter","Boolean"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/crm-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\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation'\nimport {\n checkCrmEmailQuota,\n checkCrmRecordsQuota,\n CRM_COLLECTIONS,\n CRM_EMAIL_USAGE_COLLECTION,\n type CrmActivityLink,\n crmActivityCeilingLink,\n type CrmEmailQuotaResult,\n crmEmailUsageDayKey,\n type CrmRecordsQuotaResult,\n} from '@aglyn/aglyn/server'\n/*\n * By module path, not the barrel: the capture door's specs substitute a\n * fixture barrel (see `upsert-contact.ts`), and the restamp is reached from\n * the door.\n */\nimport {\n CRM_LEAD_SOURCE_PICKLIST,\n type CrmListCollection,\n type CrmListFieldsContext,\n crmListFieldsPatch,\n effectiveCrmLeadSourcePicklist,\n} from '@aglyn/aglyn/app-utils/crm'\nimport {\n type EmailRampVerdict,\n rampedDailyAllowance,\n} from '@aglyn/shared-util-email'\nimport { notifyPluginRecordWritten } from '@aglyn/aglyn/plugin-manager/plugin-record-written'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * The CRM RECORDS band's measurement (AGL-2611): the three collections it\n * counts, and their sum.\n *\n * One shape for every server that asks, because the band is enforced at six\n * doors — contact capture, the console's company and deal drawers (through\n * the client twin of this read), the CSV import, the lead conversion and the\n * two REST creates — and the monthly rollup bills from the same three\n * aggregates. A door that counted contacts alone would admit a company on a\n * band the rollup had already found full.\n */\nexport interface CrmRecordsCount {\n contactsCount: number\n companiesCount: number\n dealsCount: number\n /** The band's own figure: the three above, summed. */\n crmRecordsCount: number\n}\n\n/**\n * Count the records band for one org: three `count()` aggregates, in\n * parallel, no document reads.\n *\n * `contacts` may be handed in by a caller that already holds the collection\n * reference — `upsertHostContact` resolves it through `orgDataCollection`\n * before it decides anything — so the read is the same one whichever door\n * it came through, and the org reference is needed only for the two\n * collections the hub added.\n */\nexport async function countCrmRecords(\n orgRef: FirebaseFirestore.DocumentReference,\n contacts: FirebaseFirestore.CollectionReference = orgRef.collection('contacts'),\n): Promise<CrmRecordsCount> {\n const [contactsSnap, companiesSnap, dealsSnap] = await Promise.all([\n contacts.count().get(),\n orgRef.collection(CRM_COLLECTIONS.companies).count().get(),\n orgRef.collection(CRM_COLLECTIONS.deals).count().get(),\n ])\n const contactsCount = aggregateCount(contactsSnap)\n const companiesCount = aggregateCount(companiesSnap)\n const dealsCount = aggregateCount(dealsSnap)\n return {\n contactsCount,\n companiesCount,\n dealsCount,\n crmRecordsCount: contactsCount + companiesCount + dealsCount,\n }\n}\n\n/**\n * The band's verdict for one org, measured now — the read every create\n * door takes before it writes, so the six doors cannot disagree about\n * whether there is room. `checkCrmRecordsQuota` answers from the org doc\n * alone; this pairs it with the measurement.\n */\nexport async function crmRecordsQuotaForOrg(\n org: Partial<AglynOrgBilling> | null | undefined,\n orgRef: FirebaseFirestore.DocumentReference,\n contacts?: FirebaseFirestore.CollectionReference,\n): Promise<CrmRecordsQuotaResult & CrmRecordsCount> {\n const counts = await countCrmRecords(orgRef, contacts)\n return { ...checkCrmRecordsQuota(org, counts.crmRecordsCount), ...counts }\n}\n\n/** `count()` answers through `.data().count`; a missing figure is zero, never NaN. */\nfunction aggregateCount(snapshot: { data(): { count?: unknown } }): number {\n const value = Number(snapshot.data()?.count ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * How many activities one record already carries — one aggregate on a\n * single-field equality, which Firestore indexes on its own, so this needs\n * no composite index and cannot go FAILED_PRECONDITION in production.\n */\nexport async function countCrmActivitiesForRecord(\n orgRef: FirebaseFirestore.DocumentReference,\n link: CrmActivityLink,\n): Promise<number> {\n const lead = crmActivityCeilingLink(link)\n if (!lead) return 0\n const snapshot = await orgRef\n .collection(CRM_COLLECTIONS.activities)\n .where(lead.field, '==', lead.id)\n .count()\n .get()\n return aggregateCount(snapshot)\n}\n\n/**\n * `orgs/{orgId}/crmEmailUsage/{day}` — one UTC day's one-to-one email\n * counter, the document `checkCrmEmailQuota` is enforced against and the\n * billing page reads.\n */\nfunction crmEmailUsageRefForDay(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n day: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_EMAIL_USAGE_COLLECTION)\n .doc(day)\n}\n\n/** Today's counter document — {@link crmEmailUsageRefForDay} keyed by `now`. */\nexport function crmEmailUsageRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n now: Date = new Date(),\n): FirebaseFirestore.DocumentReference {\n return crmEmailUsageRefForDay(firestore, orgId, crmEmailUsageDayKey(now))\n}\n\n/**\n * The counter a day-document holds, clamped.\n *\n * A malformed or absent counter reads as ZERO SENT, which is the permissive\n * direction — deliberately, and the same clamp `checkApiRequestQuota`'s\n * reader applies: a counter that cannot be read must not refuse a rep who\n * has sent nothing, and the cap is a pace rather than a cost.\n */\nfunction crmEmailCount(snapshot: { get(field: string): unknown }): number {\n const value = Number(snapshot.get('count') ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * How many one-to-one emails the org has sent today, for a meter — never\n * for the cap, which {@link reserveCrmEmailSend} judges inside the\n * transaction that also takes the slot.\n */\nexport async function crmEmailsSentToday(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n now: Date = new Date(),\n): Promise<number> {\n return crmEmailCount(await crmEmailUsageRef(firestore, orgId, now).get())\n}\n\n/** One slot on one day's counter, held from the reservation to the send. */\nexport interface CrmEmailSendReservation {\n orgId: string\n /**\n * The UTC day the slot was taken on, so a release after midnight gives\n * the slot back to the day that lent it rather than to the new day.\n */\n day: string\n}\n\nexport type ReserveCrmEmailSendResult =\n | { ok: true; reservation: CrmEmailSendReservation; quota: CrmEmailQuotaResult }\n | { ok: false; quota: CrmEmailQuotaResult }\n\n/**\n * The plan's day, narrowed to the share of it a young workspace has earned.\n *\n * The new-sender ramp reaches the one-to-one path here and only here, so the\n * ceiling the transaction below judges against is one value with one origin.\n * The alternative — a full-allowance reservation with a ramp consulted beside\n * it — is two ceilings for one decision, and under two concurrent senders it\n * admits against whichever of them was read first.\n *\n * `included` is the only field the ramp moves; `resetsAt` is the day's, which\n * a share does not change, and `used` is what the transaction read.\n */\nfunction rampedCrmEmailQuota(\n plan: CrmEmailQuotaResult,\n ramp: EmailRampVerdict | null | undefined,\n): CrmEmailQuotaResult {\n const included = rampedDailyAllowance(plan.included, ramp)\n if (included >= plan.included) return plan\n return {\n ...plan,\n included,\n allowed: plan.used < included,\n remaining: Math.max(0, included - plan.used),\n }\n}\n\n/**\n * Claims ONE one-to-one send against today's cap, ATOMICALLY (AGL-2645).\n *\n * The counter used to be read before the send and incremented after\n * delivery, so two reps sending in the same window both passed the same\n * reading and both sent — `CRM_EMAIL_SENDS_PER_MINUTE` past the cap per\n * sender, in exactly the conditions the cap exists for. **A read-then-write\n * cap is not a cap**, the lesson `reserveCampaignEmailSends` learned for\n * campaigns; this is the same shape on the day counter.\n *\n * The transaction reads the counter, judges it with `checkCrmEmailQuota` —\n * the real plan table, `UNLIMITED` on Enterprise, zero on a tier with no\n * suite — and writes an ABSOLUTE value derived from that read, deliberately\n * not `FieldValue.increment`. Firestore aborts and re-runs the callback\n * when a document the transaction read has moved, so a second sender that\n * starts inside the first one's window re-reads the raised figure and is\n * refused. An increment would be atomic on the number and useless for the\n * decision, because the decision is made from a value the write never\n * proves it still held.\n *\n * A refused reservation writes NOTHING. A taken one is the count the cap\n * enforces and the billing page shows, from the moment it is taken: a send\n * the provider then refuses hands the slot back through\n * {@link releaseCrmEmailSend}, and a process that dies between the two\n * leaves the org one send short for the rest of the UTC day — conservative\n * in the direction a cap cares about, and healed at midnight, because each\n * day is its own document.\n *\n * ## The new-sender ramp rides the same read\n *\n * `ramp` narrows the plan's day to the share of it a young workspace has\n * earned, INSIDE the transaction and before the counter is judged — see\n * {@link rampedCrmEmailQuota}. Applying it here rather than in the caller is\n * the whole of what makes it safe: the ceiling and the count it is compared\n * against come out of one read, so the retry Firestore forces on a losing\n * transaction re-derives both. A ramp checked outside would be a second\n * ceiling, decided from a snapshot the write never proves it still held —\n * the defect this reservation was built to remove, one field over.\n *\n * The ramp is optional and an absent one is not a throttle: a caller that\n * did not resolve one gets the plan's day, which is what every surface but\n * the send path wants.\n */\nexport async function reserveCrmEmailSend(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n org: Partial<AglynOrgBilling> | null | undefined,\n now: Date = new Date(),\n ramp?: EmailRampVerdict | null,\n): Promise<ReserveCrmEmailSendResult> {\n const day = crmEmailUsageDayKey(now)\n const ref = crmEmailUsageRefForDay(firestore, orgId, day)\n return firestore.runTransaction(async (tx) => {\n const quota = rampedCrmEmailQuota(\n checkCrmEmailQuota(org, crmEmailCount(await tx.get(ref)), now),\n ramp,\n )\n if (!quota.allowed) return { ok: false, quota }\n tx.set(\n ref,\n { count: quota.used + 1, day, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n return { ok: true, reservation: { orgId, day }, quota }\n })\n}\n\n/**\n * Gives back a slot the provider did not use.\n *\n * Also a transaction, and also an absolute write from its own read: a\n * decrement computed from a stale figure would undo a slot another rep took\n * in the meantime, which is the same defect one direction over. Never\n * drives the counter below zero.\n *\n * **Never throws.** This runs on the failure path of a send that was\n * already refused, and the answer the rep gets is the provider's refusal; a\n * bookkeeping failure on top of it is logged, and its cost is one slot the\n * org does not get back until midnight — the safe direction.\n */\nexport async function releaseCrmEmailSend(\n firestore: FirebaseFirestore.Firestore,\n reservation: CrmEmailSendReservation | null | undefined,\n): Promise<void> {\n if (!reservation?.orgId || !reservation.day) return\n const ref = crmEmailUsageRefForDay(firestore, reservation.orgId, reservation.day)\n try {\n await firestore.runTransaction(async (tx) => {\n const used = crmEmailCount(await tx.get(ref))\n tx.set(\n ref,\n {\n count: Math.max(0, used - 1),\n day: reservation.day,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.error('crm email reservation release failed', error)\n }\n}\n\n/*==========================================\n * THE LIST FIELDS, RESTAMPED FROM WHAT WAS STORED (AGL-3321).\n *\n * A CRM list queries fields derived from the record — its search tokens, a\n * lead's status and verdict keys, a contact's facet keys (`crmListFields`\n * in `@aglyn/aglyn`). A writer that holds the whole document spreads them;\n * a writer that PATCHED an input — a dotted facet field, an `arrayUnion` of\n * tags, a merged capture — cannot know the result without reading it, so\n * it restamps here after its own write: the record is read back in a\n * transaction and only the list fields it now carries wrongly are written.\n *\n * Never throws. A restamp is a consequence of a write that already landed,\n * and failing the caller would report the write as failed; a record left\n * stale is what the one-time backfill (`backfill-crm-list-fields.mjs`)\n * brings level, and the next write to it restamps it again.\n *=========================================*/\n\nexport interface CrmListFieldsRestamp {\n /** Records whose list fields were written. */\n restamped: number\n /** Records already current. */\n current: number\n /** Records that no longer exist. */\n missing: number\n}\n\n/**\n * What a record's restamp reads beside it (AGL-3577): for a lead, the org's\n * lead source list, which its `leadSourceDirection` is read off. `{}` for\n * any other record, and for a lead whose org list cannot be read — the\n * restamp then leaves a held value's direction as stored rather than guess.\n */\nexport async function crmListFieldsContextFor(\n ref: FirebaseFirestore.DocumentReference,\n collection: CrmListCollection,\n): Promise<CrmListFieldsContext> {\n const org = collection === 'leads' ? ref.parent.parent : null\n if (!org) return {}\n try {\n const list = await org\n .collection(CRM_COLLECTIONS.picklists)\n .doc(CRM_LEAD_SOURCE_PICKLIST)\n .get()\n return { leadSources: effectiveCrmLeadSourcePicklist(list.data()) }\n } catch (error) {\n console.error(`[crm] lead source list unreadable for ${org.path}`, error)\n return {}\n }\n}\n\n/**\n * Restamp one record's list fields from the record as stored — and a lead's\n * direction from its org's lead source list, read here unless `context`\n * already holds it.\n *\n * Then tell the plugins a record was written (AGL-3336): every server writer\n * of an org record passes here after its write, so this is the one place a\n * plugin that acts on the record AFTER any write — the CRM's sharing rules —\n * is told, without an edit to each writer. Not for a record that no longer\n * exists, and after the restamp's own transaction, never inside it.\n */\nexport async function restampCrmListFieldsAt(\n ref: FirebaseFirestore.DocumentReference,\n collection: CrmListCollection,\n context?: CrmListFieldsContext,\n): Promise<'restamped' | 'current' | 'missing'> {\n let outcome: 'restamped' | 'current' | 'missing'\n try {\n // Read outside the transaction: the list is the org's, not the record's.\n const known = context ?? (await crmListFieldsContextFor(ref, collection))\n outcome = await ref.firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(ref)\n if (!snapshot.exists) return 'missing' as const\n const patch = crmListFieldsPatch(collection, snapshot.data() ?? {}, known)\n if (!Object.keys(patch).length) return 'current' as const\n tx.update(ref, patch)\n return 'restamped' as const\n })\n } catch (error) {\n console.error(`[crm] list fields restamp failed for ${ref.path}`, error)\n return 'current'\n }\n if (outcome !== 'missing') {\n await notifyPluginRecordWritten({ path: ref.path, collection })\n }\n return outcome\n}\n\n/** Restamp several records of one collection, a few at a time. */\nexport async function restampCrmListFieldsOf(\n refs: readonly FirebaseFirestore.DocumentReference[],\n collection: CrmListCollection,\n): Promise<CrmListFieldsRestamp> {\n const result: CrmListFieldsRestamp = { restamped: 0, current: 0, missing: 0 }\n const queue = [...new Map(refs.map((ref) => [ref.path, ref])).values()]\n // The org's lead source list, read once per org rather than per lead.\n const contexts = new Map<string, Promise<CrmListFieldsContext>>()\n const contextOf = (ref: FirebaseFirestore.DocumentReference) => {\n const key = ref.parent.parent?.path ?? ''\n let context = contexts.get(key)\n if (!context) {\n context = crmListFieldsContextFor(ref, collection)\n contexts.set(key, context)\n }\n return context\n }\n // Each is its own small transaction on its own record.\n const worker = async () => {\n for (let ref = queue.shift(); ref !== undefined; ref = queue.shift()) {\n const outcome = await restampCrmListFieldsAt(ref, collection, await contextOf(ref))\n result[outcome] += 1\n }\n }\n await Promise.all(Array.from({ length: Math.min(RESTAMP_CONCURRENCY, queue.length) }, worker))\n return result\n}\n\n/** Restamp several records of one collection under `orgs/{orgId}/`, by id. */\nexport async function restampCrmListFields(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n collection: CrmListCollection,\n ids: readonly string[],\n): Promise<CrmListFieldsRestamp> {\n const records = firestore.collection('orgs').doc(orgId).collection(collection)\n return restampCrmListFieldsOf(\n [...new Set(ids.filter(Boolean))].map((id) => records.doc(id)),\n collection,\n )\n}\n\n/** Records restamped at once by {@link restampCrmListFields}. */\nconst RESTAMP_CONCURRENCY = 8\n"],"names":["checkCrmEmailQuota","checkCrmRecordsQuota","CRM_COLLECTIONS","CRM_EMAIL_USAGE_COLLECTION","crmActivityCeilingLink","crmEmailUsageDayKey","CRM_LEAD_SOURCE_PICKLIST","crmListFieldsPatch","effectiveCrmLeadSourcePicklist","rampedDailyAllowance","notifyPluginRecordWritten","FieldValue","countCrmRecords","orgRef","contacts","collection","contactsSnap","companiesSnap","dealsSnap","Promise","all","count","get","companies","deals","contactsCount","aggregateCount","companiesCount","dealsCount","crmRecordsCount","crmRecordsQuotaForOrg","org","counts","snapshot","value","Number","data","isFinite","Math","floor","countCrmActivitiesForRecord","link","lead","activities","where","field","id","crmEmailUsageRefForDay","firestore","orgId","day","doc","crmEmailUsageRef","now","Date","crmEmailCount","crmEmailsSentToday","rampedCrmEmailQuota","plan","ramp","included","allowed","used","remaining","max","reserveCrmEmailSend","ref","runTransaction","tx","quota","ok","set","updatedAt","serverTimestamp","merge","reservation","releaseCrmEmailSend","error","console","crmListFieldsContextFor","parent","list","picklists","leadSources","path","restampCrmListFieldsAt","context","outcome","known","exists","patch","Object","keys","length","update","restampCrmListFieldsOf","refs","result","restamped","current","missing","queue","Map","map","values","contexts","contextOf","key","worker","shift","undefined","Array","from","min","RESTAMP_CONCURRENCY","restampCrmListFields","ids","records","Set","filter","Boolean"],"mappings":"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"}
@@ -145,6 +145,8 @@ export declare function originOf(value: string | undefined | null): string;
145
145
  export declare function resolveRenderMonitorTargets(env: Readonly<Record<string, string | undefined>>): string[];
146
146
  /** The state document id for one origin. */
147
147
  export declare function renderMonitorCheckId(origin: string): string;
148
+ /** The state document id for one host, as {@link renderMonitorCheckId} names it. */
149
+ export declare function renderMonitorHostCheckId(host: string): string;
148
150
  /** One render monitor state document. */
149
151
  export interface RenderMonitorStateDoc extends OperatorHealthStateDoc {
150
152
  origin: string;
@@ -153,8 +155,14 @@ export interface RenderMonitorStateDoc extends OperatorHealthStateDoc {
153
155
  /** When the current run of failures began; null while passing. */
154
156
  failingSinceMs: number | null;
155
157
  lastOkAtMs: number | null;
158
+ /** Runs in a row where bot protection answered instead of the site. */
159
+ consecutiveBlindRuns?: number;
160
+ /** When the current blind stretch began; null while the monitor can see. */
161
+ blindSinceMs?: number | null;
162
+ /** When a run last saw the site itself: a pass or a real failure. */
163
+ lastObservedAtMs?: number | null;
156
164
  }
157
- export type RenderMonitorTransition = 'failing' | 'recovered' | null;
165
+ export type RenderMonitorTransition = 'failing' | 'recovered' | 'blind' | null;
158
166
  /**
159
167
  * The next state for one site, given what this run saw. Pure.
160
168
  *
@@ -162,10 +170,16 @@ export type RenderMonitorTransition = 'failing' | 'recovered' | null;
162
170
  * failed, so one slow cold start or one dropped connection says nothing. It
163
171
  * turns back on the first passing run, because a render that works is
164
172
  * proof, and a recovery held back is a recovery nobody hears about.
173
+ *
174
+ * A BLIND observation (every failure was a bot-protection challenge) changes
175
+ * nothing about the site: its status, failure count and since-time carry over
176
+ * untouched, and only the blind count moves. It transitions to `blind` once,
177
+ * on the `threshold`-th blind run in a row.
165
178
  */
166
179
  export declare function nextRenderMonitorState(prior: Partial<RenderMonitorStateDoc> | null, observation: {
167
180
  ok: boolean;
168
181
  detail: string;
182
+ blind?: boolean;
169
183
  }, context: {
170
184
  origin: string;
171
185
  label: string;
@@ -186,6 +200,8 @@ export interface RenderProbeRow extends RenderProbeVerdict {
186
200
  export interface RenderMonitorSiteResult {
187
201
  origin: string;
188
202
  ok: boolean;
203
+ /** Every failure this run was a challenge, so it saw nothing of the site. */
204
+ blind: boolean;
189
205
  probes: RenderProbeRow[];
190
206
  /** The state written; absent on a dry run or a failed write. */
191
207
  status?: 'ok' | 'degraded';
@@ -230,4 +246,69 @@ export declare function runRenderMonitor(options?: {
230
246
  store?: RenderMonitorStore;
231
247
  raise?: RaiseAlert;
232
248
  }): Promise<RenderMonitorReport>;
249
+ /**
250
+ * One watched site as `/api/health/pages` reports it (AGL-3580).
251
+ *
252
+ * The shape of every other health check — `ok`, `ms`, a stable `code` — plus
253
+ * when the monitor last saw the site, so a reader can tell a fresh verdict
254
+ * from an old one without a second request.
255
+ */
256
+ export interface RenderPagesCheck {
257
+ ok: boolean;
258
+ ms: number;
259
+ code?: string;
260
+ /** When a run last rendered or failed this site's pages, ISO. */
261
+ lastObservedAt: string | null;
262
+ /** When the site's current status began, ISO. */
263
+ since: string | null;
264
+ }
265
+ /**
266
+ * A verdict older than this says the monitor stopped, not that the site did.
267
+ * Four missed five-minute runs.
268
+ */
269
+ export declare const RENDER_PAGES_STALE_MS: number;
270
+ /** How many sites one `?site=` may ask about. */
271
+ export declare const RENDER_PAGES_MAX_SITES = 10;
272
+ /**
273
+ * Which watched hosts a `?site=` asks about. Pure.
274
+ *
275
+ * Absent or empty asks about every watched site. A host that is not watched
276
+ * is kept, so the caller can answer it red: a public monitor pointed at a
277
+ * site nobody renders is a monitor that can never go red, which is the
278
+ * mistake this door exists to undo.
279
+ */
280
+ export declare function requestedRenderPagesHosts(query: string | null | undefined, watchedOrigins: readonly string[]): string[];
281
+ /**
282
+ * The public verdict on real pages, per site, from the render monitor's
283
+ * stored state (AGL-3580). Pure.
284
+ *
285
+ * This is what the status page's `Published sites` and `Marketing site`
286
+ * monitors read. Before it they read `/api/health/render/*`, which builds a
287
+ * node tree inside a route handler and never draws a page, and both stayed at
288
+ * 100% through two outages on 2026-10-05 in which every fresh page hung.
289
+ *
290
+ * Red is reserved for the one thing a visitor would notice:
291
+ *
292
+ * - `not-rendering` — the monitor saw the site fail to render pages it could
293
+ * not have served from a cache, `threshold` runs in a row.
294
+ * - `not-watched` — the monitor renders nothing for this host, so this row
295
+ * could never go red; fix the monitor's config or the URL.
296
+ * - `state-unavailable` — the verdicts could not be read at all.
297
+ *
298
+ * Green with a code is a verdict this door cannot improve on, and is never
299
+ * reported as an outage, because it is not one:
300
+ *
301
+ * - `awaiting-first-run` — a site added since the monitor last ran.
302
+ * - `monitor-blind` — bot protection answered the monitor; the site's last
303
+ * real verdict stands, and the operator alert says what to fix.
304
+ * - `observation-stale` — no run in {@link RENDER_PAGES_STALE_MS}; the
305
+ * monitor's own `render-monitor` row on `/api/health/crons` is what goes
306
+ * red for that, so a stopped scheduler is never told as a site outage.
307
+ */
308
+ export declare function renderPagesHealth(hosts: readonly string[], watchedOrigins: readonly string[], states: ReadonlyMap<string, Partial<RenderMonitorStateDoc>> | null, ms: number, now?: number): Record<string, RenderPagesCheck>;
309
+ /**
310
+ * Read the stored verdicts for `hosts`, one batched read. Null when the read
311
+ * fails, which {@link renderPagesHealth} reports red rather than calm.
312
+ */
313
+ export declare function readRenderMonitorStates(hosts: readonly string[]): Promise<Map<string, Partial<RenderMonitorStateDoc>> | null>;
233
314
  export {};
@@ -46,7 +46,20 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
46
46
  *
47
47
  * A site that fails `threshold` runs in a row raises
48
48
  * `system.siteRenderFailing` once; the first passing run after that raises
49
- * `system.siteRenderRecovered` once. The state lives in
49
+ * `system.siteRenderRecovered` once.
50
+ *
51
+ * ## A challenge is not a failed render (AGL-3580)
52
+ *
53
+ * When bot protection answers instead of the site, this run saw nothing about
54
+ * the site at all. On 2026-10-05 the console had no `AGLYN_PROBE_TOKEN`, every
55
+ * `/search` probe got the 429 checkpoint for three hours, and the monitor told
56
+ * the operator "Pages are not rendering on aglyn.com" and then "rendering
57
+ * again after 3 h" about a site that was serving pages the whole time. So a
58
+ * run whose only failures are challenges is BLIND: it leaves the site's
59
+ * verdict and failure count exactly as they were, and `threshold` blind runs
60
+ * in a row raise `system.renderMonitorBlind` once, which names the setup
61
+ * problem instead of an outage. A run with a real failure beside a challenge
62
+ * is still a failure, because the real one is evidence. The state lives in
50
63
  * `operatorHealthState/render-monitor--<host>`, beside every other health
51
64
  * check, so Staff → Operator alerts lists it with its status and since when.
52
65
  *
@@ -330,7 +343,10 @@ function spellingLetters(host) {
330
343
  ];
331
344
  }
332
345
  /** The state document id for one origin. */ export function renderMonitorCheckId(origin) {
333
- return `${RENDER_MONITOR_CHECK_PREFIX}${new URL(origin).host}`;
346
+ return renderMonitorHostCheckId(new URL(origin).host);
347
+ }
348
+ /** The state document id for one host, as {@link renderMonitorCheckId} names it. */ export function renderMonitorHostCheckId(host) {
349
+ return `${RENDER_MONITOR_CHECK_PREFIX}${host}`;
334
350
  }
335
351
  /**
336
352
  * The next state for one site, given what this run saw. Pure.
@@ -339,15 +355,47 @@ function spellingLetters(host) {
339
355
  * failed, so one slow cold start or one dropped connection says nothing. It
340
356
  * turns back on the first passing run, because a render that works is
341
357
  * proof, and a recovery held back is a recovery nobody hears about.
358
+ *
359
+ * A BLIND observation (every failure was a bot-protection challenge) changes
360
+ * nothing about the site: its status, failure count and since-time carry over
361
+ * untouched, and only the blind count moves. It transitions to `blind` once,
362
+ * on the `threshold`-th blind run in a row.
342
363
  */ export function nextRenderMonitorState(prior, observation, context) {
343
364
  var _observation_detail, _ref, _ref1, _ref2, _ref3;
344
365
  const { origin, label, threshold, now } = context;
345
366
  const checkId = renderMonitorCheckId(origin);
346
367
  const detail = String((_observation_detail = observation.detail) != null ? _observation_detail : '').slice(0, 1000);
347
368
  const wasDegraded = (prior == null ? void 0 : prior.status) === 'degraded';
348
- if (observation.ok) {
369
+ if (!observation.ok && observation.blind) {
370
+ var _ref4, _ref5, _ref6, _ref7, _ref8, _ref9, _ref10;
371
+ const blindRuns = ((_ref4 = prior == null ? void 0 : prior.consecutiveBlindRuns) != null ? _ref4 : 0) + 1;
349
372
  return {
350
373
  next: {
374
+ checkId,
375
+ label,
376
+ origin,
377
+ status: wasDegraded ? 'degraded' : 'ok',
378
+ sinceMs: (_ref5 = prior == null ? void 0 : prior.sinceMs) != null ? _ref5 : now,
379
+ detail: `The monitor cannot see this site: ${detail}`.slice(0, 1000),
380
+ updatedAtMs: now,
381
+ consecutiveFailures: (_ref6 = prior == null ? void 0 : prior.consecutiveFailures) != null ? _ref6 : 0,
382
+ failingSinceMs: (_ref7 = prior == null ? void 0 : prior.failingSinceMs) != null ? _ref7 : null,
383
+ lastOkAtMs: (_ref8 = prior == null ? void 0 : prior.lastOkAtMs) != null ? _ref8 : null,
384
+ consecutiveBlindRuns: blindRuns,
385
+ blindSinceMs: (_ref9 = prior == null ? void 0 : prior.blindSinceMs) != null ? _ref9 : now,
386
+ lastObservedAtMs: (_ref10 = prior == null ? void 0 : prior.lastObservedAtMs) != null ? _ref10 : null
387
+ },
388
+ transition: blindRuns === Math.max(1, threshold) ? 'blind' : null
389
+ };
390
+ }
391
+ const sighted = {
392
+ consecutiveBlindRuns: 0,
393
+ blindSinceMs: null,
394
+ lastObservedAtMs: now
395
+ };
396
+ if (observation.ok) {
397
+ return {
398
+ next: _extends({
351
399
  checkId,
352
400
  label,
353
401
  origin,
@@ -358,7 +406,7 @@ function spellingLetters(host) {
358
406
  consecutiveFailures: 0,
359
407
  failingSinceMs: null,
360
408
  lastOkAtMs: now
361
- },
409
+ }, sighted),
362
410
  transition: wasDegraded ? 'recovered' : null
363
411
  };
364
412
  }
@@ -366,7 +414,7 @@ function spellingLetters(host) {
366
414
  const failingSinceMs = (_ref1 = prior == null ? void 0 : prior.failingSinceMs) != null ? _ref1 : now;
367
415
  const degraded = wasDegraded || failures >= Math.max(1, threshold);
368
416
  return {
369
- next: {
417
+ next: _extends({
370
418
  checkId,
371
419
  label,
372
420
  origin,
@@ -377,7 +425,7 @@ function spellingLetters(host) {
377
425
  consecutiveFailures: failures,
378
426
  failingSinceMs,
379
427
  lastOkAtMs: (_ref3 = prior == null ? void 0 : prior.lastOkAtMs) != null ? _ref3 : null
380
- },
428
+ }, sighted),
381
429
  transition: degraded && !wasDegraded ? 'failing' : null
382
430
  };
383
431
  }
@@ -505,14 +553,16 @@ function probeLine(row) {
505
553
  ...pageProbe
506
554
  ]);
507
555
  const ok = probes.every((probe)=>probe.ok);
556
+ const failing = probes.filter((probe)=>!probe.ok);
557
+ const blind = !ok && failing.every((probe)=>probe.challenged);
508
558
  const result = {
509
559
  origin,
510
560
  ok,
561
+ blind,
511
562
  probes
512
563
  };
513
564
  if (dryRun) return result;
514
565
  const host = new URL(origin).host;
515
- const failing = probes.filter((probe)=>!probe.ok);
516
566
  const detail = ok ? 'Fresh renders pass.' : failing.map(probeLine).join('; ');
517
567
  try {
518
568
  const now = clock();
@@ -520,7 +570,8 @@ function probeLine(row) {
520
570
  var _ref, _ref1;
521
571
  const decided = nextRenderMonitorState(prior, {
522
572
  ok,
523
- detail
573
+ detail,
574
+ blind
524
575
  }, {
525
576
  origin,
526
577
  label: `Page rendering on ${host}`,
@@ -548,6 +599,16 @@ function probeLine(row) {
548
599
  detail
549
600
  }
550
601
  });
602
+ } else if (transition === 'blind') {
603
+ var _next_consecutiveBlindRuns;
604
+ await raise('system.renderMonitorBlind', {
605
+ dedupeKey: host,
606
+ context: {
607
+ site: host,
608
+ runs: (_next_consecutiveBlindRuns = next.consecutiveBlindRuns) != null ? _next_consecutiveBlindRuns : threshold,
609
+ detail
610
+ }
611
+ });
551
612
  } else if (transition === 'recovered') {
552
613
  await raise('system.siteRenderRecovered', {
553
614
  dedupeKey: host,
@@ -569,5 +630,141 @@ function probeLine(row) {
569
630
  sites
570
631
  };
571
632
  }
633
+ /**
634
+ * A verdict older than this says the monitor stopped, not that the site did.
635
+ * Four missed five-minute runs.
636
+ */ export const RENDER_PAGES_STALE_MS = 20 * 60000;
637
+ /** How many sites one `?site=` may ask about. */ export const RENDER_PAGES_MAX_SITES = 10;
638
+ /**
639
+ * Which watched hosts a `?site=` asks about. Pure.
640
+ *
641
+ * Absent or empty asks about every watched site. A host that is not watched
642
+ * is kept, so the caller can answer it red: a public monitor pointed at a
643
+ * site nobody renders is a monitor that can never go red, which is the
644
+ * mistake this door exists to undo.
645
+ */ export function requestedRenderPagesHosts(query, watchedOrigins) {
646
+ const asked = String(query != null ? query : '').split(/[\s,]+/).map((entry)=>entry.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '')).filter((entry)=>/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(entry));
647
+ const hosts = asked.length ? asked : watchedOrigins.map((origin)=>new URL(origin).host);
648
+ return [
649
+ ...new Set(hosts)
650
+ ].slice(0, RENDER_PAGES_MAX_SITES);
651
+ }
652
+ /**
653
+ * The public verdict on real pages, per site, from the render monitor's
654
+ * stored state (AGL-3580). Pure.
655
+ *
656
+ * This is what the status page's `Published sites` and `Marketing site`
657
+ * monitors read. Before it they read `/api/health/render/*`, which builds a
658
+ * node tree inside a route handler and never draws a page, and both stayed at
659
+ * 100% through two outages on 2026-10-05 in which every fresh page hung.
660
+ *
661
+ * Red is reserved for the one thing a visitor would notice:
662
+ *
663
+ * - `not-rendering` — the monitor saw the site fail to render pages it could
664
+ * not have served from a cache, `threshold` runs in a row.
665
+ * - `not-watched` — the monitor renders nothing for this host, so this row
666
+ * could never go red; fix the monitor's config or the URL.
667
+ * - `state-unavailable` — the verdicts could not be read at all.
668
+ *
669
+ * Green with a code is a verdict this door cannot improve on, and is never
670
+ * reported as an outage, because it is not one:
671
+ *
672
+ * - `awaiting-first-run` — a site added since the monitor last ran.
673
+ * - `monitor-blind` — bot protection answered the monitor; the site's last
674
+ * real verdict stands, and the operator alert says what to fix.
675
+ * - `observation-stale` — no run in {@link RENDER_PAGES_STALE_MS}; the
676
+ * monitor's own `render-monitor` row on `/api/health/crons` is what goes
677
+ * red for that, so a stopped scheduler is never told as a site outage.
678
+ */ export function renderPagesHealth(hosts, watchedOrigins, states, ms, now = Date.now()) {
679
+ const watched = new Set(watchedOrigins.map((origin)=>new URL(origin).host));
680
+ const iso = (value)=>typeof value === 'number' && Number.isFinite(value) ? new Date(value).toISOString() : null;
681
+ const checks = {};
682
+ if (!hosts.length) {
683
+ checks['sites'] = {
684
+ ok: false,
685
+ ms,
686
+ code: 'no-sites-watched',
687
+ lastObservedAt: null,
688
+ since: null
689
+ };
690
+ return checks;
691
+ }
692
+ for (const host of hosts){
693
+ var _ref, _state_lastObservedAtMs, _state_consecutiveBlindRuns, _state_updatedAtMs;
694
+ const blank = {
695
+ ms,
696
+ lastObservedAt: null,
697
+ since: null
698
+ };
699
+ if (!watched.has(host)) {
700
+ checks[host] = _extends({}, blank, {
701
+ ok: false,
702
+ code: 'not-watched'
703
+ });
704
+ continue;
705
+ }
706
+ if (states === null) {
707
+ checks[host] = _extends({}, blank, {
708
+ ok: false,
709
+ code: 'state-unavailable'
710
+ });
711
+ continue;
712
+ }
713
+ const state = states.get(renderMonitorHostCheckId(host));
714
+ if (!state) {
715
+ checks[host] = _extends({}, blank, {
716
+ ok: true,
717
+ code: 'awaiting-first-run'
718
+ });
719
+ continue;
720
+ }
721
+ const observed = (_ref = (_state_lastObservedAtMs = state.lastObservedAtMs) != null ? _state_lastObservedAtMs : state.lastOkAtMs) != null ? _ref : null;
722
+ const row = {
723
+ ms,
724
+ lastObservedAt: iso(observed),
725
+ since: iso(state.sinceMs)
726
+ };
727
+ if (state.status === 'degraded') {
728
+ checks[host] = _extends({}, row, {
729
+ ok: false,
730
+ code: 'not-rendering'
731
+ });
732
+ } else if (((_state_consecutiveBlindRuns = state.consecutiveBlindRuns) != null ? _state_consecutiveBlindRuns : 0) > 0) {
733
+ checks[host] = _extends({}, row, {
734
+ ok: true,
735
+ code: 'monitor-blind'
736
+ });
737
+ } else if (now - ((_state_updatedAtMs = state.updatedAtMs) != null ? _state_updatedAtMs : 0) > RENDER_PAGES_STALE_MS) {
738
+ checks[host] = _extends({}, row, {
739
+ ok: true,
740
+ code: 'observation-stale'
741
+ });
742
+ } else {
743
+ checks[host] = _extends({}, row, {
744
+ ok: true
745
+ });
746
+ }
747
+ }
748
+ return checks;
749
+ }
750
+ /**
751
+ * Read the stored verdicts for `hosts`, one batched read. Null when the read
752
+ * fails, which {@link renderPagesHealth} reports red rather than calm.
753
+ */ export async function readRenderMonitorStates(hosts) {
754
+ try {
755
+ const { default: firebaseAdmin } = await import("./firebase-admin.js");
756
+ const db = firebaseAdmin.app().firestore();
757
+ const refs = hosts.map((host)=>db.collection(RENDER_MONITOR_COLLECTION).doc(renderMonitorHostCheckId(host)));
758
+ const snapshots = refs.length ? await db.getAll(...refs) : [];
759
+ const states = new Map();
760
+ for (const snapshot of snapshots){
761
+ if (snapshot.exists) states.set(snapshot.id, snapshot.data());
762
+ }
763
+ return states;
764
+ } catch (error) {
765
+ console.error('[render-monitor] stored verdicts could not be read', error);
766
+ return null;
767
+ }
768
+ }
572
769
 
573
770
  //# sourceMappingURL=render-monitor.js.map