@aglyn/tenant-data-admin 1.0.0-beta.198 → 1.0.0-beta.200
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/lib/server/admin-audit-write.d.ts +28 -0
- package/src/lib/server/admin-audit-write.js +46 -0
- package/src/lib/server/admin-audit-write.js.map +1 -0
- package/src/lib/server/admin-audit.d.ts +4 -9
- package/src/lib/server/admin-audit.js +12 -31
- package/src/lib/server/admin-audit.js.map +1 -1
- package/src/lib/server/collection-live-pages.d.ts +83 -0
- package/src/lib/server/collection-live-pages.js +198 -0
- package/src/lib/server/collection-live-pages.js.map +1 -0
- package/src/lib/server/consent-group-carry.js +2 -0
- package/src/lib/server/consent-group-carry.js.map +1 -1
- package/src/lib/server/contact-company-link.js +3 -0
- package/src/lib/server/contact-company-link.js.map +1 -1
- package/src/lib/server/contact-lifecycle-floor.js +3 -0
- package/src/lib/server/contact-lifecycle-floor.js.map +1 -1
- package/src/lib/server/contact-merge.js +4 -0
- package/src/lib/server/contact-merge.js.map +1 -1
- package/src/lib/server/crm-booking-activity.js +3 -2
- package/src/lib/server/crm-booking-activity.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +15 -0
- package/src/lib/server/crm-records.js +52 -0
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/csp-aggregate.d.ts +16 -0
- package/src/lib/server/csp-aggregate.js +36 -0
- package/src/lib/server/csp-aggregate.js.map +1 -1
- package/src/lib/server/duplicate-resource.js +12 -5
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/email-delivery-log.d.ts +33 -0
- package/src/lib/server/email-delivery-log.js +43 -2
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +11 -14
- package/src/lib/server/email-suppression.js +14 -31
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/erase-person.js +22 -3
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/erase.js +4 -3
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-memberships.d.ts +34 -0
- package/src/lib/server/host-memberships.js +89 -1
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/host-visitor-records.js +23 -3
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/list-members.d.ts +11 -0
- package/src/lib/server/list-members.js +28 -1
- package/src/lib/server/list-members.js.map +1 -1
- package/src/lib/server/list-query.d.ts +36 -0
- package/src/lib/server/list-query.js +49 -0
- package/src/lib/server/list-query.js.map +1 -0
- package/src/lib/server/live-page-usage.d.ts +33 -0
- package/src/lib/server/live-page-usage.js +61 -1
- package/src/lib/server/live-page-usage.js.map +1 -1
- package/src/lib/server/media-tombstone.js +8 -2
- package/src/lib/server/media-tombstone.js.map +1 -1
- package/src/lib/server/org-member-list-fields.d.ts +31 -0
- package/src/lib/server/org-member-list-fields.js +38 -0
- package/src/lib/server/org-member-list-fields.js.map +1 -0
- package/src/lib/server/organizations.js +37 -7
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/replay-erasures.js +2 -1
- package/src/lib/server/replay-erasures.js.map +1 -1
- package/src/lib/server/sso-enforcement.js +2 -1
- package/src/lib/server/sso-enforcement.js.map +1 -1
- package/src/lib/server/upsert-contact.js +8 -4
- package/src/lib/server/upsert-contact.js.map +1 -1
|
@@ -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'\nimport {\n type EmailRampVerdict,\n rampedDailyAllowance,\n} from '@aglyn/shared-util-email'\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"],"names":["checkCrmEmailQuota","checkCrmRecordsQuota","CRM_COLLECTIONS","CRM_EMAIL_USAGE_COLLECTION","crmActivityCeilingLink","crmEmailUsageDayKey","rampedDailyAllowance","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"],"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 { type CrmListCollection, crmListFieldsPatch } from '@aglyn/aglyn/app-utils/crm'\nimport {\n type EmailRampVerdict,\n rampedDailyAllowance,\n} from '@aglyn/shared-util-email'\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/** Restamp one record's list fields from the record as stored. */\nexport async function restampCrmListFieldsAt(\n ref: FirebaseFirestore.DocumentReference,\n collection: CrmListCollection,\n): Promise<'restamped' | 'current' | 'missing'> {\n try {\n return 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}\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","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","exists","patch","Object","keys","length","update","path","restampCrmListFieldsOf","refs","result","restamped","current","missing","queue","Map","map","values","worker","shift","undefined","outcome","Array","from","min","RESTAMP_CONCURRENCY","restampCrmListFields","ids","records","Set","filter","Boolean"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SACEA,kBAAkB,EAClBC,oBAAoB,EACpBC,eAAe,EACfC,0BAA0B,EAE1BC,sBAAsB,EAEtBC,mBAAmB,QAEd,sBAAqB;AAC5B;;;;CAIC,GACD,SAAiCC,kBAAkB,QAAQ,6BAA4B;AACvF,SAEEC,oBAAoB,QACf,2BAA0B;AACjC,SAASC,UAAU,QAAQ,2BAA0B;AAqBrD;;;;;;;;;CASC,GACD,OAAO,eAAeC,gBACpBC,MAA2C,EAC3CC,WAAkDD,OAAOE,UAAU,CAAC,WAAW;IAE/E,MAAM,CAACC,cAAcC,eAAeC,UAAU,GAAG,MAAMC,QAAQC,GAAG,CAAC;QACjEN,SAASO,KAAK,GAAGC,GAAG;QACpBT,OAAOE,UAAU,CAACV,gBAAgBkB,SAAS,EAAEF,KAAK,GAAGC,GAAG;QACxDT,OAAOE,UAAU,CAACV,gBAAgBmB,KAAK,EAAEH,KAAK,GAAGC,GAAG;KACrD;IACD,MAAMG,gBAAgBC,eAAeV;IACrC,MAAMW,iBAAiBD,eAAeT;IACtC,MAAMW,aAAaF,eAAeR;IAClC,OAAO;QACLO;QACAE;QACAC;QACAC,iBAAiBJ,gBAAgBE,iBAAiBC;IACpD;AACF;AAEA;;;;;CAKC,GACD,OAAO,eAAeE,sBACpBC,GAAgD,EAChDlB,MAA2C,EAC3CC,QAAgD;IAEhD,MAAMkB,SAAS,MAAMpB,gBAAgBC,QAAQC;IAC7C,OAAO,aAAKV,qBAAqB2B,KAAKC,OAAOH,eAAe,GAAMG;AACpE;AAEA,oFAAoF,GACpF,SAASN,eAAeO,QAAyC;;QAC1CA;IAArB,MAAMC,QAAQC,gBAAOF,iBAAAA,SAASG,IAAI,uBAAbH,eAAiBZ,KAAK,mBAAI;IAC/C,OAAOc,OAAOE,QAAQ,CAACH,UAAUA,QAAQ,IAAII,KAAKC,KAAK,CAACL,SAAS;AACnE;AAEA;;;;CAIC,GACD,OAAO,eAAeM,4BACpB3B,MAA2C,EAC3C4B,IAAqB;IAErB,MAAMC,OAAOnC,uBAAuBkC;IACpC,IAAI,CAACC,MAAM,OAAO;IAClB,MAAMT,WAAW,MAAMpB,OACpBE,UAAU,CAACV,gBAAgBsC,UAAU,EACrCC,KAAK,CAACF,KAAKG,KAAK,EAAE,MAAMH,KAAKI,EAAE,EAC/BzB,KAAK,GACLC,GAAG;IACN,OAAOI,eAAeO;AACxB;AAEA;;;;CAIC,GACD,SAASc,uBACPC,SAAsC,EACtCC,KAAa,EACbC,GAAW;IAEX,OAAOF,UACJjC,UAAU,CAAC,QACXoC,GAAG,CAACF,OACJlC,UAAU,CAACT,4BACX6C,GAAG,CAACD;AACT;AAEA,8EAA8E,GAC9E,OAAO,SAASE,iBACdJ,SAAsC,EACtCC,KAAa,EACbI,MAAY,IAAIC,MAAM;IAEtB,OAAOP,uBAAuBC,WAAWC,OAAOzC,oBAAoB6C;AACtE;AAEA;;;;;;;CAOC,GACD,SAASE,cAActB,QAAyC;QACzCA;IAArB,MAAMC,QAAQC,QAAOF,gBAAAA,SAASX,GAAG,CAAC,oBAAbW,gBAAyB;IAC9C,OAAOE,OAAOE,QAAQ,CAACH,UAAUA,QAAQ,IAAII,KAAKC,KAAK,CAACL,SAAS;AACnE;AAEA;;;;CAIC,GACD,OAAO,eAAesB,mBACpBR,SAAsC,EACtCC,KAAa,EACbI,MAAY,IAAIC,MAAM;IAEtB,OAAOC,cAAc,MAAMH,iBAAiBJ,WAAWC,OAAOI,KAAK/B,GAAG;AACxE;AAgBA;;;;;;;;;;;CAWC,GACD,SAASmC,oBACPC,IAAyB,EACzBC,IAAyC;IAEzC,MAAMC,WAAWlD,qBAAqBgD,KAAKE,QAAQ,EAAED;IACrD,IAAIC,YAAYF,KAAKE,QAAQ,EAAE,OAAOF;IACtC,OAAO,aACFA;QACHE;QACAC,SAASH,KAAKI,IAAI,GAAGF;QACrBG,WAAWzB,KAAK0B,GAAG,CAAC,GAAGJ,WAAWF,KAAKI,IAAI;;AAE/C;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0CC,GACD,OAAO,eAAeG,oBACpBjB,SAAsC,EACtCC,KAAa,EACblB,GAAgD,EAChDsB,MAAY,IAAIC,MAAM,EACtBK,IAA8B;IAE9B,MAAMT,MAAM1C,oBAAoB6C;IAChC,MAAMa,MAAMnB,uBAAuBC,WAAWC,OAAOC;IACrD,OAAOF,UAAUmB,cAAc,CAAC,OAAOC;QACrC,MAAMC,QAAQZ,oBACZtD,mBAAmB4B,KAAKwB,cAAc,MAAMa,GAAG9C,GAAG,CAAC4C,OAAOb,MAC1DM;QAEF,IAAI,CAACU,MAAMR,OAAO,EAAE,OAAO;YAAES,IAAI;YAAOD;QAAM;QAC9CD,GAAGG,GAAG,CACJL,KACA;YAAE7C,OAAOgD,MAAMP,IAAI,GAAG;YAAGZ;YAAKsB,WAAW7D,WAAW8D,eAAe;QAAG,GACtE;YAAEC,OAAO;QAAK;QAEhB,OAAO;YAAEJ,IAAI;YAAMK,aAAa;gBAAE1B;gBAAOC;YAAI;YAAGmB;QAAM;IACxD;AACF;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,eAAeO,oBACpB5B,SAAsC,EACtC2B,WAAuD;IAEvD,IAAI,EAACA,+BAAAA,YAAa1B,KAAK,KAAI,CAAC0B,YAAYzB,GAAG,EAAE;IAC7C,MAAMgB,MAAMnB,uBAAuBC,WAAW2B,YAAY1B,KAAK,EAAE0B,YAAYzB,GAAG;IAChF,IAAI;QACF,MAAMF,UAAUmB,cAAc,CAAC,OAAOC;YACpC,MAAMN,OAAOP,cAAc,MAAMa,GAAG9C,GAAG,CAAC4C;YACxCE,GAAGG,GAAG,CACJL,KACA;gBACE7C,OAAOiB,KAAK0B,GAAG,CAAC,GAAGF,OAAO;gBAC1BZ,KAAKyB,YAAYzB,GAAG;gBACpBsB,WAAW7D,WAAW8D,eAAe;YACvC,GACA;gBAAEC,OAAO;YAAK;QAElB;IACF,EAAE,OAAOG,OAAO;QACdC,QAAQD,KAAK,CAAC,wCAAwCA;IACxD;AACF;AA4BA,gEAAgE,GAChE,OAAO,eAAeE,uBACpBb,GAAwC,EACxCnD,UAA6B;IAE7B,IAAI;QACF,OAAO,MAAMmD,IAAIlB,SAAS,CAACmB,cAAc,CAAC,OAAOC;gBAGFnC;YAF7C,MAAMA,WAAW,MAAMmC,GAAG9C,GAAG,CAAC4C;YAC9B,IAAI,CAACjC,SAAS+C,MAAM,EAAE,OAAO;YAC7B,MAAMC,QAAQxE,mBAAmBM,aAAYkB,iBAAAA,SAASG,IAAI,cAAbH,iBAAmB,CAAC;YACjE,IAAI,CAACiD,OAAOC,IAAI,CAACF,OAAOG,MAAM,EAAE,OAAO;YACvChB,GAAGiB,MAAM,CAACnB,KAAKe;YACf,OAAO;QACT;IACF,EAAE,OAAOJ,OAAO;QACdC,QAAQD,KAAK,CAAC,CAAC,qCAAqC,EAAEX,IAAIoB,IAAI,EAAE,EAAET;QAClE,OAAO;IACT;AACF;AAEA,gEAAgE,GAChE,OAAO,eAAeU,uBACpBC,IAAoD,EACpDzE,UAA6B;IAE7B,MAAM0E,SAA+B;QAAEC,WAAW;QAAGC,SAAS;QAAGC,SAAS;IAAE;IAC5E,MAAMC,QAAQ;WAAI,IAAIC,IAAIN,KAAKO,GAAG,CAAC,CAAC7B,MAAQ;gBAACA,IAAIoB,IAAI;gBAAEpB;aAAI,GAAG8B,MAAM;KAAG;IACvE,uDAAuD;IACvD,MAAMC,SAAS;QACb,IAAK,IAAI/B,MAAM2B,MAAMK,KAAK,IAAIhC,QAAQiC,WAAWjC,MAAM2B,MAAMK,KAAK,GAAI;YACpE,MAAME,UAAU,MAAMrB,uBAAuBb,KAAKnD;YAClD0E,MAAM,CAACW,QAAQ,IAAI;QACrB;IACF;IACA,MAAMjF,QAAQC,GAAG,CAACiF,MAAMC,IAAI,CAAC;QAAElB,QAAQ9C,KAAKiE,GAAG,CAACC,qBAAqBX,MAAMT,MAAM;IAAE,GAAGa;IACtF,OAAOR;AACT;AAEA,4EAA4E,GAC5E,OAAO,eAAegB,qBACpBzD,SAAsC,EACtCC,KAAa,EACblC,UAA6B,EAC7B2F,GAAsB;IAEtB,MAAMC,UAAU3D,UAAUjC,UAAU,CAAC,QAAQoC,GAAG,CAACF,OAAOlC,UAAU,CAACA;IACnE,OAAOwE,uBACL;WAAI,IAAIqB,IAAIF,IAAIG,MAAM,CAACC;KAAU,CAACf,GAAG,CAAC,CAACjD,KAAO6D,QAAQxD,GAAG,CAACL,MAC1D/B;AAEJ;AAEA,+DAA+D,GAC/D,MAAMyF,sBAAsB"}
|
|
@@ -40,6 +40,22 @@ export declare function resetCspAggregateStateForTests(): void;
|
|
|
40
40
|
* hand the id to whoever controls the URL.
|
|
41
41
|
*/
|
|
42
42
|
export declare function cspBlockedOrigin(blockedUri: string): string;
|
|
43
|
+
/** The written field the staff CSP table's search reads. */
|
|
44
|
+
export declare const CSP_SEARCH_TOKENS_PATH = "searchTokens";
|
|
45
|
+
/**
|
|
46
|
+
* The search tokens one counter carries: the word prefixes of its blocked
|
|
47
|
+
* origin, its directive and its app — each whole, and each run of letters
|
|
48
|
+
* and digits in it — so `googletagmanager`, `cdn.example.com`, `inline`,
|
|
49
|
+
* `script`, `elem` and `tenant` all find the counters they name. The
|
|
50
|
+
* lower-casing and the prefixes are `nameSearchTokens`, the normalizer the
|
|
51
|
+
* list query asks with. Not the sampled site and path: they change with every
|
|
52
|
+
* report, and a merge would leave tokens for a site the row no longer shows.
|
|
53
|
+
*/
|
|
54
|
+
export declare function cspSearchTokens(key: {
|
|
55
|
+
origin?: unknown;
|
|
56
|
+
directive?: unknown;
|
|
57
|
+
app?: unknown;
|
|
58
|
+
}): string[];
|
|
43
59
|
/** UTC day bucket, `YYYY-MM-DD`. */
|
|
44
60
|
export declare function cspAggregateDay(nowMs: number): string;
|
|
45
61
|
export interface RecordCspViolationsOptions {
|
|
@@ -83,7 +83,18 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
83
83
|
* for it, and Firestore denies what nothing matches, so the Admin SDK is the
|
|
84
84
|
* only path in. The staff read-back is `/api/admin/csp-reports` on the
|
|
85
85
|
* console.
|
|
86
|
+
*
|
|
87
|
+
* ## The search tokens
|
|
88
|
+
*
|
|
89
|
+
* The staff table's search reads `searchTokens` with `array-contains`
|
|
90
|
+
* (AGL-3321), so every write stamps it, through {@link cspSearchTokens}. It
|
|
91
|
+
* is derived from the counter's KEY — origin, directive, app — which is the
|
|
92
|
+
* document id and never changes, so the array is the same on every write to
|
|
93
|
+
* one document and a merge cannot leave it describing something else.
|
|
94
|
+
* `tools/scripts/backfill-csp-search-tokens.mjs` stamps the counters written
|
|
95
|
+
* before it, held to `csp-search-tokens.fixtures.json` beside it.
|
|
86
96
|
*/ import { FieldValue } from "firebase-admin/firestore";
|
|
97
|
+
import { nameSearchTokens } from "@aglyn/aglyn/app-utils/name-search";
|
|
87
98
|
import { isDocumentId } from "./document-id.js";
|
|
88
99
|
/** Top-level collection of counter documents. Server-only; no rules match. */ export const CSP_AGGREGATE_COLLECTION = 'cspViolationDaily';
|
|
89
100
|
/**
|
|
@@ -164,6 +175,26 @@ let budgetUsed = 0;
|
|
|
164
175
|
return sanitizePart(blockedUri, 40);
|
|
165
176
|
}
|
|
166
177
|
}
|
|
178
|
+
/** The written field the staff CSP table's search reads. */ export const CSP_SEARCH_TOKENS_PATH = 'searchTokens';
|
|
179
|
+
/**
|
|
180
|
+
* The search tokens one counter carries: the word prefixes of its blocked
|
|
181
|
+
* origin, its directive and its app — each whole, and each run of letters
|
|
182
|
+
* and digits in it — so `googletagmanager`, `cdn.example.com`, `inline`,
|
|
183
|
+
* `script`, `elem` and `tenant` all find the counters they name. The
|
|
184
|
+
* lower-casing and the prefixes are `nameSearchTokens`, the normalizer the
|
|
185
|
+
* list query asks with. Not the sampled site and path: they change with every
|
|
186
|
+
* report, and a merge would leave tokens for a site the row no longer shows.
|
|
187
|
+
*/ export function cspSearchTokens(key) {
|
|
188
|
+
const words = [
|
|
189
|
+
key.origin,
|
|
190
|
+
key.directive,
|
|
191
|
+
key.app
|
|
192
|
+
].map((value)=>typeof value === 'string' ? value.trim().toLowerCase() : '').filter(Boolean).flatMap((value)=>[
|
|
193
|
+
value,
|
|
194
|
+
...value.split(/[^a-z0-9]+/)
|
|
195
|
+
]).filter(Boolean);
|
|
196
|
+
return nameSearchTokens(words.join(' '));
|
|
197
|
+
}
|
|
167
198
|
/** UTC day bucket, `YYYY-MM-DD`. */ export function cspAggregateDay(nowMs) {
|
|
168
199
|
return new Date(nowMs).toISOString().slice(0, 10);
|
|
169
200
|
}
|
|
@@ -247,6 +278,11 @@ function normalizeDirective(directive) {
|
|
|
247
278
|
origin,
|
|
248
279
|
disposition: sanitizePart(violation.disposition || 'report', 16),
|
|
249
280
|
count: FieldValue.increment(n),
|
|
281
|
+
[CSP_SEARCH_TOKENS_PATH]: cspSearchTokens({
|
|
282
|
+
origin,
|
|
283
|
+
directive,
|
|
284
|
+
app: options.app
|
|
285
|
+
}),
|
|
250
286
|
lastSeenMs: now,
|
|
251
287
|
// Samples, not keys: enough to find the surface, no power to
|
|
252
288
|
// mint documents. Already clamped by the parser; clamped again
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/csp-aggregate.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 * Durable CSP-violation aggregates (AGL-1799).\n *\n * The collectors were delivering reports into a sink that forgets: the Vercel\n * runtime log retains ~60 minutes and there is no log drain, so AGL-1702's\n * \"a week of signed-in traffic\" and AGL-1726's \"a business week of visitor\n * traffic\" were gated on evidence that structurally could not exist. This\n * module is the durable half — the log lines stay exactly as they were (they\n * are still the right tool for the last hour), and every accepted violation\n * ALSO compounds into a Firestore counter that holds for the retention window.\n *\n * ## Shape: counters, never report bodies\n *\n * One document per (UTC day x app x directive x disposition x blocked origin),\n * with `FieldValue.increment` — NOT one document per report. AGL-523 made a\n * deliberate choice not to write report bodies to Firestore from an\n * unauthenticated route, and AGL-1799 argued the distinction explicitly: \"a\n * counter with a bounded key space is a different proposition from storing\n * report bodies\". The bound is what this module is careful about, because\n * every key ingredient except the day is attacker-influenced:\n *\n * - the DIRECTIVE is normalized into a closed set (a real browser can only\n * ever send a spec directive; anything else lands in `other` rather than\n * minting a document per invented directive);\n * - the ORIGIN is reduced to the host of `blocked-uri` (or its bare keyword\n * — `inline`, `eval`, `data`), sanitized to a fixed character set, and\n * capped: at most {@link MAX_DISTINCT_ORIGINS_PER_DAY} distinct origins per\n * (day x app x directive) may mint documents per instance per day. Beyond\n * that, new origins are dropped while known ones keep counting — a flood\n * of unique URLs (the AGL-1769 lesson: never let request content mint\n * unbounded documents) saturates the cap instead of the collection;\n * - WRITES are budgeted per instance per minute, so a POST flood cannot turn\n * an unauthenticated beacon into a Firestore write bill even against\n * already-minted documents.\n *\n * Every cap here is per instance, like the tenant collector's `keyStore`: the\n * fleet-wide worst case is (instances x cap), which is bounded and small,\n * and an instance recycle merely re-counts an origin against a fresh cap —\n * the merge write is idempotent about which document it lands in.\n *\n * ## Ids are attacker-derived, so they go through `isDocumentId`\n *\n * Key parts are sanitized to `[a-z0-9.:_-]` and joined with `|`, then the\n * whole id is checked with {@link isDocumentId} (the AGL-1771 predicate —\n * imported from its leaf, `./document-id`, which imports nothing). Belt and\n * braces: the sanitizer already removes `/` and the joiner makes a reserved\n * `__x__` form impossible, but the predicate is the contract and it is\n * cheaper to check than to argue.\n *\n * ## Retention is TTL, not a sweep\n *\n * `expiresAt` (a Timestamp — TTL refuses numbers) is set\n * {@link CSP_AGGREGATE_RETENTION_DAYS} days past the day bucket. The TTL\n * policy is a gcloud action plus a `fieldOverrides` entry in\n * `cloud/firebase-firestore.indexes.json` — see\n * docs/FIRESTORE_MANUAL_CONFIG.md (AGL-1793): the entry ships with this\n * module and the gcloud enable is owed at deploy time.\n *\n * ## Fail-soft, like everything on this path\n *\n * A collector must never turn an observed problem into a served one. Every\n * failure here — no credential, Firestore down, a hostile id that survives\n * sanitization — drops the aggregate (one `console.error` line, tagged) and\n * returns. The log lines the collectors already emit are unaffected.\n *\n * No client ever reads or writes this collection: there is no rules `match`\n * for it, and Firestore denies what nothing matches, so the Admin SDK is the\n * only path in. The staff read-back is `/api/admin/csp-reports` on the\n * console.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type { CspViolation } from '@aglyn/aglyn/app-utils/csp-report'\nimport { isDocumentId } from './document-id'\n\n/** Top-level collection of counter documents. Server-only; no rules match. */\nexport const CSP_AGGREGATE_COLLECTION = 'cspViolationDaily'\n\n/**\n * Long enough that \"a week of traffic\" (AGL-1702 condition 1, AGL-1726\n * condition 3) is readable with room to be read late, short enough that the\n * collection stays a working set rather than an archive.\n */\nexport const CSP_AGGREGATE_RETENTION_DAYS = 60\n\n/**\n * Distinct blocked origins that may mint documents per (day x app x\n * directive), per instance. A legitimate policy violates against a handful of\n * hosts; thirty is generous headroom for a real regression while keeping a\n * flood of attacker-invented origins from minting unbounded documents.\n */\nexport const MAX_DISTINCT_ORIGINS_PER_DAY = 30\n\n/**\n * Aggregate writes allowed per instance per minute. Distinct-origin capping\n * bounds document COUNT; this bounds write OPS, which are billed even when\n * they land on existing documents. Sized far above what real browsers on one\n * instance produce and far below what makes a flood expensive.\n */\nconst WRITE_BUDGET_PER_MINUTE = 120\n\n/**\n * The directives a browser can actually report, per the CSP3 registry. An\n * unrecognized directive is a non-browser caller (or a future spec revision)\n * and shares one bucket rather than minting its own key space.\n */\nconst KNOWN_DIRECTIVES = new Set([\n 'base-uri',\n 'block-all-mixed-content',\n 'child-src',\n 'connect-src',\n 'default-src',\n 'font-src',\n 'form-action',\n 'frame-ancestors',\n 'frame-src',\n 'img-src',\n 'manifest-src',\n 'media-src',\n 'object-src',\n 'prefetch-src',\n 'script-src',\n 'script-src-attr',\n 'script-src-elem',\n 'style-src',\n 'style-src-attr',\n 'style-src-elem',\n 'upgrade-insecure-requests',\n 'worker-src',\n])\n\n/**\n * Per-instance mint tracker: (day|app|directive) to the origin|disposition\n * pairs that have been allowed to mint documents. Cleared wholesale at the\n * cap, like the tenant collector's `keyStore`: the failure mode of clearing\n * is a brief widening of the mint cap, strictly better than an unbounded map\n * fed by attacker-chosen keys.\n */\nconst mintTracker = new Map<string, Set<string>>()\nconst MAX_TRACKED_GROUPS = 512\n\n/** Per-instance write budget window. */\nlet budgetWindowStartMs = 0\nlet budgetUsed = 0\n\n/** Test seam: module-scope state would otherwise leak across cases. */\nexport function resetCspAggregateStateForTests(): void {\n mintTracker.clear()\n budgetWindowStartMs = 0\n budgetUsed = 0\n}\n\n/** One id/field component: lowercase, fixed charset, never empty. */\nfunction sanitizePart(value: string, max: number): string {\n const cleaned = value\n .toLowerCase()\n .replace(/[^a-z0-9.:_-]+/g, '-')\n .slice(0, max)\n return cleaned || 'unknown'\n}\n\n/**\n * `blocked-uri` reduced to the coarsest thing worth counting: the HOST for a\n * URL, the bare keyword (`inline`, `eval`, `data`, ...) otherwise. Paths are\n * deliberately dropped — twelve chunk URLs off one CDN are one origin-level\n * fact (the AGL-1779 shape), and a path would both explode the key space and\n * hand the id to whoever controls the URL.\n */\nexport function cspBlockedOrigin(blockedUri: string): string {\n if (!blockedUri) return 'none'\n try {\n return sanitizePart(new URL(blockedUri).host, 120)\n } catch {\n return sanitizePart(blockedUri, 40)\n }\n}\n\n/** UTC day bucket, `YYYY-MM-DD`. */\nexport function cspAggregateDay(nowMs: number): string {\n return new Date(nowMs).toISOString().slice(0, 10)\n}\n\nfunction normalizeDirective(directive: string): string {\n return KNOWN_DIRECTIVES.has(directive) ? directive : 'other'\n}\n\nexport interface RecordCspViolationsOptions {\n /** Which collector this came through — part of the counter key. */\n app: 'console' | 'tenant'\n /**\n * The reporting site (tenant only), REQUEST-derived and already clamped by\n * the caller. Stored as a sample field, never in the id: distinct sites\n * must not multiply the key space.\n */\n site?: string\n now?: number\n /** Injectable for tests; defaults to the Admin SDK's Firestore. */\n firestore?: any\n}\n\n/**\n * Compound `violations` into the daily counters. Never throws; returns how\n * many documents were written (0 on any failure, budget exhaustion included).\n */\nexport async function recordCspViolations(\n violations: readonly CspViolation[],\n options: RecordCspViolationsOptions,\n): Promise<number> {\n try {\n if (!violations.length) return 0\n const now = options.now ?? Date.now()\n const day = cspAggregateDay(now)\n const dayStartMs = Date.parse(`${day}T00:00:00.000Z`)\n\n // Group first: N occurrences of one key in a batch are ONE write carrying\n // increment(N), so a page that repeats itself costs one op, not N.\n const grouped = new Map<\n string,\n { n: number; directive: string; origin: string; violation: CspViolation }\n >()\n for (const violation of violations) {\n const directive = normalizeDirective(violation.effectiveDirective)\n const origin = cspBlockedOrigin(violation.blockedUri)\n const disposition = sanitizePart(violation.disposition || 'report', 16)\n const id = [\n day,\n options.app,\n sanitizePart(directive, 64),\n disposition,\n origin,\n ].join('|')\n // Belt and braces — the AGL-1771 contract for anything handed to\n // `.doc()`. The sanitizer above should make this unreachable.\n if (!isDocumentId(id)) continue\n\n const existing = grouped.get(id)\n if (existing) {\n existing.n += 1\n continue\n }\n\n // Distinct-origin cap, per (day x app x directive). Known pairs keep\n // counting; new pairs beyond the cap are dropped — bounded documents\n // is the property, not perfect counts.\n const groupKey = `${day}|${options.app}|${directive}`\n if (mintTracker.size > MAX_TRACKED_GROUPS) mintTracker.clear()\n let minted = mintTracker.get(groupKey)\n if (!minted) {\n minted = new Set()\n mintTracker.set(groupKey, minted)\n }\n const pair = `${disposition}|${origin}`\n if (!minted.has(pair)) {\n if (minted.size >= MAX_DISTINCT_ORIGINS_PER_DAY) continue\n minted.add(pair)\n }\n\n grouped.set(id, { n: 1, directive, origin, violation })\n }\n if (!grouped.size) return 0\n\n // Write budget: fixed one-minute window per instance.\n if (now - budgetWindowStartMs >= 60_000) {\n budgetWindowStartMs = now\n budgetUsed = 0\n }\n const room = WRITE_BUDGET_PER_MINUTE - budgetUsed\n if (room <= 0) return 0\n const entries = [...grouped.entries()].slice(0, room)\n budgetUsed += entries.length\n\n const firestore =\n options.firestore ??\n // Lazily resolved so this module stays inert at load: route specs and\n // this module's own spec inject a fake, and nothing initializes\n // firebase-admin just by importing the barrel.\n (await import('./firebase-admin')).firebaseAdmin.app().firestore()\n\n await Promise.all(\n entries.map(([id, { n, directive, origin, violation }]) =>\n firestore\n .collection(CSP_AGGREGATE_COLLECTION)\n .doc(id)\n .set(\n {\n day,\n app: options.app,\n directive,\n origin,\n disposition: sanitizePart(violation.disposition || 'report', 16),\n count: FieldValue.increment(n),\n lastSeenMs: now,\n // Samples, not keys: enough to find the surface, no power to\n // mint documents. Already clamped by the parser; clamped again\n // because this module's contract should not depend on that.\n lastPath: violation.documentPath.slice(0, 200),\n ...(options.site ? { lastSite: options.site.slice(0, 253) } : {}),\n // Timestamp-typed via Date — Firestore TTL refuses numbers.\n expiresAt: new Date(\n dayStartMs + CSP_AGGREGATE_RETENTION_DAYS * 86_400_000,\n ),\n },\n { merge: true },\n ),\n ),\n )\n return entries.length\n } catch (error) {\n // Fail-soft: the aggregate is an observer, and `console.error` rather\n // than `console.warn` on purpose — the collectors' own log lines are\n // warns, and their specs count warns.\n console.error(\n JSON.stringify({\n tag: 'AGL-1799:csp-aggregate',\n error: String(error).slice(0, 200),\n }),\n )\n return 0\n }\n}\n"],"names":["FieldValue","isDocumentId","CSP_AGGREGATE_COLLECTION","CSP_AGGREGATE_RETENTION_DAYS","MAX_DISTINCT_ORIGINS_PER_DAY","WRITE_BUDGET_PER_MINUTE","KNOWN_DIRECTIVES","Set","mintTracker","Map","MAX_TRACKED_GROUPS","budgetWindowStartMs","budgetUsed","resetCspAggregateStateForTests","clear","sanitizePart","value","max","cleaned","toLowerCase","replace","slice","cspBlockedOrigin","blockedUri","URL","host","cspAggregateDay","nowMs","Date","toISOString","normalizeDirective","directive","has","recordCspViolations","violations","options","length","now","day","dayStartMs","parse","grouped","violation","effectiveDirective","origin","disposition","id","app","join","existing","get","n","groupKey","size","minted","set","pair","add","room","entries","firestore","firebaseAdmin","Promise","all","map","collection","doc","count","increment","lastSeenMs","lastPath","documentPath","site","lastSite","expiresAt","merge","error","console","JSON","stringify","tag","String"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqEC,GAED,SAASA,UAAU,QAAQ,2BAA0B;AAErD,SAASC,YAAY,QAAQ,mBAAe;AAE5C,4EAA4E,GAC5E,OAAO,MAAMC,2BAA2B,oBAAmB;AAE3D;;;;CAIC,GACD,OAAO,MAAMC,+BAA+B,GAAE;AAE9C;;;;;CAKC,GACD,OAAO,MAAMC,+BAA+B,GAAE;AAE9C;;;;;CAKC,GACD,MAAMC,0BAA0B;AAEhC;;;;CAIC,GACD,MAAMC,mBAAmB,IAAIC,IAAI;IAC/B;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAED;;;;;;CAMC,GACD,MAAMC,cAAc,IAAIC;AACxB,MAAMC,qBAAqB;AAE3B,sCAAsC,GACtC,IAAIC,sBAAsB;AAC1B,IAAIC,aAAa;AAEjB,qEAAqE,GACrE,OAAO,SAASC;IACdL,YAAYM,KAAK;IACjBH,sBAAsB;IACtBC,aAAa;AACf;AAEA,mEAAmE,GACnE,SAASG,aAAaC,KAAa,EAAEC,GAAW;IAC9C,MAAMC,UAAUF,MACbG,WAAW,GACXC,OAAO,CAAC,mBAAmB,KAC3BC,KAAK,CAAC,GAAGJ;IACZ,OAAOC,WAAW;AACpB;AAEA;;;;;;CAMC,GACD,OAAO,SAASI,iBAAiBC,UAAkB;IACjD,IAAI,CAACA,YAAY,OAAO;IACxB,IAAI;QACF,OAAOR,aAAa,IAAIS,IAAID,YAAYE,IAAI,EAAE;IAChD,EAAE,eAAM;QACN,OAAOV,aAAaQ,YAAY;IAClC;AACF;AAEA,kCAAkC,GAClC,OAAO,SAASG,gBAAgBC,KAAa;IAC3C,OAAO,IAAIC,KAAKD,OAAOE,WAAW,GAAGR,KAAK,CAAC,GAAG;AAChD;AAEA,SAASS,mBAAmBC,SAAiB;IAC3C,OAAOzB,iBAAiB0B,GAAG,CAACD,aAAaA,YAAY;AACvD;AAgBA;;;CAGC,GACD,OAAO,eAAeE,oBACpBC,UAAmC,EACnCC,OAAmC;IAEnC,IAAI;YAEUA,cA8DVA;QA/DF,IAAI,CAACD,WAAWE,MAAM,EAAE,OAAO;QAC/B,MAAMC,OAAMF,eAAAA,QAAQE,GAAG,YAAXF,eAAeP,KAAKS,GAAG;QACnC,MAAMC,MAAMZ,gBAAgBW;QAC5B,MAAME,aAAaX,KAAKY,KAAK,CAAC,GAAGF,IAAI,cAAc,CAAC;QAEpD,0EAA0E;QAC1E,mEAAmE;QACnE,MAAMG,UAAU,IAAIhC;QAIpB,KAAK,MAAMiC,aAAaR,WAAY;YAClC,MAAMH,YAAYD,mBAAmBY,UAAUC,kBAAkB;YACjE,MAAMC,SAAStB,iBAAiBoB,UAAUnB,UAAU;YACpD,MAAMsB,cAAc9B,aAAa2B,UAAUG,WAAW,IAAI,UAAU;YACpE,MAAMC,KAAK;gBACTR;gBACAH,QAAQY,GAAG;gBACXhC,aAAagB,WAAW;gBACxBc;gBACAD;aACD,CAACI,IAAI,CAAC;YACP,iEAAiE;YACjE,8DAA8D;YAC9D,IAAI,CAAC/C,aAAa6C,KAAK;YAEvB,MAAMG,WAAWR,QAAQS,GAAG,CAACJ;YAC7B,IAAIG,UAAU;gBACZA,SAASE,CAAC,IAAI;gBACd;YACF;YAEA,qEAAqE;YACrE,qEAAqE;YACrE,uCAAuC;YACvC,MAAMC,WAAW,GAAGd,IAAI,CAAC,EAAEH,QAAQY,GAAG,CAAC,CAAC,EAAEhB,WAAW;YACrD,IAAIvB,YAAY6C,IAAI,GAAG3C,oBAAoBF,YAAYM,KAAK;YAC5D,IAAIwC,SAAS9C,YAAY0C,GAAG,CAACE;YAC7B,IAAI,CAACE,QAAQ;gBACXA,SAAS,IAAI/C;gBACbC,YAAY+C,GAAG,CAACH,UAAUE;YAC5B;YACA,MAAME,OAAO,GAAGX,YAAY,CAAC,EAAED,QAAQ;YACvC,IAAI,CAACU,OAAOtB,GAAG,CAACwB,OAAO;gBACrB,IAAIF,OAAOD,IAAI,IAAIjD,8BAA8B;gBACjDkD,OAAOG,GAAG,CAACD;YACb;YAEAf,QAAQc,GAAG,CAACT,IAAI;gBAAEK,GAAG;gBAAGpB;gBAAWa;gBAAQF;YAAU;QACvD;QACA,IAAI,CAACD,QAAQY,IAAI,EAAE,OAAO;QAE1B,sDAAsD;QACtD,IAAIhB,MAAM1B,uBAAuB,OAAQ;YACvCA,sBAAsB0B;YACtBzB,aAAa;QACf;QACA,MAAM8C,OAAOrD,0BAA0BO;QACvC,IAAI8C,QAAQ,GAAG,OAAO;QACtB,MAAMC,UAAU;eAAIlB,QAAQkB,OAAO;SAAG,CAACtC,KAAK,CAAC,GAAGqC;QAChD9C,cAAc+C,QAAQvB,MAAM;QAE5B,MAAMwB,aACJzB,qBAAAA,QAAQyB,SAAS,YAAjBzB,qBAIA,AAHA,sEAAsE;QACtE,gEAAgE;QAChE,+CAA+C;QAC9C,CAAA,MAAM,MAAM,CAAC,sBAAkB,EAAG0B,aAAa,CAACd,GAAG,GAAGa,SAAS;QAElE,MAAME,QAAQC,GAAG,CACfJ,QAAQK,GAAG,CAAC,CAAC,CAAClB,IAAI,EAAEK,CAAC,EAAEpB,SAAS,EAAEa,MAAM,EAAEF,SAAS,EAAE,CAAC,GACpDkB,UACGK,UAAU,CAAC/D,0BACXgE,GAAG,CAACpB,IACJS,GAAG,CACF;gBACEjB;gBACAS,KAAKZ,QAAQY,GAAG;gBAChBhB;gBACAa;gBACAC,aAAa9B,aAAa2B,UAAUG,WAAW,IAAI,UAAU;gBAC7DsB,OAAOnE,WAAWoE,SAAS,CAACjB;gBAC5BkB,YAAYhC;gBACZ,6DAA6D;gBAC7D,+DAA+D;gBAC/D,4DAA4D;gBAC5DiC,UAAU5B,UAAU6B,YAAY,CAAClD,KAAK,CAAC,GAAG;eACtCc,QAAQqC,IAAI,GAAG;gBAAEC,UAAUtC,QAAQqC,IAAI,CAACnD,KAAK,CAAC,GAAG;YAAK,IAAI,CAAC;gBAC/D,4DAA4D;gBAC5DqD,WAAW,IAAI9C,KACbW,aAAapC,+BAA+B;gBAGhD;gBAAEwE,OAAO;YAAK;QAItB,OAAOhB,QAAQvB,MAAM;IACvB,EAAE,OAAOwC,OAAO;QACd,sEAAsE;QACtE,qEAAqE;QACrE,sCAAsC;QACtCC,QAAQD,KAAK,CACXE,KAAKC,SAAS,CAAC;YACbC,KAAK;YACLJ,OAAOK,OAAOL,OAAOvD,KAAK,CAAC,GAAG;QAChC;QAEF,OAAO;IACT;AACF"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/csp-aggregate.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 * Durable CSP-violation aggregates (AGL-1799).\n *\n * The collectors were delivering reports into a sink that forgets: the Vercel\n * runtime log retains ~60 minutes and there is no log drain, so AGL-1702's\n * \"a week of signed-in traffic\" and AGL-1726's \"a business week of visitor\n * traffic\" were gated on evidence that structurally could not exist. This\n * module is the durable half — the log lines stay exactly as they were (they\n * are still the right tool for the last hour), and every accepted violation\n * ALSO compounds into a Firestore counter that holds for the retention window.\n *\n * ## Shape: counters, never report bodies\n *\n * One document per (UTC day x app x directive x disposition x blocked origin),\n * with `FieldValue.increment` — NOT one document per report. AGL-523 made a\n * deliberate choice not to write report bodies to Firestore from an\n * unauthenticated route, and AGL-1799 argued the distinction explicitly: \"a\n * counter with a bounded key space is a different proposition from storing\n * report bodies\". The bound is what this module is careful about, because\n * every key ingredient except the day is attacker-influenced:\n *\n * - the DIRECTIVE is normalized into a closed set (a real browser can only\n * ever send a spec directive; anything else lands in `other` rather than\n * minting a document per invented directive);\n * - the ORIGIN is reduced to the host of `blocked-uri` (or its bare keyword\n * — `inline`, `eval`, `data`), sanitized to a fixed character set, and\n * capped: at most {@link MAX_DISTINCT_ORIGINS_PER_DAY} distinct origins per\n * (day x app x directive) may mint documents per instance per day. Beyond\n * that, new origins are dropped while known ones keep counting — a flood\n * of unique URLs (the AGL-1769 lesson: never let request content mint\n * unbounded documents) saturates the cap instead of the collection;\n * - WRITES are budgeted per instance per minute, so a POST flood cannot turn\n * an unauthenticated beacon into a Firestore write bill even against\n * already-minted documents.\n *\n * Every cap here is per instance, like the tenant collector's `keyStore`: the\n * fleet-wide worst case is (instances x cap), which is bounded and small,\n * and an instance recycle merely re-counts an origin against a fresh cap —\n * the merge write is idempotent about which document it lands in.\n *\n * ## Ids are attacker-derived, so they go through `isDocumentId`\n *\n * Key parts are sanitized to `[a-z0-9.:_-]` and joined with `|`, then the\n * whole id is checked with {@link isDocumentId} (the AGL-1771 predicate —\n * imported from its leaf, `./document-id`, which imports nothing). Belt and\n * braces: the sanitizer already removes `/` and the joiner makes a reserved\n * `__x__` form impossible, but the predicate is the contract and it is\n * cheaper to check than to argue.\n *\n * ## Retention is TTL, not a sweep\n *\n * `expiresAt` (a Timestamp — TTL refuses numbers) is set\n * {@link CSP_AGGREGATE_RETENTION_DAYS} days past the day bucket. The TTL\n * policy is a gcloud action plus a `fieldOverrides` entry in\n * `cloud/firebase-firestore.indexes.json` — see\n * docs/FIRESTORE_MANUAL_CONFIG.md (AGL-1793): the entry ships with this\n * module and the gcloud enable is owed at deploy time.\n *\n * ## Fail-soft, like everything on this path\n *\n * A collector must never turn an observed problem into a served one. Every\n * failure here — no credential, Firestore down, a hostile id that survives\n * sanitization — drops the aggregate (one `console.error` line, tagged) and\n * returns. The log lines the collectors already emit are unaffected.\n *\n * No client ever reads or writes this collection: there is no rules `match`\n * for it, and Firestore denies what nothing matches, so the Admin SDK is the\n * only path in. The staff read-back is `/api/admin/csp-reports` on the\n * console.\n *\n * ## The search tokens\n *\n * The staff table's search reads `searchTokens` with `array-contains`\n * (AGL-3321), so every write stamps it, through {@link cspSearchTokens}. It\n * is derived from the counter's KEY — origin, directive, app — which is the\n * document id and never changes, so the array is the same on every write to\n * one document and a merge cannot leave it describing something else.\n * `tools/scripts/backfill-csp-search-tokens.mjs` stamps the counters written\n * before it, held to `csp-search-tokens.fixtures.json` beside it.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type { CspViolation } from '@aglyn/aglyn/app-utils/csp-report'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { isDocumentId } from './document-id'\n\n/** Top-level collection of counter documents. Server-only; no rules match. */\nexport const CSP_AGGREGATE_COLLECTION = 'cspViolationDaily'\n\n/**\n * Long enough that \"a week of traffic\" (AGL-1702 condition 1, AGL-1726\n * condition 3) is readable with room to be read late, short enough that the\n * collection stays a working set rather than an archive.\n */\nexport const CSP_AGGREGATE_RETENTION_DAYS = 60\n\n/**\n * Distinct blocked origins that may mint documents per (day x app x\n * directive), per instance. A legitimate policy violates against a handful of\n * hosts; thirty is generous headroom for a real regression while keeping a\n * flood of attacker-invented origins from minting unbounded documents.\n */\nexport const MAX_DISTINCT_ORIGINS_PER_DAY = 30\n\n/**\n * Aggregate writes allowed per instance per minute. Distinct-origin capping\n * bounds document COUNT; this bounds write OPS, which are billed even when\n * they land on existing documents. Sized far above what real browsers on one\n * instance produce and far below what makes a flood expensive.\n */\nconst WRITE_BUDGET_PER_MINUTE = 120\n\n/**\n * The directives a browser can actually report, per the CSP3 registry. An\n * unrecognized directive is a non-browser caller (or a future spec revision)\n * and shares one bucket rather than minting its own key space.\n */\nconst KNOWN_DIRECTIVES = new Set([\n 'base-uri',\n 'block-all-mixed-content',\n 'child-src',\n 'connect-src',\n 'default-src',\n 'font-src',\n 'form-action',\n 'frame-ancestors',\n 'frame-src',\n 'img-src',\n 'manifest-src',\n 'media-src',\n 'object-src',\n 'prefetch-src',\n 'script-src',\n 'script-src-attr',\n 'script-src-elem',\n 'style-src',\n 'style-src-attr',\n 'style-src-elem',\n 'upgrade-insecure-requests',\n 'worker-src',\n])\n\n/**\n * Per-instance mint tracker: (day|app|directive) to the origin|disposition\n * pairs that have been allowed to mint documents. Cleared wholesale at the\n * cap, like the tenant collector's `keyStore`: the failure mode of clearing\n * is a brief widening of the mint cap, strictly better than an unbounded map\n * fed by attacker-chosen keys.\n */\nconst mintTracker = new Map<string, Set<string>>()\nconst MAX_TRACKED_GROUPS = 512\n\n/** Per-instance write budget window. */\nlet budgetWindowStartMs = 0\nlet budgetUsed = 0\n\n/** Test seam: module-scope state would otherwise leak across cases. */\nexport function resetCspAggregateStateForTests(): void {\n mintTracker.clear()\n budgetWindowStartMs = 0\n budgetUsed = 0\n}\n\n/** One id/field component: lowercase, fixed charset, never empty. */\nfunction sanitizePart(value: string, max: number): string {\n const cleaned = value\n .toLowerCase()\n .replace(/[^a-z0-9.:_-]+/g, '-')\n .slice(0, max)\n return cleaned || 'unknown'\n}\n\n/**\n * `blocked-uri` reduced to the coarsest thing worth counting: the HOST for a\n * URL, the bare keyword (`inline`, `eval`, `data`, ...) otherwise. Paths are\n * deliberately dropped — twelve chunk URLs off one CDN are one origin-level\n * fact (the AGL-1779 shape), and a path would both explode the key space and\n * hand the id to whoever controls the URL.\n */\nexport function cspBlockedOrigin(blockedUri: string): string {\n if (!blockedUri) return 'none'\n try {\n return sanitizePart(new URL(blockedUri).host, 120)\n } catch {\n return sanitizePart(blockedUri, 40)\n }\n}\n\n/** The written field the staff CSP table's search reads. */\nexport const CSP_SEARCH_TOKENS_PATH = 'searchTokens'\n\n/**\n * The search tokens one counter carries: the word prefixes of its blocked\n * origin, its directive and its app — each whole, and each run of letters\n * and digits in it — so `googletagmanager`, `cdn.example.com`, `inline`,\n * `script`, `elem` and `tenant` all find the counters they name. The\n * lower-casing and the prefixes are `nameSearchTokens`, the normalizer the\n * list query asks with. Not the sampled site and path: they change with every\n * report, and a merge would leave tokens for a site the row no longer shows.\n */\nexport function cspSearchTokens(key: {\n origin?: unknown\n directive?: unknown\n app?: unknown\n}): string[] {\n const words = [key.origin, key.directive, key.app]\n .map((value) => (typeof value === 'string' ? value.trim().toLowerCase() : ''))\n .filter(Boolean)\n .flatMap((value) => [value, ...value.split(/[^a-z0-9]+/)])\n .filter(Boolean)\n return nameSearchTokens(words.join(' '))\n}\n\n/** UTC day bucket, `YYYY-MM-DD`. */\nexport function cspAggregateDay(nowMs: number): string {\n return new Date(nowMs).toISOString().slice(0, 10)\n}\n\nfunction normalizeDirective(directive: string): string {\n return KNOWN_DIRECTIVES.has(directive) ? directive : 'other'\n}\n\nexport interface RecordCspViolationsOptions {\n /** Which collector this came through — part of the counter key. */\n app: 'console' | 'tenant'\n /**\n * The reporting site (tenant only), REQUEST-derived and already clamped by\n * the caller. Stored as a sample field, never in the id: distinct sites\n * must not multiply the key space.\n */\n site?: string\n now?: number\n /** Injectable for tests; defaults to the Admin SDK's Firestore. */\n firestore?: any\n}\n\n/**\n * Compound `violations` into the daily counters. Never throws; returns how\n * many documents were written (0 on any failure, budget exhaustion included).\n */\nexport async function recordCspViolations(\n violations: readonly CspViolation[],\n options: RecordCspViolationsOptions,\n): Promise<number> {\n try {\n if (!violations.length) return 0\n const now = options.now ?? Date.now()\n const day = cspAggregateDay(now)\n const dayStartMs = Date.parse(`${day}T00:00:00.000Z`)\n\n // Group first: N occurrences of one key in a batch are ONE write carrying\n // increment(N), so a page that repeats itself costs one op, not N.\n const grouped = new Map<\n string,\n { n: number; directive: string; origin: string; violation: CspViolation }\n >()\n for (const violation of violations) {\n const directive = normalizeDirective(violation.effectiveDirective)\n const origin = cspBlockedOrigin(violation.blockedUri)\n const disposition = sanitizePart(violation.disposition || 'report', 16)\n const id = [\n day,\n options.app,\n sanitizePart(directive, 64),\n disposition,\n origin,\n ].join('|')\n // Belt and braces — the AGL-1771 contract for anything handed to\n // `.doc()`. The sanitizer above should make this unreachable.\n if (!isDocumentId(id)) continue\n\n const existing = grouped.get(id)\n if (existing) {\n existing.n += 1\n continue\n }\n\n // Distinct-origin cap, per (day x app x directive). Known pairs keep\n // counting; new pairs beyond the cap are dropped — bounded documents\n // is the property, not perfect counts.\n const groupKey = `${day}|${options.app}|${directive}`\n if (mintTracker.size > MAX_TRACKED_GROUPS) mintTracker.clear()\n let minted = mintTracker.get(groupKey)\n if (!minted) {\n minted = new Set()\n mintTracker.set(groupKey, minted)\n }\n const pair = `${disposition}|${origin}`\n if (!minted.has(pair)) {\n if (minted.size >= MAX_DISTINCT_ORIGINS_PER_DAY) continue\n minted.add(pair)\n }\n\n grouped.set(id, { n: 1, directive, origin, violation })\n }\n if (!grouped.size) return 0\n\n // Write budget: fixed one-minute window per instance.\n if (now - budgetWindowStartMs >= 60_000) {\n budgetWindowStartMs = now\n budgetUsed = 0\n }\n const room = WRITE_BUDGET_PER_MINUTE - budgetUsed\n if (room <= 0) return 0\n const entries = [...grouped.entries()].slice(0, room)\n budgetUsed += entries.length\n\n const firestore =\n options.firestore ??\n // Lazily resolved so this module stays inert at load: route specs and\n // this module's own spec inject a fake, and nothing initializes\n // firebase-admin just by importing the barrel.\n (await import('./firebase-admin')).firebaseAdmin.app().firestore()\n\n await Promise.all(\n entries.map(([id, { n, directive, origin, violation }]) =>\n firestore\n .collection(CSP_AGGREGATE_COLLECTION)\n .doc(id)\n .set(\n {\n day,\n app: options.app,\n directive,\n origin,\n disposition: sanitizePart(violation.disposition || 'report', 16),\n count: FieldValue.increment(n),\n [CSP_SEARCH_TOKENS_PATH]: cspSearchTokens({\n origin,\n directive,\n app: options.app,\n }),\n lastSeenMs: now,\n // Samples, not keys: enough to find the surface, no power to\n // mint documents. Already clamped by the parser; clamped again\n // because this module's contract should not depend on that.\n lastPath: violation.documentPath.slice(0, 200),\n ...(options.site ? { lastSite: options.site.slice(0, 253) } : {}),\n // Timestamp-typed via Date — Firestore TTL refuses numbers.\n expiresAt: new Date(\n dayStartMs + CSP_AGGREGATE_RETENTION_DAYS * 86_400_000,\n ),\n },\n { merge: true },\n ),\n ),\n )\n return entries.length\n } catch (error) {\n // Fail-soft: the aggregate is an observer, and `console.error` rather\n // than `console.warn` on purpose — the collectors' own log lines are\n // warns, and their specs count warns.\n console.error(\n JSON.stringify({\n tag: 'AGL-1799:csp-aggregate',\n error: String(error).slice(0, 200),\n }),\n )\n return 0\n }\n}\n"],"names":["FieldValue","nameSearchTokens","isDocumentId","CSP_AGGREGATE_COLLECTION","CSP_AGGREGATE_RETENTION_DAYS","MAX_DISTINCT_ORIGINS_PER_DAY","WRITE_BUDGET_PER_MINUTE","KNOWN_DIRECTIVES","Set","mintTracker","Map","MAX_TRACKED_GROUPS","budgetWindowStartMs","budgetUsed","resetCspAggregateStateForTests","clear","sanitizePart","value","max","cleaned","toLowerCase","replace","slice","cspBlockedOrigin","blockedUri","URL","host","CSP_SEARCH_TOKENS_PATH","cspSearchTokens","key","words","origin","directive","app","map","trim","filter","Boolean","flatMap","split","join","cspAggregateDay","nowMs","Date","toISOString","normalizeDirective","has","recordCspViolations","violations","options","length","now","day","dayStartMs","parse","grouped","violation","effectiveDirective","disposition","id","existing","get","n","groupKey","size","minted","set","pair","add","room","entries","firestore","firebaseAdmin","Promise","all","collection","doc","count","increment","lastSeenMs","lastPath","documentPath","site","lastSite","expiresAt","merge","error","console","JSON","stringify","tag","String"],"mappings":"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"}
|
|
@@ -62,7 +62,11 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
62
62
|
* because the copy still renders and the row still lands. That binding is
|
|
63
63
|
* moved onto the copy below. No other kind embeds its own id in its design,
|
|
64
64
|
* so no other kind is decoded.
|
|
65
|
-
*/
|
|
65
|
+
*/ // The leaf module, not the server barrel: specs stage that barrel as a
|
|
66
|
+
// closed world, and the list keys every create stamps (AGL-3321) are
|
|
67
|
+
// nothing they have reason to name.
|
|
68
|
+
import { artifactCreateListKeys } from "@aglyn/aglyn/app-utils/artifact-list-keys";
|
|
69
|
+
import { billableScreenIds, checkEntitlement, checkQuota, claimAttempt, createResourceUid, decodeStoredNodes, encodeStoredNodes, newFormListFields, NON_PAGE_SCREEN_MAX_PER_HOST, nonPageScreenIds, SCREEN_KIND_EMAIL, SCREEN_KIND_TEMPLATE, screenClaimsToBeAPage } from "@aglyn/aglyn/server";
|
|
66
70
|
import { DUPLICABLE_RESOURCE_NOUNS, DUPLICATE_BUSY_MESSAGE, duplicateDisplayName, duplicateVersionNote, uniqueDuplicateName, uniqueDuplicateSlug } from "@aglyn/aglyn/app-utils/duplicate-resource";
|
|
67
71
|
import { formDesignReboundTo } from "@aglyn/aglyn/app-utils/forms";
|
|
68
72
|
import { Timestamp } from "firebase-admin/firestore";
|
|
@@ -271,7 +275,7 @@ async function copy(firestore, kind, recipe, options) {
|
|
|
271
275
|
const id = createResourceUid();
|
|
272
276
|
const versionId = recipe.versionParentField ? createResourceUid() : null;
|
|
273
277
|
return firestore.runTransaction(async (tx)=>{
|
|
274
|
-
var _data_recipe_nameField, _options_name, _recipe_stamps;
|
|
278
|
+
var _data_recipe_nameField, _options_name, _recipe_stamps, _recipe_stamps1;
|
|
275
279
|
// ALL READS BEFORE ANY WRITE, which Firestore requires. The host is read
|
|
276
280
|
// inside the transaction rather than passed in because its routing map
|
|
277
281
|
// is an input to the screen count and is ordinary client-writable.
|
|
@@ -446,9 +450,12 @@ async function copy(firestore, kind, recipe, options) {
|
|
|
446
450
|
// comes back decoded.
|
|
447
451
|
const packed = encodeStoredNodes(rebound(doc['nodes']));
|
|
448
452
|
if (packed) doc['nodes'] = Buffer.from(packed);
|
|
449
|
-
tx.create(collectionRef.doc(id), _extends({}, doc, (_recipe_stamps = recipe.stamps) != null ? _recipe_stamps : {}, recipe.collection === '
|
|
450
|
-
|
|
451
|
-
|
|
453
|
+
tx.create(collectionRef.doc(id), _extends({}, doc, (_recipe_stamps = recipe.stamps) != null ? _recipe_stamps : {}, artifactCreateListKeys(recipe.collection, _extends({}, doc, (_recipe_stamps1 = recipe.stamps) != null ? _recipe_stamps1 : {})), kind === 'form' ? newFormListFields({
|
|
454
|
+
id,
|
|
455
|
+
displayName: name,
|
|
456
|
+
slug: doc['slug'],
|
|
457
|
+
routing: doc['routing']
|
|
458
|
+
}) : {}, versionData && versionId ? {
|
|
452
459
|
versionId
|
|
453
460
|
} : {}, {
|
|
454
461
|
createdAt: Timestamp.now(),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/duplicate-resource.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 * Duplicating a site resource whole, as a draft (AGL-2936).\n *\n * One module for every kind that lives under a site — screens and email\n * designs, reusable components, layouts, templates, forms and workflows —\n * so the console route, the AI runtime's `duplicate_resource` tool and any\n * later door make the same copy: the same fields, the same uniqueness\n * rules, the same band arithmetic, the same activity rows. A caller that\n * has verified who is asking and that they may write the site hands over\n * the ids and gets back the copy's id, or a refusal it can answer with.\n *\n * WHAT MAKES THE COPY A DRAFT is different per kind, and each is the\n * omission of exactly one thing the create paths would otherwise do:\n *\n * - a screen is reachable only through the host's routing map, so the\n * copy gets no entry there — it has a slug of its own (`-copy`) but no\n * address until somebody publishes it;\n * - a collection's entry template renders only for the collection that\n * points at it, and nothing points at the copy;\n * - a layout, component or form is served only where a page places it,\n * and nothing places the copy;\n * - a workflow runs only when its trigger fires, and the copy's trigger\n * is cleared;\n * - a template is inert until it is used.\n *\n * COUNTED LIKE A CREATE. The copy is one more document in a collection the\n * plan bands, so it meets the same counter the create route meets — for a\n * screen that is not a page, the flat platform ceiling rather than a band —\n * and meets it INSIDE the transaction that writes the copy (AGL-2231): a count\n * taken before the write is a count N concurrent copies all pass. The\n * siblings are read once and answer three questions — the count, the name\n * the copy may take, and the slug — because three reads of one collection\n * is the reason a route and its readout come to disagree.\n *\n * THE VERSION TRAVELS WITH THE DOCUMENT. A besigner resource is opened by\n * its version, so the copy of a screen with no version is a screen nobody\n * can open. The source's most recently saved version is copied into the\n * copy's own `versions` as its first — no `versioning` entitlement is\n * owed for a first version — with its stored node bytes carried across\n * untouched: they are msgpack at rest, and re-encoding them is the\n * double-encode `encodeStoredNodes` exists to avoid.\n *\n * THE ONE TREE THAT IS REWRITTEN is a form's (AGL-3024). A form's design\n * names its own form inside itself, so carrying those bytes across hands the\n * copy a design that files every submission under the SOURCE — silently,\n * because the copy still renders and the row still lands. That binding is\n * moved onto the copy below. No other kind embeds its own id in its design,\n * so no other kind is decoded.\n */\n\nimport {\n billableScreenIds,\n checkEntitlement,\n checkQuota,\n claimAttempt,\n createResourceUid,\n decodeStoredNodes,\n encodeStoredNodes,\n nameSearchKey,\n NON_PAGE_SCREEN_MAX_PER_HOST,\n nonPageScreenIds,\n SCREEN_KIND_EMAIL,\n SCREEN_KIND_TEMPLATE,\n screenClaimsToBeAPage,\n type BillableScreenSource,\n type OrgEntitlements,\n type OrgFeatureFlags,\n} from '@aglyn/aglyn/server'\nimport {\n DUPLICABLE_RESOURCE_NOUNS,\n DUPLICATE_BUSY_MESSAGE,\n duplicateDisplayName,\n duplicateVersionNote,\n uniqueDuplicateName,\n uniqueDuplicateSlug,\n type DuplicableHostResourceKind,\n} from '@aglyn/aglyn/app-utils/duplicate-resource'\nimport { formDesignReboundTo } from '@aglyn/aglyn/app-utils/forms'\nimport { Timestamp } from 'firebase-admin/firestore'\nimport { logResourceDuplicated } from './duplicate-activity'\nimport firebaseAdmin from './firebase-admin'\n\n/** How the copy of each kind is made. */\ninterface KindRecipe {\n collection: string\n /** The field the name lives in — `name` on a workflow, `displayName` elsewhere. */\n nameField: 'displayName' | 'name'\n /** The source fields carried onto the copy; everything else stays behind. */\n fields: readonly string[]\n /** The back-pointer a copied version carries, when the kind versions at all. */\n versionParentField?: 'screenId' | 'layoutId' | 'componentId' | 'formId'\n /** The plan counter the copy is charged to. */\n quotaKey?: keyof OrgEntitlements & string\n /** The feature the plan must include for the kind to exist at all. */\n entitlement?: keyof OrgFeatureFlags\n /** Whether a `slug` on the source becomes a unique `-copy` slug. */\n slug?: boolean\n /** What the copy carries beyond the source's fields. */\n stamps?: Record<string, unknown>\n}\n\n/**\n * Per kind, the same fields the create route's allow-list admits, less the\n * ones a copy must not inherit: a screen's `versionId` (the copy mints its\n * own), a template's `source` (stamped `authored` below — a copy of a\n * marketplace template is the customer's own), a workflow's `trigger`\n * (cleared, so the copy runs nothing until armed), and anything the server\n * stamps on every create.\n */\nconst RECIPES: Record<DuplicableHostResourceKind, KindRecipe> = {\n screen: {\n collection: 'screens',\n nameField: 'displayName',\n fields: [\n 'description',\n 'seo',\n 'kind',\n 'visibility',\n 'locale',\n 'protection',\n // The shared-layout binding. A version inherits it from its screen, so\n // a copy without it renders with no header or footer (AGL-3120).\n 'layoutId',\n ],\n versionParentField: 'screenId',\n quotaKey: 'screensPerHost',\n slug: true,\n },\n // No `quotaKey`: an email design is not a page, so it meets the flat\n // platform ceiling on non-page screens below rather than the page band.\n emailDesign: {\n collection: 'screens',\n nameField: 'displayName',\n fields: ['description', 'kind'],\n versionParentField: 'screenId',\n },\n component: {\n collection: 'components',\n nameField: 'displayName',\n // `kind` keeps an email block an email block (AGL-3287): without it the\n // copy reads as a page component, leaves every email editor's palette,\n // and turns up in page editors where its email blocks cannot render.\n fields: ['description', 'icon', 'rootId', 'nodes', 'props', 'kind'],\n versionParentField: 'componentId',\n entitlement: 'reusableComponents',\n },\n layout: {\n collection: 'layouts',\n nameField: 'displayName',\n fields: ['description'],\n versionParentField: 'layoutId',\n quotaKey: 'sharedLayoutsPerHost',\n },\n template: {\n collection: 'templates',\n nameField: 'displayName',\n fields: [\n 'kind',\n // A component template's own kind — email block or page component —\n // so Use template on a copy still makes the right one (AGL-3287).\n 'componentKind',\n 'description',\n 'category',\n 'placeholders',\n 'nodes',\n 'rootId',\n 'props',\n 'slug',\n 'seo',\n 'theme',\n ],\n quotaKey: 'templatesPerHost',\n stamps: { source: { type: 'authored' } },\n },\n form: {\n collection: 'forms',\n nameField: 'displayName',\n fields: [\n 'fields',\n 'consentFieldName',\n 'routing',\n 'legacyMatch',\n 'rootId',\n 'nodes',\n ],\n versionParentField: 'formId',\n quotaKey: 'formsPerHost',\n entitlement: 'reusableComponents',\n slug: true,\n },\n workflow: {\n collection: 'workflows',\n nameField: 'name',\n fields: ['steps', 'returnValue'],\n quotaKey: 'workflowsPerHost',\n entitlement: 'workflows',\n stamps: { trigger: null },\n },\n}\n\nexport interface DuplicateResourceOptions {\n orgId: string\n hostId: string\n sourceId: string\n /** The name the copy takes; blank means `Copy of <source>`. */\n name?: string | null\n /** The verified person making the copy. */\n uid: string\n email?: string | null\n /**\n * The owning org's document, when the caller already holds it; read\n * here otherwise. The bands are resolved from it.\n */\n org?: Record<string, unknown> | null\n /**\n * A client-minted key for this attempt, so a double click makes one copy\n * and a retry of a finished attempt answers what the first did. A caller\n * with no client behind it — the AI runtime — sends none and is not\n * claimed.\n */\n attemptKey?: string | null\n}\n\nexport type DuplicateResourceResult =\n | {\n ok: true\n id: string\n /** The copy's first version, for the kinds that version. */\n versionId: string | null\n name: string\n }\n | { ok: false; status: number; error: string }\n\n/** What a sibling contributes to the three answers the siblings give. */\ninterface SiblingRow {\n id: string\n name: string\n slug: string\n kind: unknown\n deletedAt: unknown\n sourceType: unknown\n}\n\nconst asRecord = (value: unknown): Record<string, unknown> =>\n value && typeof value === 'object' && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : {}\n\n/**\n * Makes the copy. The caller has authenticated `uid` and verified that they\n * may write content on `hostId`; this decides everything after that.\n */\nexport async function duplicateResource(\n kind: DuplicableHostResourceKind,\n options: DuplicateResourceOptions,\n): Promise<DuplicateResourceResult> {\n const recipe = RECIPES[kind]\n const noun = DUPLICABLE_RESOURCE_NOUNS[kind]\n const sourceId = String(options.sourceId ?? '').trim().slice(0, 64)\n if (!sourceId) return { ok: false, status: 400, error: `Missing the ${noun} to copy` }\n const firestore = firebaseAdmin.app().firestore()\n\n const attemptKey = String(options.attemptKey ?? '').trim().slice(0, 200)\n const claimed = attemptKey\n ? await claimAttempt(firestore as never, {\n kind: `duplicate-${kind}`,\n scopeId: `${options.hostId}:${sourceId}`,\n orgId: options.orgId,\n key: attemptKey,\n busyMessage: DUPLICATE_BUSY_MESSAGE,\n })\n : null\n if (claimed && 'replay' in claimed) {\n return claimed.replay.body as DuplicateResourceResult\n }\n const claim = claimed && 'claim' in claimed ? claimed.claim : null\n\n try {\n const result = await copy(firestore, kind, recipe, { ...options, sourceId })\n if (result.ok) {\n await logResourceDuplicated(\n kind,\n { uid: options.uid, email: options.email ?? null },\n {\n orgId: options.orgId,\n hostId: options.hostId,\n source: { id: sourceId, name: result.sourceName },\n target: { id: result.id, name: result.name, versionId: result.versionId },\n },\n )\n const answer: DuplicateResourceResult = {\n ok: true,\n id: result.id,\n versionId: result.versionId,\n name: result.name,\n }\n await claim?.record(200, answer)\n return answer\n }\n // A refusal releases the key: the person fixes the cause — frees a slot,\n // picks another name — and presses the same button again.\n await claim?.release()\n return result\n } catch (error) {\n await claim?.release()\n throw error\n }\n}\n\ntype CopyOutcome =\n | { ok: true; id: string; versionId: string | null; name: string; sourceName: string }\n | { ok: false; status: number; error: string }\n\nasync function copy(\n firestore: FirebaseFirestore.Firestore,\n kind: DuplicableHostResourceKind,\n recipe: KindRecipe,\n options: DuplicateResourceOptions & { sourceId: string },\n): Promise<CopyOutcome> {\n const { hostId, sourceId, uid } = options\n const noun = DUPLICABLE_RESOURCE_NOUNS[kind]\n const hostRef = firestore.collection('hosts').doc(hostId)\n const collectionRef = hostRef.collection(recipe.collection)\n const sourceRef = collectionRef.doc(sourceId)\n const org =\n options.org ??\n (((await firestore.collection('orgs').doc(options.orgId).get()).data() ??\n null) as Record<string, unknown> | null)\n\n if (recipe.entitlement && !checkEntitlement(org as never, recipe.entitlement)) {\n return {\n ok: false,\n status: 403,\n error: 'This feature is not included in your plan — see Billing',\n }\n }\n\n const id = createResourceUid()\n const versionId = recipe.versionParentField ? createResourceUid() : null\n\n return firestore.runTransaction(async (tx): Promise<CopyOutcome> => {\n // ALL READS BEFORE ANY WRITE, which Firestore requires. The host is read\n // inside the transaction rather than passed in because its routing map\n // is an input to the screen count and is ordinary client-writable.\n const siblingFields = [\n recipe.nameField,\n 'slug',\n 'kind',\n 'deletedAt',\n 'source.type',\n ]\n const [hostSnapshot, source, siblingsSnapshot] = await Promise.all([\n tx.get(hostRef),\n tx.get(sourceRef),\n tx.get(collectionRef.select(...siblingFields)),\n ])\n if (!hostSnapshot.exists) return { ok: false, status: 404, error: 'Unknown site' }\n if (!source.exists || source.get('deletedAt') != null) {\n return { ok: false, status: 404, error: `Unknown ${noun}` }\n }\n const data = asRecord(source.data())\n\n // The two screen kinds share a collection and are told apart by `kind`:\n // an email design copied as a page would land on the Screens list, and\n // the other way round would put a page on the Emails page.\n //\n // Of the screens that are not pages, only a collection's entry template\n // may be copied as a screen (AGL-3102), and the copy stays a template:\n // `kind` rides across with the recipe's fields, so it composes nothing\n // until a collection points at it — which is how a second collection\n // starts from the first one's design. An error screen is refused:\n // `kind: 'error'` is the slot assignment's to stamp, because that write\n // is what bounds how many a site holds, and a copy would stamp one\n // outside it.\n const sourceKind = data['kind']\n const nonPageScreen =\n recipe.collection === 'screens' &&\n !screenClaimsToBeAPage({ kind: sourceKind as string })\n if (recipe.collection === 'screens') {\n if (kind === 'emailDesign' && sourceKind !== SCREEN_KIND_EMAIL) {\n return { ok: false, status: 400, error: 'That screen is not an email design' }\n }\n if (kind === 'screen' && nonPageScreen && sourceKind !== SCREEN_KIND_TEMPLATE) {\n return {\n ok: false,\n status: 400,\n error: 'Only a page or a collection entry template can be duplicated as a screen',\n }\n }\n }\n\n const siblings: SiblingRow[] = siblingsSnapshot.docs.map((row) => ({\n id: row.id,\n name: String(row.get(recipe.nameField) ?? ''),\n slug: String(row.get('slug') ?? ''),\n kind: row.get('kind'),\n deletedAt: row.get('deletedAt'),\n sourceType: row.get('source.type'),\n }))\n const live = siblings.filter((row) => row.deletedAt == null)\n\n // THE BAND, met inside the transaction (AGL-2231) with the create route's\n // own arithmetic: screens count what the routing map makes billable,\n // templates exclude the platform's starters, everything else counts its\n // live documents. A screen copy that is not a page — an email design or\n // an entry template — meets the flat platform ceiling on non-page screens\n // instead, the one an email design's create meets and a demoted template\n // fills, and never the page band: it spends none of it.\n const screenRows: BillableScreenSource[] = siblings.map((row) => ({\n id: row.id,\n kind: row.kind,\n deletedAt: row.deletedAt,\n }))\n const routingMap = hostSnapshot.get('screens') as never\n if (nonPageScreen) {\n if (nonPageScreenIds(screenRows, routingMap).size >= NON_PAGE_SCREEN_MAX_PER_HOST) {\n return {\n ok: false,\n status: 403,\n error:\n 'This site is at its limit of ' +\n `${NON_PAGE_SCREEN_MAX_PER_HOST} email and template screens — ` +\n 'delete some to make room',\n }\n }\n } else if (recipe.quotaKey) {\n const used =\n kind === 'screen'\n ? billableScreenIds(screenRows, routingMap).size\n : kind === 'template'\n ? live.filter((row) => row.sourceType !== 'starter').length\n : live.length\n const quota = checkQuota(org as never, recipe.quotaKey as never, used)\n if (!quota.allowed) {\n return {\n ok: false,\n status: 403,\n error:\n `Your plan includes ${quota.limit} ${noun}s — ` +\n 'upgrade in Billing for more',\n }\n }\n }\n\n // THE NAME AND THE SLUG, unique among the live siblings. A deleted\n // sibling's name is free: a person who deleted \"Home\" and copies\n // \"About\" as \"Home\" is not confused by a tombstone.\n const sourceName = String(data[recipe.nameField] ?? '').trim()\n const requested = String(options.name ?? '').trim() || duplicateDisplayName(sourceName)\n const name = uniqueDuplicateName(\n requested,\n live.map((row) => row.name),\n )\n const slug = recipe.slug\n ? uniqueDuplicateSlug(\n data['slug'] as string | undefined,\n live.map((row) => row.slug),\n )\n : undefined\n\n // THE VERSION: the most recently saved one, else the one the document\n // points at. Read inside the transaction so the copy is of what was\n // saved when the count was taken.\n let versionData: Record<string, unknown> | null = null\n let sourceVersionNumber: number | null = null\n if (recipe.versionParentField) {\n const versionsRef = sourceRef.collection('versions')\n const [newest, all] = await Promise.all([\n tx.get(versionsRef.orderBy('updatedAt', 'desc').limit(1)),\n tx.get(versionsRef.select()),\n ])\n let found: FirebaseFirestore.DocumentSnapshot | null = newest.docs[0] ?? null\n const pointer = String(data['versionId'] ?? '')\n if (!found && pointer) {\n const pointed = await tx.get(versionsRef.doc(pointer))\n if (pointed.exists) found = pointed\n }\n if (found) {\n versionData = asRecord(found.data())\n sourceVersionNumber = all.size\n }\n }\n\n // THE DOCUMENT: the recipe's fields, the name, the slug, the stamps.\n const doc: Record<string, unknown> = {}\n for (const field of recipe.fields) {\n if (data[field] !== undefined) doc[field] = data[field]\n }\n doc[recipe.nameField] = name\n if (recipe.slug) {\n if (slug) doc['slug'] = slug\n else delete doc['slug']\n }\n /*\n * THE FORM'S BINDING, moved onto the copy (AGL-3024).\n *\n * A form's design names its own form in a prop, and that name is what\n * `/api/forms/submit` files a submission under. Copied verbatim it still\n * names the SOURCE, so the copy renders, collects, and files everything\n * it collects under the form it was copied from — while its own list\n * stays empty and nothing anywhere reports an error. `checkFormContract`\n * is what the console's form page banners it with; `formDesignReboundTo`\n * is the write side of the same fact.\n *\n * This is the one place a copied tree is not carried across untouched,\n * and the exception is narrow on purpose: no other kind here embeds its\n * own id in its design. `formDesignReboundTo` answers `null` when there\n * is nothing to move, so every other kind — and a form whose design is\n * already correct — pays nothing for this.\n */\n const rebound = (raw: unknown): unknown => {\n if (kind !== 'form') return raw\n return (\n formDesignReboundTo(decodeStoredNodes(raw), {\n formId: id,\n formName: name,\n }) ?? raw\n )\n }\n // The stored tree is msgpack at rest; `encodeStoredNodes` passes encoded\n // bytes through and packs a plain map, so a legacy plain source lands\n // compressed like every create does — and so does a rebound one, which\n // comes back decoded.\n const packed = encodeStoredNodes(rebound(doc['nodes']))\n if (packed) doc['nodes'] = Buffer.from(packed)\n tx.create(collectionRef.doc(id), {\n ...doc,\n ...(recipe.stamps ?? {}),\n ...(recipe.collection === 'screens' ? { nameLower: nameSearchKey(name) } : {}),\n ...(versionData && versionId ? { versionId } : {}),\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n createdBy: uid,\n })\n if (versionData && versionId && recipe.versionParentField) {\n const versionNodes = encodeStoredNodes(rebound(versionData['nodes']))\n tx.create(collectionRef.doc(id).collection('versions').doc(versionId), {\n ...versionData,\n ...(versionNodes ? { nodes: Buffer.from(versionNodes) } : {}),\n [recipe.versionParentField]: id,\n hostId,\n displayName: duplicateVersionNote(sourceName, sourceVersionNumber),\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n createdBy: uid,\n })\n }\n return {\n ok: true,\n id,\n versionId: versionData && versionId ? versionId : null,\n name,\n sourceName,\n }\n })\n}\n"],"names":["billableScreenIds","checkEntitlement","checkQuota","claimAttempt","createResourceUid","decodeStoredNodes","encodeStoredNodes","nameSearchKey","NON_PAGE_SCREEN_MAX_PER_HOST","nonPageScreenIds","SCREEN_KIND_EMAIL","SCREEN_KIND_TEMPLATE","screenClaimsToBeAPage","DUPLICABLE_RESOURCE_NOUNS","DUPLICATE_BUSY_MESSAGE","duplicateDisplayName","duplicateVersionNote","uniqueDuplicateName","uniqueDuplicateSlug","formDesignReboundTo","Timestamp","logResourceDuplicated","firebaseAdmin","RECIPES","screen","collection","nameField","fields","versionParentField","quotaKey","slug","emailDesign","component","entitlement","layout","template","stamps","source","type","form","workflow","trigger","asRecord","value","Array","isArray","duplicateResource","kind","options","recipe","noun","sourceId","String","trim","slice","ok","status","error","firestore","app","attemptKey","claimed","scopeId","hostId","orgId","key","busyMessage","replay","body","claim","result","copy","uid","email","id","name","sourceName","target","versionId","answer","record","release","hostRef","doc","collectionRef","sourceRef","org","get","data","runTransaction","tx","siblingFields","hostSnapshot","siblingsSnapshot","Promise","all","select","exists","sourceKind","nonPageScreen","siblings","docs","map","row","deletedAt","sourceType","live","filter","screenRows","routingMap","size","used","length","quota","allowed","limit","requested","undefined","versionData","sourceVersionNumber","newest","versionsRef","orderBy","found","pointer","pointed","field","rebound","raw","formId","formName","packed","Buffer","from","create","nameLower","createdAt","now","updatedAt","createdBy","versionNodes","nodes","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/duplicate-resource.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 * Duplicating a site resource whole, as a draft (AGL-2936).\n *\n * One module for every kind that lives under a site — screens and email\n * designs, reusable components, layouts, templates, forms and workflows —\n * so the console route, the AI runtime's `duplicate_resource` tool and any\n * later door make the same copy: the same fields, the same uniqueness\n * rules, the same band arithmetic, the same activity rows. A caller that\n * has verified who is asking and that they may write the site hands over\n * the ids and gets back the copy's id, or a refusal it can answer with.\n *\n * WHAT MAKES THE COPY A DRAFT is different per kind, and each is the\n * omission of exactly one thing the create paths would otherwise do:\n *\n * - a screen is reachable only through the host's routing map, so the\n * copy gets no entry there — it has a slug of its own (`-copy`) but no\n * address until somebody publishes it;\n * - a collection's entry template renders only for the collection that\n * points at it, and nothing points at the copy;\n * - a layout, component or form is served only where a page places it,\n * and nothing places the copy;\n * - a workflow runs only when its trigger fires, and the copy's trigger\n * is cleared;\n * - a template is inert until it is used.\n *\n * COUNTED LIKE A CREATE. The copy is one more document in a collection the\n * plan bands, so it meets the same counter the create route meets — for a\n * screen that is not a page, the flat platform ceiling rather than a band —\n * and meets it INSIDE the transaction that writes the copy (AGL-2231): a count\n * taken before the write is a count N concurrent copies all pass. The\n * siblings are read once and answer three questions — the count, the name\n * the copy may take, and the slug — because three reads of one collection\n * is the reason a route and its readout come to disagree.\n *\n * THE VERSION TRAVELS WITH THE DOCUMENT. A besigner resource is opened by\n * its version, so the copy of a screen with no version is a screen nobody\n * can open. The source's most recently saved version is copied into the\n * copy's own `versions` as its first — no `versioning` entitlement is\n * owed for a first version — with its stored node bytes carried across\n * untouched: they are msgpack at rest, and re-encoding them is the\n * double-encode `encodeStoredNodes` exists to avoid.\n *\n * THE ONE TREE THAT IS REWRITTEN is a form's (AGL-3024). A form's design\n * names its own form inside itself, so carrying those bytes across hands the\n * copy a design that files every submission under the SOURCE — silently,\n * because the copy still renders and the row still lands. That binding is\n * moved onto the copy below. No other kind embeds its own id in its design,\n * so no other kind is decoded.\n */\n\n// The leaf module, not the server barrel: specs stage that barrel as a\n// closed world, and the list keys every create stamps (AGL-3321) are\n// nothing they have reason to name.\nimport { artifactCreateListKeys } from '@aglyn/aglyn/app-utils/artifact-list-keys'\nimport {\n billableScreenIds,\n checkEntitlement,\n checkQuota,\n claimAttempt,\n createResourceUid,\n decodeStoredNodes,\n encodeStoredNodes,\n newFormListFields,\n NON_PAGE_SCREEN_MAX_PER_HOST,\n nonPageScreenIds,\n SCREEN_KIND_EMAIL,\n SCREEN_KIND_TEMPLATE,\n screenClaimsToBeAPage,\n type BillableScreenSource,\n type OrgEntitlements,\n type OrgFeatureFlags,\n} from '@aglyn/aglyn/server'\nimport {\n DUPLICABLE_RESOURCE_NOUNS,\n DUPLICATE_BUSY_MESSAGE,\n duplicateDisplayName,\n duplicateVersionNote,\n uniqueDuplicateName,\n uniqueDuplicateSlug,\n type DuplicableHostResourceKind,\n} from '@aglyn/aglyn/app-utils/duplicate-resource'\nimport { formDesignReboundTo } from '@aglyn/aglyn/app-utils/forms'\nimport { Timestamp } from 'firebase-admin/firestore'\nimport { logResourceDuplicated } from './duplicate-activity'\nimport firebaseAdmin from './firebase-admin'\n\n/** How the copy of each kind is made. */\ninterface KindRecipe {\n collection: string\n /** The field the name lives in — `name` on a workflow, `displayName` elsewhere. */\n nameField: 'displayName' | 'name'\n /** The source fields carried onto the copy; everything else stays behind. */\n fields: readonly string[]\n /** The back-pointer a copied version carries, when the kind versions at all. */\n versionParentField?: 'screenId' | 'layoutId' | 'componentId' | 'formId'\n /** The plan counter the copy is charged to. */\n quotaKey?: keyof OrgEntitlements & string\n /** The feature the plan must include for the kind to exist at all. */\n entitlement?: keyof OrgFeatureFlags\n /** Whether a `slug` on the source becomes a unique `-copy` slug. */\n slug?: boolean\n /** What the copy carries beyond the source's fields. */\n stamps?: Record<string, unknown>\n}\n\n/**\n * Per kind, the same fields the create route's allow-list admits, less the\n * ones a copy must not inherit: a screen's `versionId` (the copy mints its\n * own), a template's `source` (stamped `authored` below — a copy of a\n * marketplace template is the customer's own), a workflow's `trigger`\n * (cleared, so the copy runs nothing until armed), and anything the server\n * stamps on every create.\n */\nconst RECIPES: Record<DuplicableHostResourceKind, KindRecipe> = {\n screen: {\n collection: 'screens',\n nameField: 'displayName',\n fields: [\n 'description',\n 'seo',\n 'kind',\n 'visibility',\n 'locale',\n 'protection',\n // The shared-layout binding. A version inherits it from its screen, so\n // a copy without it renders with no header or footer (AGL-3120).\n 'layoutId',\n ],\n versionParentField: 'screenId',\n quotaKey: 'screensPerHost',\n slug: true,\n },\n // No `quotaKey`: an email design is not a page, so it meets the flat\n // platform ceiling on non-page screens below rather than the page band.\n emailDesign: {\n collection: 'screens',\n nameField: 'displayName',\n fields: ['description', 'kind'],\n versionParentField: 'screenId',\n },\n component: {\n collection: 'components',\n nameField: 'displayName',\n // `kind` keeps an email block an email block (AGL-3287): without it the\n // copy reads as a page component, leaves every email editor's palette,\n // and turns up in page editors where its email blocks cannot render.\n fields: ['description', 'icon', 'rootId', 'nodes', 'props', 'kind'],\n versionParentField: 'componentId',\n entitlement: 'reusableComponents',\n },\n layout: {\n collection: 'layouts',\n nameField: 'displayName',\n fields: ['description'],\n versionParentField: 'layoutId',\n quotaKey: 'sharedLayoutsPerHost',\n },\n template: {\n collection: 'templates',\n nameField: 'displayName',\n fields: [\n 'kind',\n // A component template's own kind — email block or page component —\n // so Use template on a copy still makes the right one (AGL-3287).\n 'componentKind',\n 'description',\n 'category',\n 'placeholders',\n 'nodes',\n 'rootId',\n 'props',\n 'slug',\n 'seo',\n 'theme',\n ],\n quotaKey: 'templatesPerHost',\n stamps: { source: { type: 'authored' } },\n },\n form: {\n collection: 'forms',\n nameField: 'displayName',\n fields: [\n 'fields',\n 'consentFieldName',\n 'routing',\n 'legacyMatch',\n 'rootId',\n 'nodes',\n ],\n versionParentField: 'formId',\n quotaKey: 'formsPerHost',\n entitlement: 'reusableComponents',\n slug: true,\n },\n workflow: {\n collection: 'workflows',\n nameField: 'name',\n fields: ['steps', 'returnValue'],\n quotaKey: 'workflowsPerHost',\n entitlement: 'workflows',\n stamps: { trigger: null },\n },\n}\n\nexport interface DuplicateResourceOptions {\n orgId: string\n hostId: string\n sourceId: string\n /** The name the copy takes; blank means `Copy of <source>`. */\n name?: string | null\n /** The verified person making the copy. */\n uid: string\n email?: string | null\n /**\n * The owning org's document, when the caller already holds it; read\n * here otherwise. The bands are resolved from it.\n */\n org?: Record<string, unknown> | null\n /**\n * A client-minted key for this attempt, so a double click makes one copy\n * and a retry of a finished attempt answers what the first did. A caller\n * with no client behind it — the AI runtime — sends none and is not\n * claimed.\n */\n attemptKey?: string | null\n}\n\nexport type DuplicateResourceResult =\n | {\n ok: true\n id: string\n /** The copy's first version, for the kinds that version. */\n versionId: string | null\n name: string\n }\n | { ok: false; status: number; error: string }\n\n/** What a sibling contributes to the three answers the siblings give. */\ninterface SiblingRow {\n id: string\n name: string\n slug: string\n kind: unknown\n deletedAt: unknown\n sourceType: unknown\n}\n\nconst asRecord = (value: unknown): Record<string, unknown> =>\n value && typeof value === 'object' && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : {}\n\n/**\n * Makes the copy. The caller has authenticated `uid` and verified that they\n * may write content on `hostId`; this decides everything after that.\n */\nexport async function duplicateResource(\n kind: DuplicableHostResourceKind,\n options: DuplicateResourceOptions,\n): Promise<DuplicateResourceResult> {\n const recipe = RECIPES[kind]\n const noun = DUPLICABLE_RESOURCE_NOUNS[kind]\n const sourceId = String(options.sourceId ?? '').trim().slice(0, 64)\n if (!sourceId) return { ok: false, status: 400, error: `Missing the ${noun} to copy` }\n const firestore = firebaseAdmin.app().firestore()\n\n const attemptKey = String(options.attemptKey ?? '').trim().slice(0, 200)\n const claimed = attemptKey\n ? await claimAttempt(firestore as never, {\n kind: `duplicate-${kind}`,\n scopeId: `${options.hostId}:${sourceId}`,\n orgId: options.orgId,\n key: attemptKey,\n busyMessage: DUPLICATE_BUSY_MESSAGE,\n })\n : null\n if (claimed && 'replay' in claimed) {\n return claimed.replay.body as DuplicateResourceResult\n }\n const claim = claimed && 'claim' in claimed ? claimed.claim : null\n\n try {\n const result = await copy(firestore, kind, recipe, { ...options, sourceId })\n if (result.ok) {\n await logResourceDuplicated(\n kind,\n { uid: options.uid, email: options.email ?? null },\n {\n orgId: options.orgId,\n hostId: options.hostId,\n source: { id: sourceId, name: result.sourceName },\n target: { id: result.id, name: result.name, versionId: result.versionId },\n },\n )\n const answer: DuplicateResourceResult = {\n ok: true,\n id: result.id,\n versionId: result.versionId,\n name: result.name,\n }\n await claim?.record(200, answer)\n return answer\n }\n // A refusal releases the key: the person fixes the cause — frees a slot,\n // picks another name — and presses the same button again.\n await claim?.release()\n return result\n } catch (error) {\n await claim?.release()\n throw error\n }\n}\n\ntype CopyOutcome =\n | { ok: true; id: string; versionId: string | null; name: string; sourceName: string }\n | { ok: false; status: number; error: string }\n\nasync function copy(\n firestore: FirebaseFirestore.Firestore,\n kind: DuplicableHostResourceKind,\n recipe: KindRecipe,\n options: DuplicateResourceOptions & { sourceId: string },\n): Promise<CopyOutcome> {\n const { hostId, sourceId, uid } = options\n const noun = DUPLICABLE_RESOURCE_NOUNS[kind]\n const hostRef = firestore.collection('hosts').doc(hostId)\n const collectionRef = hostRef.collection(recipe.collection)\n const sourceRef = collectionRef.doc(sourceId)\n const org =\n options.org ??\n (((await firestore.collection('orgs').doc(options.orgId).get()).data() ??\n null) as Record<string, unknown> | null)\n\n if (recipe.entitlement && !checkEntitlement(org as never, recipe.entitlement)) {\n return {\n ok: false,\n status: 403,\n error: 'This feature is not included in your plan — see Billing',\n }\n }\n\n const id = createResourceUid()\n const versionId = recipe.versionParentField ? createResourceUid() : null\n\n return firestore.runTransaction(async (tx): Promise<CopyOutcome> => {\n // ALL READS BEFORE ANY WRITE, which Firestore requires. The host is read\n // inside the transaction rather than passed in because its routing map\n // is an input to the screen count and is ordinary client-writable.\n const siblingFields = [\n recipe.nameField,\n 'slug',\n 'kind',\n 'deletedAt',\n 'source.type',\n ]\n const [hostSnapshot, source, siblingsSnapshot] = await Promise.all([\n tx.get(hostRef),\n tx.get(sourceRef),\n tx.get(collectionRef.select(...siblingFields)),\n ])\n if (!hostSnapshot.exists) return { ok: false, status: 404, error: 'Unknown site' }\n if (!source.exists || source.get('deletedAt') != null) {\n return { ok: false, status: 404, error: `Unknown ${noun}` }\n }\n const data = asRecord(source.data())\n\n // The two screen kinds share a collection and are told apart by `kind`:\n // an email design copied as a page would land on the Screens list, and\n // the other way round would put a page on the Emails page.\n //\n // Of the screens that are not pages, only a collection's entry template\n // may be copied as a screen (AGL-3102), and the copy stays a template:\n // `kind` rides across with the recipe's fields, so it composes nothing\n // until a collection points at it — which is how a second collection\n // starts from the first one's design. An error screen is refused:\n // `kind: 'error'` is the slot assignment's to stamp, because that write\n // is what bounds how many a site holds, and a copy would stamp one\n // outside it.\n const sourceKind = data['kind']\n const nonPageScreen =\n recipe.collection === 'screens' &&\n !screenClaimsToBeAPage({ kind: sourceKind as string })\n if (recipe.collection === 'screens') {\n if (kind === 'emailDesign' && sourceKind !== SCREEN_KIND_EMAIL) {\n return { ok: false, status: 400, error: 'That screen is not an email design' }\n }\n if (kind === 'screen' && nonPageScreen && sourceKind !== SCREEN_KIND_TEMPLATE) {\n return {\n ok: false,\n status: 400,\n error: 'Only a page or a collection entry template can be duplicated as a screen',\n }\n }\n }\n\n const siblings: SiblingRow[] = siblingsSnapshot.docs.map((row) => ({\n id: row.id,\n name: String(row.get(recipe.nameField) ?? ''),\n slug: String(row.get('slug') ?? ''),\n kind: row.get('kind'),\n deletedAt: row.get('deletedAt'),\n sourceType: row.get('source.type'),\n }))\n const live = siblings.filter((row) => row.deletedAt == null)\n\n // THE BAND, met inside the transaction (AGL-2231) with the create route's\n // own arithmetic: screens count what the routing map makes billable,\n // templates exclude the platform's starters, everything else counts its\n // live documents. A screen copy that is not a page — an email design or\n // an entry template — meets the flat platform ceiling on non-page screens\n // instead, the one an email design's create meets and a demoted template\n // fills, and never the page band: it spends none of it.\n const screenRows: BillableScreenSource[] = siblings.map((row) => ({\n id: row.id,\n kind: row.kind,\n deletedAt: row.deletedAt,\n }))\n const routingMap = hostSnapshot.get('screens') as never\n if (nonPageScreen) {\n if (nonPageScreenIds(screenRows, routingMap).size >= NON_PAGE_SCREEN_MAX_PER_HOST) {\n return {\n ok: false,\n status: 403,\n error:\n 'This site is at its limit of ' +\n `${NON_PAGE_SCREEN_MAX_PER_HOST} email and template screens — ` +\n 'delete some to make room',\n }\n }\n } else if (recipe.quotaKey) {\n const used =\n kind === 'screen'\n ? billableScreenIds(screenRows, routingMap).size\n : kind === 'template'\n ? live.filter((row) => row.sourceType !== 'starter').length\n : live.length\n const quota = checkQuota(org as never, recipe.quotaKey as never, used)\n if (!quota.allowed) {\n return {\n ok: false,\n status: 403,\n error:\n `Your plan includes ${quota.limit} ${noun}s — ` +\n 'upgrade in Billing for more',\n }\n }\n }\n\n // THE NAME AND THE SLUG, unique among the live siblings. A deleted\n // sibling's name is free: a person who deleted \"Home\" and copies\n // \"About\" as \"Home\" is not confused by a tombstone.\n const sourceName = String(data[recipe.nameField] ?? '').trim()\n const requested = String(options.name ?? '').trim() || duplicateDisplayName(sourceName)\n const name = uniqueDuplicateName(\n requested,\n live.map((row) => row.name),\n )\n const slug = recipe.slug\n ? uniqueDuplicateSlug(\n data['slug'] as string | undefined,\n live.map((row) => row.slug),\n )\n : undefined\n\n // THE VERSION: the most recently saved one, else the one the document\n // points at. Read inside the transaction so the copy is of what was\n // saved when the count was taken.\n let versionData: Record<string, unknown> | null = null\n let sourceVersionNumber: number | null = null\n if (recipe.versionParentField) {\n const versionsRef = sourceRef.collection('versions')\n const [newest, all] = await Promise.all([\n tx.get(versionsRef.orderBy('updatedAt', 'desc').limit(1)),\n tx.get(versionsRef.select()),\n ])\n let found: FirebaseFirestore.DocumentSnapshot | null = newest.docs[0] ?? null\n const pointer = String(data['versionId'] ?? '')\n if (!found && pointer) {\n const pointed = await tx.get(versionsRef.doc(pointer))\n if (pointed.exists) found = pointed\n }\n if (found) {\n versionData = asRecord(found.data())\n sourceVersionNumber = all.size\n }\n }\n\n // THE DOCUMENT: the recipe's fields, the name, the slug, the stamps.\n const doc: Record<string, unknown> = {}\n for (const field of recipe.fields) {\n if (data[field] !== undefined) doc[field] = data[field]\n }\n doc[recipe.nameField] = name\n if (recipe.slug) {\n if (slug) doc['slug'] = slug\n else delete doc['slug']\n }\n /*\n * THE FORM'S BINDING, moved onto the copy (AGL-3024).\n *\n * A form's design names its own form in a prop, and that name is what\n * `/api/forms/submit` files a submission under. Copied verbatim it still\n * names the SOURCE, so the copy renders, collects, and files everything\n * it collects under the form it was copied from — while its own list\n * stays empty and nothing anywhere reports an error. `checkFormContract`\n * is what the console's form page banners it with; `formDesignReboundTo`\n * is the write side of the same fact.\n *\n * This is the one place a copied tree is not carried across untouched,\n * and the exception is narrow on purpose: no other kind here embeds its\n * own id in its design. `formDesignReboundTo` answers `null` when there\n * is nothing to move, so every other kind — and a form whose design is\n * already correct — pays nothing for this.\n */\n const rebound = (raw: unknown): unknown => {\n if (kind !== 'form') return raw\n return (\n formDesignReboundTo(decodeStoredNodes(raw), {\n formId: id,\n formName: name,\n }) ?? raw\n )\n }\n // The stored tree is msgpack at rest; `encodeStoredNodes` passes encoded\n // bytes through and packs a plain map, so a legacy plain source lands\n // compressed like every create does — and so does a rebound one, which\n // comes back decoded.\n const packed = encodeStoredNodes(rebound(doc['nodes']))\n if (packed) doc['nodes'] = Buffer.from(packed)\n tx.create(collectionRef.doc(id), {\n ...doc,\n ...(recipe.stamps ?? {}),\n // The keys the copy's list finds it by (AGL-3321), from the copy's own\n // name and stamps — see `artifactCreateListKeys`.\n ...artifactCreateListKeys(recipe.collection, { ...doc, ...(recipe.stamps ?? {}) }),\n // A copied form starts as a new one does, with its own search keys and\n // nothing counted (AGL-3330); only the routing it copied is kept.\n ...(kind === 'form'\n ? newFormListFields({\n id,\n displayName: name,\n slug: doc['slug'],\n routing: doc['routing'] as Parameters<typeof newFormListFields>[0]['routing'],\n })\n : {}),\n ...(versionData && versionId ? { versionId } : {}),\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n createdBy: uid,\n })\n if (versionData && versionId && recipe.versionParentField) {\n const versionNodes = encodeStoredNodes(rebound(versionData['nodes']))\n tx.create(collectionRef.doc(id).collection('versions').doc(versionId), {\n ...versionData,\n ...(versionNodes ? { nodes: Buffer.from(versionNodes) } : {}),\n [recipe.versionParentField]: id,\n hostId,\n displayName: duplicateVersionNote(sourceName, sourceVersionNumber),\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n createdBy: uid,\n })\n }\n return {\n ok: true,\n id,\n versionId: versionData && versionId ? versionId : null,\n name,\n sourceName,\n }\n })\n}\n"],"names":["artifactCreateListKeys","billableScreenIds","checkEntitlement","checkQuota","claimAttempt","createResourceUid","decodeStoredNodes","encodeStoredNodes","newFormListFields","NON_PAGE_SCREEN_MAX_PER_HOST","nonPageScreenIds","SCREEN_KIND_EMAIL","SCREEN_KIND_TEMPLATE","screenClaimsToBeAPage","DUPLICABLE_RESOURCE_NOUNS","DUPLICATE_BUSY_MESSAGE","duplicateDisplayName","duplicateVersionNote","uniqueDuplicateName","uniqueDuplicateSlug","formDesignReboundTo","Timestamp","logResourceDuplicated","firebaseAdmin","RECIPES","screen","collection","nameField","fields","versionParentField","quotaKey","slug","emailDesign","component","entitlement","layout","template","stamps","source","type","form","workflow","trigger","asRecord","value","Array","isArray","duplicateResource","kind","options","recipe","noun","sourceId","String","trim","slice","ok","status","error","firestore","app","attemptKey","claimed","scopeId","hostId","orgId","key","busyMessage","replay","body","claim","result","copy","uid","email","id","name","sourceName","target","versionId","answer","record","release","hostRef","doc","collectionRef","sourceRef","org","get","data","runTransaction","tx","siblingFields","hostSnapshot","siblingsSnapshot","Promise","all","select","exists","sourceKind","nonPageScreen","siblings","docs","map","row","deletedAt","sourceType","live","filter","screenRows","routingMap","size","used","length","quota","allowed","limit","requested","undefined","versionData","sourceVersionNumber","newest","versionsRef","orderBy","found","pointer","pointed","field","rebound","raw","formId","formName","packed","Buffer","from","create","displayName","routing","createdAt","now","updatedAt","createdBy","versionNodes","nodes"],"mappings":"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"}
|
|
@@ -26,6 +26,31 @@ export declare const EMAIL_DELIVERY_READ_LIMIT = 50;
|
|
|
26
26
|
* document without bound; the first few tell a staffer what they need.
|
|
27
27
|
*/
|
|
28
28
|
export declare const EMAIL_DELIVERY_MAX_LINKS = 10;
|
|
29
|
+
/**
|
|
30
|
+
* The field a message's search tokens are stored in (AGL-3321): the staff
|
|
31
|
+
* account page's delivery table searches it with `array-contains` on one
|
|
32
|
+
* typed word, and its Message filter's "contains" reads it too.
|
|
33
|
+
*/
|
|
34
|
+
export declare const EMAIL_DELIVERY_SEARCH_FIELD = "searchTokens";
|
|
35
|
+
/** The most tokens one message stores; a long subject loses reach past it. */
|
|
36
|
+
export declare const EMAIL_DELIVERY_SEARCH_TOKEN_LIMIT = 200;
|
|
37
|
+
/**
|
|
38
|
+
* A message's search tokens: its recipient the way a suppression's address
|
|
39
|
+
* is tokenized (`emailSearchTokens` — the whole address, its domain and each
|
|
40
|
+
* piece of it), then the word prefixes of its subject and of its sender tag.
|
|
41
|
+
* In that order, deduplicated, capped.
|
|
42
|
+
*
|
|
43
|
+
* Every writer stamps it from the message's merged state, so a subject that
|
|
44
|
+
* arrives on a later event than the first still becomes searchable, and
|
|
45
|
+
* `tools/scripts/backfill-email-delivery-search.mjs` stamps the messages
|
|
46
|
+
* written before the field. Both are held to
|
|
47
|
+
* `tools/scripts/lib/email-search-tokens.fixtures.json`.
|
|
48
|
+
*/
|
|
49
|
+
export declare function emailDeliverySearchTokens(message: {
|
|
50
|
+
to?: unknown;
|
|
51
|
+
subject?: unknown;
|
|
52
|
+
context?: unknown;
|
|
53
|
+
}): string[];
|
|
29
54
|
/** One message as the staff view reads it. */
|
|
30
55
|
export interface EmailDeliveryRecord {
|
|
31
56
|
/** The provider's message id — also the document id. */
|
|
@@ -303,6 +328,14 @@ export declare function readEmailDeliveries(email: string | null | undefined, op
|
|
|
303
328
|
limit?: number;
|
|
304
329
|
firestore?: any;
|
|
305
330
|
}): Promise<EmailDeliveryRecord[]>;
|
|
331
|
+
/**
|
|
332
|
+
* One stored message document as {@link EmailDeliveryRecord}.
|
|
333
|
+
*
|
|
334
|
+
* Shared by every reader in this file so the defaults are decided once. A
|
|
335
|
+
* second copy would be a second answer to "what does an absent `openCount`
|
|
336
|
+
* mean", and the two would drift the first time a field is added.
|
|
337
|
+
*/
|
|
338
|
+
export declare function deliveryRecordFrom(doc: any): EmailDeliveryRecord;
|
|
306
339
|
/**
|
|
307
340
|
* {@link readEmailDeliveries} with the read failure kept separate from an
|
|
308
341
|
* empty result.
|