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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/package.json +9 -9
  2. package/src/lib/server/account-lock-mail.d.ts +74 -0
  3. package/src/lib/server/account-lock-mail.js +139 -0
  4. package/src/lib/server/account-lock-mail.js.map +1 -0
  5. package/src/lib/server/account-mail.d.ts +41 -0
  6. package/src/lib/server/account-mail.js +52 -0
  7. package/src/lib/server/account-mail.js.map +1 -0
  8. package/src/lib/server/api-v1-description.d.ts +112 -0
  9. package/src/lib/server/api-v1-description.js +126 -0
  10. package/src/lib/server/api-v1-description.js.map +1 -0
  11. package/src/lib/server/api-v1-kit.d.ts +156 -0
  12. package/src/lib/server/api-v1-kit.js +191 -0
  13. package/src/lib/server/api-v1-kit.js.map +1 -0
  14. package/src/lib/server/api-v1-resources.d.ts +144 -0
  15. package/src/lib/server/api-v1-resources.js +126 -0
  16. package/src/lib/server/api-v1-resources.js.map +1 -0
  17. package/src/lib/server/consent-group-change.d.ts +2 -1
  18. package/src/lib/server/consent-group-change.js +41 -16
  19. package/src/lib/server/consent-group-change.js.map +1 -1
  20. package/src/lib/server/contact-email-index.d.ts +22 -0
  21. package/src/lib/server/contact-email-index.js +108 -0
  22. package/src/lib/server/contact-email-index.js.map +1 -1
  23. package/src/lib/server/crm-email-activity.d.ts +1 -1
  24. package/src/lib/server/crm-email-activity.js +1 -1
  25. package/src/lib/server/crm-email-activity.js.map +1 -1
  26. package/src/lib/server/crm-inbound-email.d.ts +23 -0
  27. package/src/lib/server/crm-inbound-email.js +1 -16
  28. package/src/lib/server/crm-inbound-email.js.map +1 -1
  29. package/src/lib/server/crm-records.d.ts +9 -1
  30. package/src/lib/server/crm-records.js +19 -2
  31. package/src/lib/server/crm-records.js.map +1 -1
  32. package/src/lib/server/cron-auth.d.ts +44 -0
  33. package/src/lib/server/cron-auth.js +76 -0
  34. package/src/lib/server/cron-auth.js.map +1 -0
  35. package/src/lib/server/dataset-live-pages.js +6 -6
  36. package/src/lib/server/dataset-live-pages.js.map +1 -1
  37. package/src/lib/server/duplicate-resource.d.ts +26 -0
  38. package/src/lib/server/duplicate-resource.js +47 -25
  39. package/src/lib/server/duplicate-resource.js.map +1 -1
  40. package/src/lib/server/dynamic-list-materialize.js +4 -1
  41. package/src/lib/server/dynamic-list-materialize.js.map +1 -1
  42. package/src/lib/server/email-deliverability.js +21 -6
  43. package/src/lib/server/email-deliverability.js.map +1 -1
  44. package/src/lib/server/email-suppression.d.ts +83 -3
  45. package/src/lib/server/email-suppression.js +229 -3
  46. package/src/lib/server/email-suppression.js.map +1 -1
  47. package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
  48. package/src/lib/server/email-unsubscribe-link.js +7 -7
  49. package/src/lib/server/email-unsubscribe-link.js.map +1 -1
  50. package/src/lib/server/erase.d.ts +19 -33
  51. package/src/lib/server/erase.js +37 -162
  52. package/src/lib/server/erase.js.map +1 -1
  53. package/src/lib/server/host-email-tokens.js +1 -1
  54. package/src/lib/server/host-email-tokens.js.map +1 -1
  55. package/src/lib/server/host-memberships.js +3 -3
  56. package/src/lib/server/host-memberships.js.map +1 -1
  57. package/src/lib/server/lockdown.js +2 -0
  58. package/src/lib/server/lockdown.js.map +1 -1
  59. package/src/lib/server/media-download-tokens.js +2 -1
  60. package/src/lib/server/media-download-tokens.js.map +1 -1
  61. package/src/lib/server/member-email-aliases.d.ts +1 -1
  62. package/src/lib/server/member-email-aliases.js +1 -1
  63. package/src/lib/server/member-email-aliases.js.map +1 -1
  64. package/src/lib/server/new-account-announcement.d.ts +62 -0
  65. package/src/lib/server/new-account-announcement.js +77 -0
  66. package/src/lib/server/new-account-announcement.js.map +1 -0
  67. package/src/lib/server/notifications.d.ts +8 -2
  68. package/src/lib/server/notifications.js +18 -4
  69. package/src/lib/server/notifications.js.map +1 -1
  70. package/src/lib/server/org-member-notice.js +5 -1
  71. package/src/lib/server/org-member-notice.js.map +1 -1
  72. package/src/lib/server/organizations.js +4 -0
  73. package/src/lib/server/organizations.js.map +1 -1
  74. package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
  75. package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +1 -1
  76. package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
  77. package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
  78. package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
  79. package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
  80. package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
  81. package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
  82. package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
  83. package/src/lib/server/payment-provider.d.ts +173 -0
  84. package/src/lib/server/payment-provider.js +70 -0
  85. package/src/lib/server/payment-provider.js.map +1 -0
  86. package/src/lib/server/personal-data-export.js +1 -1
  87. package/src/lib/server/personal-data-export.js.map +1 -1
  88. package/src/lib/server/rate-limit-store.js +13 -3
  89. package/src/lib/server/rate-limit-store.js.map +1 -1
  90. package/src/lib/server/realm-plugins.js +8 -3
  91. package/src/lib/server/realm-plugins.js.map +1 -1
  92. package/src/lib/server/retained-refusals.d.ts +142 -0
  93. package/src/lib/server/retained-refusals.js +325 -0
  94. package/src/lib/server/retained-refusals.js.map +1 -0
  95. package/src/lib/server/risk-notice.js +21 -3
  96. package/src/lib/server/risk-notice.js.map +1 -1
  97. package/src/lib/server/serve-media-cdn.js +2 -2
  98. package/src/lib/server/serve-media-cdn.js.map +1 -1
  99. package/src/lib/server/serve-plugin-fetch.d.ts +17 -7
  100. package/src/lib/server/serve-plugin-fetch.js +19 -8
  101. package/src/lib/server/serve-plugin-fetch.js.map +1 -1
  102. package/src/lib/server/test-firestore-queries.d.ts +7 -0
  103. package/src/lib/server/test-firestore-queries.js +70 -27
  104. package/src/lib/server/test-firestore-queries.js.map +1 -1
  105. package/src/lib/server/upsert-contact.d.ts +46 -1
  106. package/src/lib/server/upsert-contact.js +85 -18
  107. package/src/lib/server/upsert-contact.js.map +1 -1
  108. package/src/tenancy.d.ts +4 -5
  109. package/src/tenancy.js +11 -6
  110. package/src/tenancy.js.map +1 -1
  111. package/src/lib/server/connect-account-status.js.map +0 -1
  112. package/src/lib/server/connect-payout-failure.js.map +0 -1
  113. package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
  114. package/src/lib/server/contact-lifecycle-floor.js +0 -91
  115. package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
  116. package/src/lib/server/contact-merge.d.ts +0 -55
  117. package/src/lib/server/contact-merge.js +0 -288
  118. package/src/lib/server/contact-merge.js.map +0 -1
  119. package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
  120. package/src/lib/server/form-dataset-binding-token.js +0 -107
  121. package/src/lib/server/form-dataset-binding-token.js.map +0 -1
  122. package/src/lib/server/stripe-account-mode.js.map +0 -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'\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":"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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 { notifyPluginRecordWritten } from '@aglyn/aglyn/plugin-manager/plugin-record-written'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * The CRM RECORDS band's measurement (AGL-2611): the three collections it\n * counts, and their sum.\n *\n * One shape for every server that asks, because the band is enforced at six\n * doors — contact capture, the console's company and deal drawers (through\n * the client twin of this read), the CSV import, the lead conversion and the\n * two REST creates — and the monthly rollup bills from the same three\n * aggregates. A door that counted contacts alone would admit a company on a\n * band the rollup had already found full.\n */\nexport interface CrmRecordsCount {\n contactsCount: number\n companiesCount: number\n dealsCount: number\n /** The band's own figure: the three above, summed. */\n crmRecordsCount: number\n}\n\n/**\n * Count the records band for one org: three `count()` aggregates, in\n * parallel, no document reads.\n *\n * `contacts` may be handed in by a caller that already holds the collection\n * reference — `upsertHostContact` resolves it through `orgDataCollection`\n * before it decides anything — so the read is the same one whichever door\n * it came through, and the org reference is needed only for the two\n * collections the hub added.\n */\nexport async function countCrmRecords(\n orgRef: FirebaseFirestore.DocumentReference,\n contacts: FirebaseFirestore.CollectionReference = orgRef.collection('contacts'),\n): Promise<CrmRecordsCount> {\n const [contactsSnap, companiesSnap, dealsSnap] = await Promise.all([\n contacts.count().get(),\n orgRef.collection(CRM_COLLECTIONS.companies).count().get(),\n orgRef.collection(CRM_COLLECTIONS.deals).count().get(),\n ])\n const contactsCount = aggregateCount(contactsSnap)\n const companiesCount = aggregateCount(companiesSnap)\n const dealsCount = aggregateCount(dealsSnap)\n return {\n contactsCount,\n companiesCount,\n dealsCount,\n crmRecordsCount: contactsCount + companiesCount + dealsCount,\n }\n}\n\n/**\n * The band's verdict for one org, measured now — the read every create\n * door takes before it writes, so the six doors cannot disagree about\n * whether there is room. `checkCrmRecordsQuota` answers from the org doc\n * alone; this pairs it with the measurement.\n */\nexport async function crmRecordsQuotaForOrg(\n org: Partial<AglynOrgBilling> | null | undefined,\n orgRef: FirebaseFirestore.DocumentReference,\n contacts?: FirebaseFirestore.CollectionReference,\n): Promise<CrmRecordsQuotaResult & CrmRecordsCount> {\n const counts = await countCrmRecords(orgRef, contacts)\n return { ...checkCrmRecordsQuota(org, counts.crmRecordsCount), ...counts }\n}\n\n/** `count()` answers through `.data().count`; a missing figure is zero, never NaN. */\nfunction aggregateCount(snapshot: { data(): { count?: unknown } }): number {\n const value = Number(snapshot.data()?.count ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * How many activities one record already carries — one aggregate on a\n * single-field equality, which Firestore indexes on its own, so this needs\n * no composite index and cannot go FAILED_PRECONDITION in production.\n */\nexport async function countCrmActivitiesForRecord(\n orgRef: FirebaseFirestore.DocumentReference,\n link: CrmActivityLink,\n): Promise<number> {\n const lead = crmActivityCeilingLink(link)\n if (!lead) return 0\n const snapshot = await orgRef\n .collection(CRM_COLLECTIONS.activities)\n .where(lead.field, '==', lead.id)\n .count()\n .get()\n return aggregateCount(snapshot)\n}\n\n/**\n * `orgs/{orgId}/crmEmailUsage/{day}` — one UTC day's one-to-one email\n * counter, the document `checkCrmEmailQuota` is enforced against and the\n * billing page reads.\n */\nfunction crmEmailUsageRefForDay(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n day: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_EMAIL_USAGE_COLLECTION)\n .doc(day)\n}\n\n/** Today's counter document — {@link crmEmailUsageRefForDay} keyed by `now`. */\nexport function crmEmailUsageRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n now: Date = new Date(),\n): FirebaseFirestore.DocumentReference {\n return crmEmailUsageRefForDay(firestore, orgId, crmEmailUsageDayKey(now))\n}\n\n/**\n * The counter a day-document holds, clamped.\n *\n * A malformed or absent counter reads as ZERO SENT, which is the permissive\n * direction — deliberately, and the same clamp `checkApiRequestQuota`'s\n * reader applies: a counter that cannot be read must not refuse a rep who\n * has sent nothing, and the cap is a pace rather than a cost.\n */\nfunction crmEmailCount(snapshot: { get(field: string): unknown }): number {\n const value = Number(snapshot.get('count') ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * How many one-to-one emails the org has sent today, for a meter — never\n * for the cap, which {@link reserveCrmEmailSend} judges inside the\n * transaction that also takes the slot.\n */\nexport async function crmEmailsSentToday(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n now: Date = new Date(),\n): Promise<number> {\n return crmEmailCount(await crmEmailUsageRef(firestore, orgId, now).get())\n}\n\n/** One slot on one day's counter, held from the reservation to the send. */\nexport interface CrmEmailSendReservation {\n orgId: string\n /**\n * The UTC day the slot was taken on, so a release after midnight gives\n * the slot back to the day that lent it rather than to the new day.\n */\n day: string\n}\n\nexport type ReserveCrmEmailSendResult =\n | { ok: true; reservation: CrmEmailSendReservation; quota: CrmEmailQuotaResult }\n | { ok: false; quota: CrmEmailQuotaResult }\n\n/**\n * The plan's day, narrowed to the share of it a young workspace has earned.\n *\n * The new-sender ramp reaches the one-to-one path here and only here, so the\n * ceiling the transaction below judges against is one value with one origin.\n * The alternative — a full-allowance reservation with a ramp consulted beside\n * it — is two ceilings for one decision, and under two concurrent senders it\n * admits against whichever of them was read first.\n *\n * `included` is the only field the ramp moves; `resetsAt` is the day's, which\n * a share does not change, and `used` is what the transaction read.\n */\nfunction rampedCrmEmailQuota(\n plan: CrmEmailQuotaResult,\n ramp: EmailRampVerdict | null | undefined,\n): CrmEmailQuotaResult {\n const included = rampedDailyAllowance(plan.included, ramp)\n if (included >= plan.included) return plan\n return {\n ...plan,\n included,\n allowed: plan.used < included,\n remaining: Math.max(0, included - plan.used),\n }\n}\n\n/**\n * Claims ONE one-to-one send against today's cap, ATOMICALLY (AGL-2645).\n *\n * The counter used to be read before the send and incremented after\n * delivery, so two reps sending in the same window both passed the same\n * reading and both sent — `CRM_EMAIL_SENDS_PER_MINUTE` past the cap per\n * sender, in exactly the conditions the cap exists for. **A read-then-write\n * cap is not a cap**, the lesson `reserveCampaignEmailSends` learned for\n * campaigns; this is the same shape on the day counter.\n *\n * The transaction reads the counter, judges it with `checkCrmEmailQuota` —\n * the real plan table, `UNLIMITED` on Enterprise, zero on a tier with no\n * suite — and writes an ABSOLUTE value derived from that read, deliberately\n * not `FieldValue.increment`. Firestore aborts and re-runs the callback\n * when a document the transaction read has moved, so a second sender that\n * starts inside the first one's window re-reads the raised figure and is\n * refused. An increment would be atomic on the number and useless for the\n * decision, because the decision is made from a value the write never\n * proves it still held.\n *\n * A refused reservation writes NOTHING. A taken one is the count the cap\n * enforces and the billing page shows, from the moment it is taken: a send\n * the provider then refuses hands the slot back through\n * {@link releaseCrmEmailSend}, and a process that dies between the two\n * leaves the org one send short for the rest of the UTC day — conservative\n * in the direction a cap cares about, and healed at midnight, because each\n * day is its own document.\n *\n * ## The new-sender ramp rides the same read\n *\n * `ramp` narrows the plan's day to the share of it a young workspace has\n * earned, INSIDE the transaction and before the counter is judged — see\n * {@link rampedCrmEmailQuota}. Applying it here rather than in the caller is\n * the whole of what makes it safe: the ceiling and the count it is compared\n * against come out of one read, so the retry Firestore forces on a losing\n * transaction re-derives both. A ramp checked outside would be a second\n * ceiling, decided from a snapshot the write never proves it still held —\n * the defect this reservation was built to remove, one field over.\n *\n * The ramp is optional and an absent one is not a throttle: a caller that\n * did not resolve one gets the plan's day, which is what every surface but\n * the send path wants.\n */\nexport async function reserveCrmEmailSend(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n org: Partial<AglynOrgBilling> | null | undefined,\n now: Date = new Date(),\n ramp?: EmailRampVerdict | null,\n): Promise<ReserveCrmEmailSendResult> {\n const day = crmEmailUsageDayKey(now)\n const ref = crmEmailUsageRefForDay(firestore, orgId, day)\n return firestore.runTransaction(async (tx) => {\n const quota = rampedCrmEmailQuota(\n checkCrmEmailQuota(org, crmEmailCount(await tx.get(ref)), now),\n ramp,\n )\n if (!quota.allowed) return { ok: false, quota }\n tx.set(\n ref,\n { count: quota.used + 1, day, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n return { ok: true, reservation: { orgId, day }, quota }\n })\n}\n\n/**\n * Gives back a slot the provider did not use.\n *\n * Also a transaction, and also an absolute write from its own read: a\n * decrement computed from a stale figure would undo a slot another rep took\n * in the meantime, which is the same defect one direction over. Never\n * drives the counter below zero.\n *\n * **Never throws.** This runs on the failure path of a send that was\n * already refused, and the answer the rep gets is the provider's refusal; a\n * bookkeeping failure on top of it is logged, and its cost is one slot the\n * org does not get back until midnight — the safe direction.\n */\nexport async function releaseCrmEmailSend(\n firestore: FirebaseFirestore.Firestore,\n reservation: CrmEmailSendReservation | null | undefined,\n): Promise<void> {\n if (!reservation?.orgId || !reservation.day) return\n const ref = crmEmailUsageRefForDay(firestore, reservation.orgId, reservation.day)\n try {\n await firestore.runTransaction(async (tx) => {\n const used = crmEmailCount(await tx.get(ref))\n tx.set(\n ref,\n {\n count: Math.max(0, used - 1),\n day: reservation.day,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.error('crm email reservation release failed', error)\n }\n}\n\n/*==========================================\n * THE LIST FIELDS, RESTAMPED FROM WHAT WAS STORED (AGL-3321).\n *\n * A CRM list queries fields derived from the record — its search tokens, a\n * lead's status and verdict keys, a contact's facet keys (`crmListFields`\n * in `@aglyn/aglyn`). A writer that holds the whole document spreads them;\n * a writer that PATCHED an input — a dotted facet field, an `arrayUnion` of\n * tags, a merged capture — cannot know the result without reading it, so\n * it restamps here after its own write: the record is read back in a\n * transaction and only the list fields it now carries wrongly are written.\n *\n * Never throws. A restamp is a consequence of a write that already landed,\n * and failing the caller would report the write as failed; a record left\n * stale is what the one-time backfill (`backfill-crm-list-fields.mjs`)\n * brings level, and the next write to it restamps it again.\n *=========================================*/\n\nexport interface CrmListFieldsRestamp {\n /** Records whose list fields were written. */\n restamped: number\n /** Records already current. */\n current: number\n /** Records that no longer exist. */\n missing: number\n}\n\n/**\n * Restamp one record's list fields from the record as stored.\n *\n * Then tell the plugins a record was written (AGL-3336): every server writer\n * of an org record passes here after its write, so this is the one place a\n * plugin that acts on the record AFTER any write — the CRM's sharing rules —\n * is told, without an edit to each writer. Not for a record that no longer\n * exists, and after the restamp's own transaction, never inside it.\n */\nexport async function restampCrmListFieldsAt(\n ref: FirebaseFirestore.DocumentReference,\n collection: CrmListCollection,\n): Promise<'restamped' | 'current' | 'missing'> {\n let outcome: 'restamped' | 'current' | 'missing'\n try {\n outcome = await ref.firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(ref)\n if (!snapshot.exists) return 'missing' as const\n const patch = crmListFieldsPatch(collection, snapshot.data() ?? {})\n if (!Object.keys(patch).length) return 'current' as const\n tx.update(ref, patch)\n return 'restamped' as const\n })\n } catch (error) {\n console.error(`[crm] list fields restamp failed for ${ref.path}`, error)\n return 'current'\n }\n if (outcome !== 'missing') {\n await notifyPluginRecordWritten({ path: ref.path, collection })\n }\n return outcome\n}\n\n/** Restamp several records of one collection, a few at a time. */\nexport async function restampCrmListFieldsOf(\n refs: readonly FirebaseFirestore.DocumentReference[],\n collection: CrmListCollection,\n): Promise<CrmListFieldsRestamp> {\n const result: CrmListFieldsRestamp = { restamped: 0, current: 0, missing: 0 }\n const queue = [...new Map(refs.map((ref) => [ref.path, ref])).values()]\n // Each is its own small transaction on its own record.\n const worker = async () => {\n for (let ref = queue.shift(); ref !== undefined; ref = queue.shift()) {\n const outcome = await restampCrmListFieldsAt(ref, collection)\n result[outcome] += 1\n }\n }\n await Promise.all(Array.from({ length: Math.min(RESTAMP_CONCURRENCY, queue.length) }, worker))\n return result\n}\n\n/** Restamp several records of one collection under `orgs/{orgId}/`, by id. */\nexport async function restampCrmListFields(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n collection: CrmListCollection,\n ids: readonly string[],\n): Promise<CrmListFieldsRestamp> {\n const records = firestore.collection('orgs').doc(orgId).collection(collection)\n return restampCrmListFieldsOf(\n [...new Set(ids.filter(Boolean))].map((id) => records.doc(id)),\n collection,\n )\n}\n\n/** Records restamped at once by {@link restampCrmListFields}. */\nconst RESTAMP_CONCURRENCY = 8\n"],"names":["checkCrmEmailQuota","checkCrmRecordsQuota","CRM_COLLECTIONS","CRM_EMAIL_USAGE_COLLECTION","crmActivityCeilingLink","crmEmailUsageDayKey","crmListFieldsPatch","rampedDailyAllowance","notifyPluginRecordWritten","FieldValue","countCrmRecords","orgRef","contacts","collection","contactsSnap","companiesSnap","dealsSnap","Promise","all","count","get","companies","deals","contactsCount","aggregateCount","companiesCount","dealsCount","crmRecordsCount","crmRecordsQuotaForOrg","org","counts","snapshot","value","Number","data","isFinite","Math","floor","countCrmActivitiesForRecord","link","lead","activities","where","field","id","crmEmailUsageRefForDay","firestore","orgId","day","doc","crmEmailUsageRef","now","Date","crmEmailCount","crmEmailsSentToday","rampedCrmEmailQuota","plan","ramp","included","allowed","used","remaining","max","reserveCrmEmailSend","ref","runTransaction","tx","quota","ok","set","updatedAt","serverTimestamp","merge","reservation","releaseCrmEmailSend","error","console","restampCrmListFieldsAt","outcome","exists","patch","Object","keys","length","update","path","restampCrmListFieldsOf","refs","result","restamped","current","missing","queue","Map","map","values","worker","shift","undefined","Array","from","min","RESTAMP_CONCURRENCY","restampCrmListFields","ids","records","Set","filter","Boolean"],"mappings":"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"}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ export declare function isCronAuthorized(headers: Partial<Record<string, string>>): boolean;
18
+ /**
19
+ * Is this scheduled-route invocation a DRY RUN? (AGL-2084)
20
+ *
21
+ * `reap-plugin-artifacts` and `reverify-plugin-versions` each chose this rule
22
+ * deliberately and each spelled it out again inline, and `audit-archive` — the
23
+ * route that DELETES Firestore audit rows — was the copy that never got
24
+ * written. Four transcriptions of four lines is how the guard goes missing, so
25
+ * it lives here once and the routes call it.
26
+ *
27
+ * The rule:
28
+ * - An explicit `dryRun` wins, from the body or the query string, in BOTH
29
+ * directions: `?dryRun=0` on a GET writes, `{ dryRun: true }` on a POST
30
+ * does not. `'0'`, `'false'` and `false` are the only ways to turn it off,
31
+ * matching what the two original routes accepted.
32
+ * - With no `dryRun` at all the METHOD decides: a GET reports, a POST acts.
33
+ *
34
+ * Keying the default on the method rather than on the body being absent is the
35
+ * load-bearing half. `scheduled-crons.yml` fires every one of these routes with
36
+ * a bodyless POST, so defaulting an absent `dryRun` to true would silently stop
37
+ * the archival, the reaping and the re-verification — a worse failure than the
38
+ * one this guard exists to prevent, and an invisible one.
39
+ */
40
+ export declare function isCronDryRun(request: {
41
+ method: string;
42
+ body?: unknown;
43
+ query?: Record<string, string | string[] | undefined>;
44
+ }): boolean;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * Authorizes a scheduled-route invocation (AGL-489), for every scheduled
18
+ * route the console serves: its own, and a plugin's served through the
19
+ * plugin API dispatcher (AGL-3080), which is why it lives in the data layer
20
+ * rather than in the app. Accepts either caller:
21
+ * - **Vercel Cron** — invokes the path with a GET and an auto-injected
22
+ * `Authorization: Bearer $CRON_SECRET` header (set when CRON_SECRET is
23
+ * configured on the project). This is how `apps/console/vercel.json`
24
+ * drives these routes in production.
25
+ * - **External scheduler** — a POST carrying `x-cron-secret: $CRON_SECRET`
26
+ * (the original contract, kept for GitHub Actions / manual runs).
27
+ *
28
+ * Returns false when CRON_SECRET is unset so an unconfigured deploy can't be
29
+ * triggered.
30
+ */ import { timingSafeEqual } from "crypto";
31
+ // Constant-time string equality (AGL-512) so secret checks don't leak via
32
+ // response-timing. Length is compared first (not itself secret).
33
+ function safeEqual(a, b) {
34
+ const ab = Buffer.from(a);
35
+ const bb = Buffer.from(b);
36
+ if (ab.length !== bb.length) return false;
37
+ return timingSafeEqual(new Uint8Array(ab), new Uint8Array(bb));
38
+ }
39
+ export function isCronAuthorized(headers) {
40
+ var _headers_authorization, _headers_xcronsecret;
41
+ const secret = process.env.CRON_SECRET;
42
+ if (!secret) return false;
43
+ if (safeEqual((_headers_authorization = headers.authorization) != null ? _headers_authorization : '', `Bearer ${secret}`)) return true;
44
+ if (safeEqual((_headers_xcronsecret = headers['x-cron-secret']) != null ? _headers_xcronsecret : '', secret)) return true;
45
+ return false;
46
+ }
47
+ /**
48
+ * Is this scheduled-route invocation a DRY RUN? (AGL-2084)
49
+ *
50
+ * `reap-plugin-artifacts` and `reverify-plugin-versions` each chose this rule
51
+ * deliberately and each spelled it out again inline, and `audit-archive` — the
52
+ * route that DELETES Firestore audit rows — was the copy that never got
53
+ * written. Four transcriptions of four lines is how the guard goes missing, so
54
+ * it lives here once and the routes call it.
55
+ *
56
+ * The rule:
57
+ * - An explicit `dryRun` wins, from the body or the query string, in BOTH
58
+ * directions: `?dryRun=0` on a GET writes, `{ dryRun: true }` on a POST
59
+ * does not. `'0'`, `'false'` and `false` are the only ways to turn it off,
60
+ * matching what the two original routes accepted.
61
+ * - With no `dryRun` at all the METHOD decides: a GET reports, a POST acts.
62
+ *
63
+ * Keying the default on the method rather than on the body being absent is the
64
+ * load-bearing half. `scheduled-crons.yml` fires every one of these routes with
65
+ * a bodyless POST, so defaulting an absent `dryRun` to true would silently stop
66
+ * the archival, the reaping and the re-verification — a worse failure than the
67
+ * one this guard exists to prevent, and an invisible one.
68
+ */ export function isCronDryRun(request) {
69
+ var _ref;
70
+ var _request_body, _request_query;
71
+ const requested = (_ref = (_request_body = request.body) == null ? void 0 : _request_body.dryRun) != null ? _ref : (_request_query = request.query) == null ? void 0 : _request_query['dryRun'];
72
+ if (requested === undefined) return request.method === 'GET';
73
+ return requested !== '0' && requested !== 'false' && requested !== false;
74
+ }
75
+
76
+ //# sourceMappingURL=cron-auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/cron-auth.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 * Authorizes a scheduled-route invocation (AGL-489), for every scheduled\n * route the console serves: its own, and a plugin's served through the\n * plugin API dispatcher (AGL-3080), which is why it lives in the data layer\n * rather than in the app. Accepts either caller:\n * - **Vercel Cron** — invokes the path with a GET and an auto-injected\n * `Authorization: Bearer $CRON_SECRET` header (set when CRON_SECRET is\n * configured on the project). This is how `apps/console/vercel.json`\n * drives these routes in production.\n * - **External scheduler** — a POST carrying `x-cron-secret: $CRON_SECRET`\n * (the original contract, kept for GitHub Actions / manual runs).\n *\n * Returns false when CRON_SECRET is unset so an unconfigured deploy can't be\n * triggered.\n */\nimport { timingSafeEqual } from 'crypto'\n\n// Constant-time string equality (AGL-512) so secret checks don't leak via\n// response-timing. Length is compared first (not itself secret).\nfunction safeEqual(a: string, b: string): boolean {\n const ab = Buffer.from(a)\n const bb = Buffer.from(b)\n if (ab.length !== bb.length) return false\n return timingSafeEqual(new Uint8Array(ab), new Uint8Array(bb))\n}\n\nexport function isCronAuthorized(\n headers: Partial<Record<string, string>>,\n): boolean {\n const secret = process.env.CRON_SECRET\n if (!secret) return false\n if (safeEqual(headers.authorization ?? '', `Bearer ${secret}`)) return true\n if (safeEqual(headers['x-cron-secret'] ?? '', secret)) return true\n return false\n}\n\n/**\n * Is this scheduled-route invocation a DRY RUN? (AGL-2084)\n *\n * `reap-plugin-artifacts` and `reverify-plugin-versions` each chose this rule\n * deliberately and each spelled it out again inline, and `audit-archive` — the\n * route that DELETES Firestore audit rows — was the copy that never got\n * written. Four transcriptions of four lines is how the guard goes missing, so\n * it lives here once and the routes call it.\n *\n * The rule:\n * - An explicit `dryRun` wins, from the body or the query string, in BOTH\n * directions: `?dryRun=0` on a GET writes, `{ dryRun: true }` on a POST\n * does not. `'0'`, `'false'` and `false` are the only ways to turn it off,\n * matching what the two original routes accepted.\n * - With no `dryRun` at all the METHOD decides: a GET reports, a POST acts.\n *\n * Keying the default on the method rather than on the body being absent is the\n * load-bearing half. `scheduled-crons.yml` fires every one of these routes with\n * a bodyless POST, so defaulting an absent `dryRun` to true would silently stop\n * the archival, the reaping and the re-verification — a worse failure than the\n * one this guard exists to prevent, and an invisible one.\n */\nexport function isCronDryRun(request: {\n method: string\n body?: unknown\n query?: Record<string, string | string[] | undefined>\n}): boolean {\n const requested =\n (request.body as { dryRun?: unknown } | undefined)?.dryRun ??\n request.query?.['dryRun']\n if (requested === undefined) return request.method === 'GET'\n return requested !== '0' && requested !== 'false' && requested !== false\n}\n"],"names":["timingSafeEqual","safeEqual","a","b","ab","Buffer","from","bb","length","Uint8Array","isCronAuthorized","headers","secret","process","env","CRON_SECRET","authorization","isCronDryRun","request","requested","body","dryRun","query","undefined","method"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;CAcC,GACD,SAASA,eAAe,QAAQ,SAAQ;AAExC,0EAA0E;AAC1E,iEAAiE;AACjE,SAASC,UAAUC,CAAS,EAAEC,CAAS;IACrC,MAAMC,KAAKC,OAAOC,IAAI,CAACJ;IACvB,MAAMK,KAAKF,OAAOC,IAAI,CAACH;IACvB,IAAIC,GAAGI,MAAM,KAAKD,GAAGC,MAAM,EAAE,OAAO;IACpC,OAAOR,gBAAgB,IAAIS,WAAWL,KAAK,IAAIK,WAAWF;AAC5D;AAEA,OAAO,SAASG,iBACdC,OAAwC;QAI1BA,wBACAA;IAHd,MAAMC,SAASC,QAAQC,GAAG,CAACC,WAAW;IACtC,IAAI,CAACH,QAAQ,OAAO;IACpB,IAAIX,WAAUU,yBAAAA,QAAQK,aAAa,YAArBL,yBAAyB,IAAI,CAAC,OAAO,EAAEC,QAAQ,GAAG,OAAO;IACvE,IAAIX,WAAUU,uBAAAA,OAAO,CAAC,gBAAgB,YAAxBA,uBAA4B,IAAIC,SAAS,OAAO;IAC9D,OAAO;AACT;AAEA;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,OAAO,SAASK,aAAaC,OAI5B;;QAEIA,eACDA;IAFF,MAAMC,qBACHD,gBAAAA,QAAQE,IAAI,qBAAb,AAACF,cAAmDG,MAAM,oBAC1DH,iBAAAA,QAAQI,KAAK,qBAAbJ,cAAe,CAAC,SAAS;IAC3B,IAAIC,cAAcI,WAAW,OAAOL,QAAQM,MAAM,KAAK;IACvD,OAAOL,cAAc,OAAOA,cAAc,WAAWA,cAAc;AACrE"}
@@ -32,7 +32,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
32
32
  *
33
33
  * A record changed one dataset, so this drops the pages that repeat over that
34
34
  * dataset and nothing else. The set is derived from the SAME binding the
35
- * published page expands — `repeatDatasetKeys`, the generic
35
+ * published page expands — `repeatKeys`, the generic
36
36
  * `props.repeatDataset` any node can now carry (AGL-3111) — rather than from a
37
37
  * list of components that may repeat. A component allowlist was already wrong
38
38
  * the day repeat stopped being the Stack's: a heading bound to a dataset
@@ -57,7 +57,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
57
57
  * failed drop is reported to the caller and logged, never thrown: a record
58
58
  * must not be lost because a cache refused, and the TTL is still underneath as
59
59
  * the backstop it was always meant to be.
60
- */ import { ORG_SCOPE_TOKEN, hostIdsFromScope, repeatDatasetKeys, screenRoutePathToUrl } from "@aglyn/aglyn/server";
60
+ */ import { ORG_SCOPE_TOKEN, hostIdsFromScope, repeatKeys, screenRoutePathToUrl } from "@aglyn/aglyn/server";
61
61
  import { readUsageSources, screenIdsUsingComponentDeep, screenIdsUsingLayoutDeep, isLiveUsageCandidate } from "./live-page-usage.js";
62
62
  /**
63
63
  * How many documents per collection the per-site scan reads.
@@ -111,12 +111,12 @@ function throttleAllows(key, now) {
111
111
  /**
112
112
  * Whether a node tree repeats over any of `keys`.
113
113
  *
114
- * Asks `repeatDatasetKeys` — literally the function the tenant composer calls
114
+ * Asks `repeatKeys` — literally the function the tenant composer calls
115
115
  * to decide which datasets to load for a page — so this cannot come to
116
116
  * disagree with what actually renders. A predicate even slightly narrower than
117
117
  * the expansion's is a page that renders rows and never refreshes them.
118
118
  */ function nodesRepeatDataset(nodes, keys) {
119
- return repeatDatasetKeys(nodes).some((key)=>keys.has(key));
119
+ return repeatKeys(nodes).some((key)=>keys.has(key));
120
120
  }
121
121
  /**
122
122
  * Every live screen whose rendered output repeats over the dataset, however
@@ -162,8 +162,8 @@ function throttleAllows(key, now) {
162
162
  * The keys a page can be bound to this dataset by: its id, and the display
163
163
  * name editors type into the Repeat attribute.
164
164
  *
165
- * Both, because the expansion looks a key up either way (`getDatasets`
166
- * resolves an id first and then a display name). Matching only the id would
165
+ * Both, because the expansion looks a key up either way (the data plugin's
166
+ * repeat rows resolve an id first and then a display name). Matching only the id would
167
167
  * miss every binding written by name, which is the older and still-supported
168
168
  * half of them.
169
169
  */ function datasetKeysOf(snapshot) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/dataset-live-pages.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 * A dataset record write refreshes the live pages that show it (AGL-3113).\n *\n * ## What was missing\n *\n * Every other change a visitor can see announces itself the moment it lands: a\n * publish, a layout, a component, a content entry, a redirect rule, the\n * maintenance toggle. A dataset RECORD did not. It was written — by a form, by\n * an automation, over `/v1`, from the console — and the pages repeating over\n * it went on serving the rows they were built from until the hour-long window\n * lapsed. `PUBLISHED_SITE_DATA_TTL_SECONDS` was the propagation mechanism,\n * which is the same mistake AGL-1302 and AGL-1150 each had to undo elsewhere:\n * a backstop doing a job nothing else was doing.\n *\n * ## Scope, not the site\n *\n * A record changed one dataset, so this drops the pages that repeat over that\n * dataset and nothing else. The set is derived from the SAME binding the\n * published page expands — `repeatDatasetKeys`, the generic\n * `props.repeatDataset` any node can now carry (AGL-3111) — rather than from a\n * list of components that may repeat. A component allowlist was already wrong\n * the day repeat stopped being the Stack's: a heading bound to a dataset\n * renders rows exactly as a Stack does, and would have been missed.\n *\n * Three corpora, because the composer reads repeat keys after the layout chain\n * and the reusable-component graft: a repeat in a layout's chrome or inside a\n * component reaches every screen that renders it.\n *\n * ## Which sites\n *\n * Datasets are ORG-scoped and shared with sites through `visibleTo` — every\n * site in the org, or up to `MAX_SCOPE_HOSTS` named ones. Each site caches its\n * own copy of the rows under its own `tenant-data:{hostId}` tag and its own\n * page keys, so one record write is a fan-out. A missing `visibleTo` is\n * visible to NOBODY and announces nothing; defaulting it to org-wide here\n * would re-make AGL-1466 in the cache layer.\n *\n * ## Best effort, always\n *\n * The record is already written by the time any of this runs. A refused or\n * failed drop is reported to the caller and logged, never thrown: a record\n * must not be lost because a cache refused, and the TTL is still underneath as\n * the backstop it was always meant to be.\n */\n\nimport {\n ORG_SCOPE_TOKEN,\n hostIdsFromScope,\n repeatDatasetKeys,\n screenRoutePathToUrl,\n} from '@aglyn/aglyn/server'\nimport type { Firestore } from 'firebase-admin/firestore'\nimport {\n readUsageSources,\n screenIdsUsingComponentDeep,\n screenIdsUsingLayoutDeep,\n isLiveUsageCandidate,\n type UsageCandidate,\n type UsageSources,\n} from './live-page-usage'\n\n/**\n * How many documents per collection the per-site scan reads.\n *\n * The console's publish scan uses the same number for the same reason\n * (AGL-1161): this decides which caches get dropped, so a prefix scan would\n * report a refreshed site and leave real pages serving the old rows. Bounded\n * all the same, and when the bound bites it is SAID rather than absorbed.\n */\nexport const DATASET_SCAN_LIMIT = 2000\n\n/**\n * How many sites one record write fans out to.\n *\n * `MAX_SCOPE_HOSTS` is the ceiling on a NAMED selection, so only an org-wide\n * dataset can reach this — an org with more sites than this pays a scan per\n * site on every write otherwise, which is a cost the person writing one row\n * did not ask for. Sites past the cap keep the TTL they had before this\n * existed, and the shortfall is returned and logged rather than swallowed.\n */\nexport const MAX_DATASET_FANOUT_HOSTS = 30\n\n/**\n * How long one site-and-dataset pair waits before announcing again.\n *\n * A 1,000-row import is one announce because the importer announces after its\n * loop, not per row. This covers the other burst — many small writes arriving\n * separately, a form under load or an automation fanning out — where each one\n * would otherwise pay a full scan per site and hand the tenant the same path\n * list again.\n *\n * Leading edge, so the FIRST write of a burst refreshes immediately; what is\n * skipped is a repeat inside the window, and the next write after it announces\n * again. The cost of the skip is bounded by the window: a row written during\n * one is visible no later than the next announce.\n */\nexport const DATASET_ANNOUNCE_WINDOW_MS = 2000\n\n/** Last announce per `orgId/datasetId/hostId`, for the window above. */\nconst lastAnnouncedAt = new Map<string, number>()\n\n/**\n * Bounded so a long-lived server cannot grow this map without limit. Far above\n * the number of datasets any one instance writes in a window; when it bites,\n * the oldest entries simply lose their throttle and announce again.\n */\nconst MAX_THROTTLE_ENTRIES = 5000\n\nfunction throttleAllows(key: string, now: number): boolean {\n const previous = lastAnnouncedAt.get(key)\n if (previous !== undefined && now - previous < DATASET_ANNOUNCE_WINDOW_MS) {\n return false\n }\n if (lastAnnouncedAt.size >= MAX_THROTTLE_ENTRIES) lastAnnouncedAt.clear()\n lastAnnouncedAt.set(key, now)\n return true\n}\n\n/** Test seam: the window is process-wide state, so a spec has to reset it. */\nexport function resetDatasetAnnounceThrottle(): void {\n lastAnnouncedAt.clear()\n}\n\n/** One site's share of a dataset change. */\nexport interface DatasetLivePageTarget {\n hostId: string\n /** The tenant keys its cache on this, never on `hostId`. */\n subdomain: string\n /** The attached custom domain, whose pages live under a second cache key. */\n cname?: string\n /** Site-absolute addresses (`/`, `/team`) that repeat over the dataset. */\n paths: string[]\n /** The site held more documents than the scan read, so `paths` is partial. */\n truncated: boolean\n}\n\nexport interface DatasetLivePageScope {\n targets: DatasetLivePageTarget[]\n /**\n * Sites the fan-out cap left out. Zero in the ordinary case, and reported\n * rather than logged because a caller that says \"refreshed\" while some sites\n * were skipped is the failure this whole arc exists to remove.\n */\n hostsDropped: number\n /**\n * Why there is nothing to announce, or `'ok'`.\n *\n * `'not-rendered'` is a success: a dataset no page repeats over has no live\n * page to refresh. It is kept apart from `'error'` for the same reason the\n * revalidate route keeps `not-routed` apart from a refusal — one means there\n * was nothing to do, the other means something was left stale.\n */\n reason: 'ok' | 'no-dataset' | 'no-sites' | 'not-rendered' | 'error'\n}\n\n/**\n * Whether a node tree repeats over any of `keys`.\n *\n * Asks `repeatDatasetKeys` — literally the function the tenant composer calls\n * to decide which datasets to load for a page — so this cannot come to\n * disagree with what actually renders. A predicate even slightly narrower than\n * the expansion's is a page that renders rows and never refreshes them.\n */\nfunction nodesRepeatDataset(\n nodes: UsageCandidate['nodes'],\n keys: ReadonlySet<string>,\n): boolean {\n return repeatDatasetKeys(nodes).some((key) => keys.has(key))\n}\n\n/**\n * Every live screen whose rendered output repeats over the dataset, however\n * indirectly.\n *\n * The same three-bucket closure a placed form's pages are found by, for the\n * same reason: a binding is found by SEARCHING node trees rather than by\n * matching a pointer, so it can sit on the screen, in the chrome of the layout\n * the screen renders inside, or inside a reusable component a third component\n * nests. After the first level the fan-out is identical, so it is delegated\n * rather than restated.\n *\n * Pure, and separate from the Firestore read, so the closure is testable\n * without a database.\n */\nexport function screenIdsRepeatingDataset(\n keys: ReadonlySet<string>,\n sources: UsageSources,\n): string[] {\n if (!keys.size) return []\n const screenIds = new Set<string>()\n\n for (const candidate of sources.screens) {\n if (!isLiveUsageCandidate(candidate)) continue\n if (nodesRepeatDataset(candidate.nodes, keys)) screenIds.add(candidate.id)\n }\n for (const candidate of sources.layouts) {\n if (!isLiveUsageCandidate(candidate)) continue\n if (!nodesRepeatDataset(candidate.nodes, keys)) continue\n // The layout itself renders no URL; the screens beneath it do.\n for (const screenId of screenIdsUsingLayoutDeep(\n candidate.id,\n sources.screens,\n sources.layouts,\n )) {\n screenIds.add(screenId)\n }\n }\n for (const candidate of sources.components) {\n if (!isLiveUsageCandidate(candidate)) continue\n if (!nodesRepeatDataset(candidate.nodes, keys)) continue\n // And a component renders wherever it is placed, however deeply nested.\n for (const screenId of screenIdsUsingComponentDeep(candidate.id, sources)) {\n screenIds.add(screenId)\n }\n }\n\n return [...screenIds]\n}\n\n/**\n * The keys a page can be bound to this dataset by: its id, and the display\n * name editors type into the Repeat attribute.\n *\n * Both, because the expansion looks a key up either way (`getDatasets`\n * resolves an id first and then a display name). Matching only the id would\n * miss every binding written by name, which is the older and still-supported\n * half of them.\n */\nfunction datasetKeysOf(\n snapshot: FirebaseFirestore.DocumentSnapshot,\n): Set<string> {\n const keys = new Set<string>([snapshot.id])\n const displayName = String(snapshot.get('displayName') ?? '').trim()\n if (displayName) keys.add(displayName)\n return keys\n}\n\n/**\n * The sites a dataset is shared with, as host ids.\n *\n * Fails CLOSED on a missing or empty `visibleTo`, matching `visibleToHost` and\n * both enforcement layers underneath it: a document nobody scoped is shared\n * with nobody, and reading it as org-wide here would announce one org's\n * dataset change to sites that cannot see the dataset at all.\n */\nasync function hostIdsSharingDataset(\n firestore: Firestore,\n orgId: string,\n visibleTo: readonly string[] | undefined,\n): Promise<{ hostIds: string[]; hostsDropped: number }> {\n if (!Array.isArray(visibleTo) || !visibleTo.length) {\n return { hostIds: [], hostsDropped: 0 }\n }\n if (!visibleTo.includes(ORG_SCOPE_TOKEN)) {\n const named = hostIdsFromScope(visibleTo)\n return { hostIds: named, hostsDropped: 0 }\n }\n // One over the cap, so a shortfall is detected rather than assumed away.\n const hosts = await firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .limit(MAX_DATASET_FANOUT_HOSTS + 1)\n .get()\n const all = hosts.docs.map((doc) => doc.id)\n return {\n hostIds: all.slice(0, MAX_DATASET_FANOUT_HOSTS),\n hostsDropped: Math.max(0, all.length - MAX_DATASET_FANOUT_HOSTS),\n }\n}\n\n/** One site's paths, or `null` when the site has nothing to refresh. */\nasync function targetForHost(\n firestore: Firestore,\n hostId: string,\n keys: ReadonlySet<string>,\n): Promise<DatasetLivePageTarget | null> {\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) return null\n const subdomain = String(hostSnapshot.get('subdomain') ?? '')\n // Without a subdomain there is no tenant deployment holding cached pages for\n // this site — the ordinary state of one not yet given a name, not a failure.\n if (!subdomain) return null\n\n const sources = await readUsageSources(hostRef, DATASET_SCAN_LIMIT)\n const screens = (hostSnapshot.get('screens') ?? {}) as Record<string, string>\n const paths = screenIdsRepeatingDataset(keys, sources.candidates)\n .map((screenId) => screens[screenId])\n .filter((path): path is string => Boolean(path))\n .map((path) => screenRoutePathToUrl(path))\n .filter((path, index, all) => all.indexOf(path) === index)\n if (!paths.length) return null\n\n const cname = String(hostSnapshot.get('cname') ?? '')\n return {\n hostId,\n subdomain,\n ...(cname ? { cname } : {}),\n paths,\n truncated: sources.truncated,\n }\n}\n\n/**\n * Every live page a change to this dataset's records makes stale, per site.\n *\n * Reads only; announcing is the caller's, because the two runtimes announce\n * differently — the console posts to the tenant over the shared secret, and\n * the tenant drops its own caches in process. Splitting it here is what lets\n * both use one answer to the question of WHICH pages.\n *\n * Never throws. A scope that could not be computed is a scope of nothing, and\n * the TTL underneath is unaffected.\n */\nexport async function datasetLivePageScope(options: {\n firestore: Firestore\n orgId: string\n datasetId: string\n}): Promise<DatasetLivePageScope> {\n const { firestore, orgId, datasetId } = options\n if (!orgId || !datasetId) {\n return { targets: [], hostsDropped: 0, reason: 'no-dataset' }\n }\n try {\n const snapshot = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('datasets')\n .doc(datasetId)\n .get()\n if (!snapshot.exists) {\n return { targets: [], hostsDropped: 0, reason: 'no-dataset' }\n }\n const { hostIds, hostsDropped } = await hostIdsSharingDataset(\n firestore,\n orgId,\n snapshot.get('visibleTo') as readonly string[] | undefined,\n )\n if (!hostIds.length) {\n return { targets: [], hostsDropped, reason: 'no-sites' }\n }\n const keys = datasetKeysOf(snapshot)\n const resolved = await Promise.all(\n hostIds.map(async (hostId) => {\n try {\n return await targetForHost(firestore, hostId, keys)\n } catch (error) {\n // One unreadable site must not cost the others their refresh.\n console.error('[dataset-live-pages] site scan failed', hostId, error)\n return null\n }\n }),\n )\n const targets = resolved.filter(\n (target): target is DatasetLivePageTarget => target !== null,\n )\n return {\n targets,\n hostsDropped,\n reason: targets.length ? 'ok' : 'not-rendered',\n }\n } catch (error) {\n console.error('[dataset-live-pages] scope failed', datasetId, error)\n return { targets: [], hostsDropped: 0, reason: 'error' }\n }\n}\n\n/**\n * How one runtime drops a site's cached pages.\n *\n * The generic seam, registered by the deployment that owns the caches. The\n * tenant registers an in-process drop at boot; the console announces over the\n * shared secret and passes its own dropper at the call site. Core names\n * neither, exactly as it names no repeat source.\n *\n * Returns whether the drop landed. It must never throw: everything above it\n * has already been written.\n */\nexport type LivePageDropper = (\n target: DatasetLivePageTarget,\n) => Promise<boolean>\n\nlet registeredDropper: LivePageDropper | undefined\n\n/**\n * Registers the dropper for this process, replacing any previous one. Returns\n * the unregister, which removes it only if it is still the one registered.\n *\n * Called from a function the platform calls BY NAME at boot, never from a\n * module's top level: a bundler deletes a module imported only for its side\n * effect when its package says it has none (AGL-3025).\n */\nexport function registerLivePageDropper(drop: LivePageDropper): () => void {\n registeredDropper = drop\n return () => {\n if (registeredDropper === drop) registeredDropper = undefined\n }\n}\n\nexport interface DatasetAnnounceResult {\n /** Sites whose cached pages were dropped. */\n sitesRefreshed: number\n /** Pages named across those sites. */\n pathsRefreshed: number\n /** Sites the drop was attempted on and refused. */\n sitesRefused: number\n /** Sites past the fan-out cap, and sites whose scan was truncated. */\n hostsDropped: number\n scanTruncated: boolean\n /** `'throttled'` when a drop for this pair already went out just now. */\n reason: DatasetLivePageScope['reason'] | 'throttled' | 'no-dropper'\n}\n\nconst EMPTY_RESULT: Omit<DatasetAnnounceResult, 'reason'> = {\n sitesRefreshed: 0,\n pathsRefreshed: 0,\n sitesRefused: 0,\n hostsDropped: 0,\n scanTruncated: false,\n}\n\n/**\n * Refresh every live page that shows this dataset's records.\n *\n * The one call a write path makes. BEST EFFORT, ALWAYS: it never throws and\n * never rejects, so a caller can `await` it inside the try that already guards\n * the write without any chance of turning a stored record into a failed one.\n *\n * `drop` overrides the registered dropper for callers that hold their own —\n * the console, whose announce is an authenticated request rather than a local\n * cache call.\n */\nexport async function announceDatasetRecordChange(options: {\n firestore: Firestore\n orgId: string\n datasetId: string\n drop?: LivePageDropper\n}): Promise<DatasetAnnounceResult> {\n const { firestore, orgId, datasetId } = options\n const drop = options.drop ?? registeredDropper\n if (!drop) {\n // Said out loud: a process that writes records and registered no dropper\n // is one where this feature is silently absent, which reads exactly like\n // a working one until somebody watches a page fail to change.\n console.warn(\n JSON.stringify({\n tag: 'AGL-3113:no-live-page-dropper',\n orgId,\n datasetId,\n }),\n )\n return { ...EMPTY_RESULT, reason: 'no-dropper' }\n }\n const throttleKey = `${orgId}/${datasetId}`\n if (!throttleAllows(throttleKey, Date.now())) {\n return { ...EMPTY_RESULT, reason: 'throttled' }\n }\n const scope = await datasetLivePageScope({ firestore, orgId, datasetId })\n if (!scope.targets.length) {\n return {\n ...EMPTY_RESULT,\n hostsDropped: scope.hostsDropped,\n reason: scope.reason,\n }\n }\n\n let sitesRefreshed = 0\n let sitesRefused = 0\n let pathsRefreshed = 0\n for (const target of scope.targets) {\n let landed = false\n try {\n landed = await drop(target)\n } catch (error) {\n // A dropper that throws is a dropper that failed; it is not a write\n // that failed.\n console.error('[dataset-live-pages] drop failed', target.hostId, error)\n }\n if (landed) {\n sitesRefreshed += 1\n pathsRefreshed += target.paths.length\n } else {\n sitesRefused += 1\n }\n }\n\n const scanTruncated = scope.targets.some((target) => target.truncated)\n const result: DatasetAnnounceResult = {\n sitesRefreshed,\n pathsRefreshed,\n sitesRefused,\n hostsDropped: scope.hostsDropped,\n scanTruncated,\n reason: scope.reason,\n }\n // ONE line per announce, on success as well as failure, for the reason\n // `postTenantRevalidate` gives: a record that only logs its failures cannot\n // tell \"every write refreshed\" apart from \"the call never happened\", and\n // that ambiguity is how an eleven-day propagation outage went unnoticed.\n console.log(\n JSON.stringify({\n tag: 'AGL-3113:dataset-announce',\n orgId,\n datasetId,\n ...result,\n }),\n )\n return result\n}\n\n/**\n * One sentence for a refresh that did not cover every page, or `null` when it\n * did.\n *\n * Beside the call rather than at each surface, so the console cannot word this\n * differently in two places — the same reason `describeRevalidateShortfall`\n * lives beside `revalidateLivePages`. `lead` names the act that succeeded,\n * because only that word differs between surfaces: a record is SAVED, and\n * telling someone who fixed a typo that they published it is a worse error\n * than the staleness the sentence is about.\n */\nexport function describeDatasetAnnounceShortfall(\n result: DatasetAnnounceResult | null,\n lead = 'Saved.',\n): string | null {\n if (!result) return null\n // `not-rendered`, `no-sites` and `throttled` are deliberately silent: a\n // dataset no page shows has no stale page, and a throttled drop means one\n // already went out a moment ago. Saying anything would be alarming and wrong.\n if (result.reason === 'error' || result.sitesRefused) {\n return (\n `${lead} The live pages could not be refreshed just now, so they ` +\n 'may show the previous records for up to an hour.'\n )\n }\n if (result.hostsDropped || result.scanTruncated) {\n return (\n `${lead} Some pages that use this dataset are too many to refresh at ` +\n 'once — they update on their own within an hour.'\n )\n }\n return null\n}\n\nexport default announceDatasetRecordChange\n"],"names":["ORG_SCOPE_TOKEN","hostIdsFromScope","repeatDatasetKeys","screenRoutePathToUrl","readUsageSources","screenIdsUsingComponentDeep","screenIdsUsingLayoutDeep","isLiveUsageCandidate","DATASET_SCAN_LIMIT","MAX_DATASET_FANOUT_HOSTS","DATASET_ANNOUNCE_WINDOW_MS","lastAnnouncedAt","Map","MAX_THROTTLE_ENTRIES","throttleAllows","key","now","previous","get","undefined","size","clear","set","resetDatasetAnnounceThrottle","nodesRepeatDataset","nodes","keys","some","has","screenIdsRepeatingDataset","sources","screenIds","Set","candidate","screens","add","id","layouts","screenId","components","datasetKeysOf","snapshot","displayName","String","trim","hostIdsSharingDataset","firestore","orgId","visibleTo","Array","isArray","length","hostIds","hostsDropped","includes","named","hosts","collection","where","limit","all","docs","map","doc","slice","Math","max","targetForHost","hostId","hostSnapshot","hostRef","exists","subdomain","paths","candidates","filter","path","Boolean","index","indexOf","cname","truncated","datasetLivePageScope","options","datasetId","targets","reason","resolved","Promise","error","console","target","registeredDropper","registerLivePageDropper","drop","EMPTY_RESULT","sitesRefreshed","pathsRefreshed","sitesRefused","scanTruncated","announceDatasetRecordChange","warn","JSON","stringify","tag","throttleKey","Date","scope","landed","result","log","describeDatasetAnnounceShortfall","lead"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/dataset-live-pages.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 * A dataset record write refreshes the live pages that show it (AGL-3113).\n *\n * ## What was missing\n *\n * Every other change a visitor can see announces itself the moment it lands: a\n * publish, a layout, a component, a content entry, a redirect rule, the\n * maintenance toggle. A dataset RECORD did not. It was written — by a form, by\n * an automation, over `/v1`, from the console — and the pages repeating over\n * it went on serving the rows they were built from until the hour-long window\n * lapsed. `PUBLISHED_SITE_DATA_TTL_SECONDS` was the propagation mechanism,\n * which is the same mistake AGL-1302 and AGL-1150 each had to undo elsewhere:\n * a backstop doing a job nothing else was doing.\n *\n * ## Scope, not the site\n *\n * A record changed one dataset, so this drops the pages that repeat over that\n * dataset and nothing else. The set is derived from the SAME binding the\n * published page expands — `repeatKeys`, the generic\n * `props.repeatDataset` any node can now carry (AGL-3111) — rather than from a\n * list of components that may repeat. A component allowlist was already wrong\n * the day repeat stopped being the Stack's: a heading bound to a dataset\n * renders rows exactly as a Stack does, and would have been missed.\n *\n * Three corpora, because the composer reads repeat keys after the layout chain\n * and the reusable-component graft: a repeat in a layout's chrome or inside a\n * component reaches every screen that renders it.\n *\n * ## Which sites\n *\n * Datasets are ORG-scoped and shared with sites through `visibleTo` — every\n * site in the org, or up to `MAX_SCOPE_HOSTS` named ones. Each site caches its\n * own copy of the rows under its own `tenant-data:{hostId}` tag and its own\n * page keys, so one record write is a fan-out. A missing `visibleTo` is\n * visible to NOBODY and announces nothing; defaulting it to org-wide here\n * would re-make AGL-1466 in the cache layer.\n *\n * ## Best effort, always\n *\n * The record is already written by the time any of this runs. A refused or\n * failed drop is reported to the caller and logged, never thrown: a record\n * must not be lost because a cache refused, and the TTL is still underneath as\n * the backstop it was always meant to be.\n */\n\nimport {\n ORG_SCOPE_TOKEN,\n hostIdsFromScope,\n repeatKeys,\n screenRoutePathToUrl,\n} from '@aglyn/aglyn/server'\nimport type { Firestore } from 'firebase-admin/firestore'\nimport {\n readUsageSources,\n screenIdsUsingComponentDeep,\n screenIdsUsingLayoutDeep,\n isLiveUsageCandidate,\n type UsageCandidate,\n type UsageSources,\n} from './live-page-usage'\n\n/**\n * How many documents per collection the per-site scan reads.\n *\n * The console's publish scan uses the same number for the same reason\n * (AGL-1161): this decides which caches get dropped, so a prefix scan would\n * report a refreshed site and leave real pages serving the old rows. Bounded\n * all the same, and when the bound bites it is SAID rather than absorbed.\n */\nexport const DATASET_SCAN_LIMIT = 2000\n\n/**\n * How many sites one record write fans out to.\n *\n * `MAX_SCOPE_HOSTS` is the ceiling on a NAMED selection, so only an org-wide\n * dataset can reach this — an org with more sites than this pays a scan per\n * site on every write otherwise, which is a cost the person writing one row\n * did not ask for. Sites past the cap keep the TTL they had before this\n * existed, and the shortfall is returned and logged rather than swallowed.\n */\nexport const MAX_DATASET_FANOUT_HOSTS = 30\n\n/**\n * How long one site-and-dataset pair waits before announcing again.\n *\n * A 1,000-row import is one announce because the importer announces after its\n * loop, not per row. This covers the other burst — many small writes arriving\n * separately, a form under load or an automation fanning out — where each one\n * would otherwise pay a full scan per site and hand the tenant the same path\n * list again.\n *\n * Leading edge, so the FIRST write of a burst refreshes immediately; what is\n * skipped is a repeat inside the window, and the next write after it announces\n * again. The cost of the skip is bounded by the window: a row written during\n * one is visible no later than the next announce.\n */\nexport const DATASET_ANNOUNCE_WINDOW_MS = 2000\n\n/** Last announce per `orgId/datasetId/hostId`, for the window above. */\nconst lastAnnouncedAt = new Map<string, number>()\n\n/**\n * Bounded so a long-lived server cannot grow this map without limit. Far above\n * the number of datasets any one instance writes in a window; when it bites,\n * the oldest entries simply lose their throttle and announce again.\n */\nconst MAX_THROTTLE_ENTRIES = 5000\n\nfunction throttleAllows(key: string, now: number): boolean {\n const previous = lastAnnouncedAt.get(key)\n if (previous !== undefined && now - previous < DATASET_ANNOUNCE_WINDOW_MS) {\n return false\n }\n if (lastAnnouncedAt.size >= MAX_THROTTLE_ENTRIES) lastAnnouncedAt.clear()\n lastAnnouncedAt.set(key, now)\n return true\n}\n\n/** Test seam: the window is process-wide state, so a spec has to reset it. */\nexport function resetDatasetAnnounceThrottle(): void {\n lastAnnouncedAt.clear()\n}\n\n/** One site's share of a dataset change. */\nexport interface DatasetLivePageTarget {\n hostId: string\n /** The tenant keys its cache on this, never on `hostId`. */\n subdomain: string\n /** The attached custom domain, whose pages live under a second cache key. */\n cname?: string\n /** Site-absolute addresses (`/`, `/team`) that repeat over the dataset. */\n paths: string[]\n /** The site held more documents than the scan read, so `paths` is partial. */\n truncated: boolean\n}\n\nexport interface DatasetLivePageScope {\n targets: DatasetLivePageTarget[]\n /**\n * Sites the fan-out cap left out. Zero in the ordinary case, and reported\n * rather than logged because a caller that says \"refreshed\" while some sites\n * were skipped is the failure this whole arc exists to remove.\n */\n hostsDropped: number\n /**\n * Why there is nothing to announce, or `'ok'`.\n *\n * `'not-rendered'` is a success: a dataset no page repeats over has no live\n * page to refresh. It is kept apart from `'error'` for the same reason the\n * revalidate route keeps `not-routed` apart from a refusal — one means there\n * was nothing to do, the other means something was left stale.\n */\n reason: 'ok' | 'no-dataset' | 'no-sites' | 'not-rendered' | 'error'\n}\n\n/**\n * Whether a node tree repeats over any of `keys`.\n *\n * Asks `repeatKeys` — literally the function the tenant composer calls\n * to decide which datasets to load for a page — so this cannot come to\n * disagree with what actually renders. A predicate even slightly narrower than\n * the expansion's is a page that renders rows and never refreshes them.\n */\nfunction nodesRepeatDataset(\n nodes: UsageCandidate['nodes'],\n keys: ReadonlySet<string>,\n): boolean {\n return repeatKeys(nodes).some((key) => keys.has(key))\n}\n\n/**\n * Every live screen whose rendered output repeats over the dataset, however\n * indirectly.\n *\n * The same three-bucket closure a placed form's pages are found by, for the\n * same reason: a binding is found by SEARCHING node trees rather than by\n * matching a pointer, so it can sit on the screen, in the chrome of the layout\n * the screen renders inside, or inside a reusable component a third component\n * nests. After the first level the fan-out is identical, so it is delegated\n * rather than restated.\n *\n * Pure, and separate from the Firestore read, so the closure is testable\n * without a database.\n */\nexport function screenIdsRepeatingDataset(\n keys: ReadonlySet<string>,\n sources: UsageSources,\n): string[] {\n if (!keys.size) return []\n const screenIds = new Set<string>()\n\n for (const candidate of sources.screens) {\n if (!isLiveUsageCandidate(candidate)) continue\n if (nodesRepeatDataset(candidate.nodes, keys)) screenIds.add(candidate.id)\n }\n for (const candidate of sources.layouts) {\n if (!isLiveUsageCandidate(candidate)) continue\n if (!nodesRepeatDataset(candidate.nodes, keys)) continue\n // The layout itself renders no URL; the screens beneath it do.\n for (const screenId of screenIdsUsingLayoutDeep(\n candidate.id,\n sources.screens,\n sources.layouts,\n )) {\n screenIds.add(screenId)\n }\n }\n for (const candidate of sources.components) {\n if (!isLiveUsageCandidate(candidate)) continue\n if (!nodesRepeatDataset(candidate.nodes, keys)) continue\n // And a component renders wherever it is placed, however deeply nested.\n for (const screenId of screenIdsUsingComponentDeep(candidate.id, sources)) {\n screenIds.add(screenId)\n }\n }\n\n return [...screenIds]\n}\n\n/**\n * The keys a page can be bound to this dataset by: its id, and the display\n * name editors type into the Repeat attribute.\n *\n * Both, because the expansion looks a key up either way (the data plugin's\n * repeat rows resolve an id first and then a display name). Matching only the id would\n * miss every binding written by name, which is the older and still-supported\n * half of them.\n */\nfunction datasetKeysOf(\n snapshot: FirebaseFirestore.DocumentSnapshot,\n): Set<string> {\n const keys = new Set<string>([snapshot.id])\n const displayName = String(snapshot.get('displayName') ?? '').trim()\n if (displayName) keys.add(displayName)\n return keys\n}\n\n/**\n * The sites a dataset is shared with, as host ids.\n *\n * Fails CLOSED on a missing or empty `visibleTo`, matching `visibleToHost` and\n * both enforcement layers underneath it: a document nobody scoped is shared\n * with nobody, and reading it as org-wide here would announce one org's\n * dataset change to sites that cannot see the dataset at all.\n */\nasync function hostIdsSharingDataset(\n firestore: Firestore,\n orgId: string,\n visibleTo: readonly string[] | undefined,\n): Promise<{ hostIds: string[]; hostsDropped: number }> {\n if (!Array.isArray(visibleTo) || !visibleTo.length) {\n return { hostIds: [], hostsDropped: 0 }\n }\n if (!visibleTo.includes(ORG_SCOPE_TOKEN)) {\n const named = hostIdsFromScope(visibleTo)\n return { hostIds: named, hostsDropped: 0 }\n }\n // One over the cap, so a shortfall is detected rather than assumed away.\n const hosts = await firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .limit(MAX_DATASET_FANOUT_HOSTS + 1)\n .get()\n const all = hosts.docs.map((doc) => doc.id)\n return {\n hostIds: all.slice(0, MAX_DATASET_FANOUT_HOSTS),\n hostsDropped: Math.max(0, all.length - MAX_DATASET_FANOUT_HOSTS),\n }\n}\n\n/** One site's paths, or `null` when the site has nothing to refresh. */\nasync function targetForHost(\n firestore: Firestore,\n hostId: string,\n keys: ReadonlySet<string>,\n): Promise<DatasetLivePageTarget | null> {\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) return null\n const subdomain = String(hostSnapshot.get('subdomain') ?? '')\n // Without a subdomain there is no tenant deployment holding cached pages for\n // this site — the ordinary state of one not yet given a name, not a failure.\n if (!subdomain) return null\n\n const sources = await readUsageSources(hostRef, DATASET_SCAN_LIMIT)\n const screens = (hostSnapshot.get('screens') ?? {}) as Record<string, string>\n const paths = screenIdsRepeatingDataset(keys, sources.candidates)\n .map((screenId) => screens[screenId])\n .filter((path): path is string => Boolean(path))\n .map((path) => screenRoutePathToUrl(path))\n .filter((path, index, all) => all.indexOf(path) === index)\n if (!paths.length) return null\n\n const cname = String(hostSnapshot.get('cname') ?? '')\n return {\n hostId,\n subdomain,\n ...(cname ? { cname } : {}),\n paths,\n truncated: sources.truncated,\n }\n}\n\n/**\n * Every live page a change to this dataset's records makes stale, per site.\n *\n * Reads only; announcing is the caller's, because the two runtimes announce\n * differently — the console posts to the tenant over the shared secret, and\n * the tenant drops its own caches in process. Splitting it here is what lets\n * both use one answer to the question of WHICH pages.\n *\n * Never throws. A scope that could not be computed is a scope of nothing, and\n * the TTL underneath is unaffected.\n */\nexport async function datasetLivePageScope(options: {\n firestore: Firestore\n orgId: string\n datasetId: string\n}): Promise<DatasetLivePageScope> {\n const { firestore, orgId, datasetId } = options\n if (!orgId || !datasetId) {\n return { targets: [], hostsDropped: 0, reason: 'no-dataset' }\n }\n try {\n const snapshot = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('datasets')\n .doc(datasetId)\n .get()\n if (!snapshot.exists) {\n return { targets: [], hostsDropped: 0, reason: 'no-dataset' }\n }\n const { hostIds, hostsDropped } = await hostIdsSharingDataset(\n firestore,\n orgId,\n snapshot.get('visibleTo') as readonly string[] | undefined,\n )\n if (!hostIds.length) {\n return { targets: [], hostsDropped, reason: 'no-sites' }\n }\n const keys = datasetKeysOf(snapshot)\n const resolved = await Promise.all(\n hostIds.map(async (hostId) => {\n try {\n return await targetForHost(firestore, hostId, keys)\n } catch (error) {\n // One unreadable site must not cost the others their refresh.\n console.error('[dataset-live-pages] site scan failed', hostId, error)\n return null\n }\n }),\n )\n const targets = resolved.filter(\n (target): target is DatasetLivePageTarget => target !== null,\n )\n return {\n targets,\n hostsDropped,\n reason: targets.length ? 'ok' : 'not-rendered',\n }\n } catch (error) {\n console.error('[dataset-live-pages] scope failed', datasetId, error)\n return { targets: [], hostsDropped: 0, reason: 'error' }\n }\n}\n\n/**\n * How one runtime drops a site's cached pages.\n *\n * The generic seam, registered by the deployment that owns the caches. The\n * tenant registers an in-process drop at boot; the console announces over the\n * shared secret and passes its own dropper at the call site. Core names\n * neither, exactly as it names no repeat source.\n *\n * Returns whether the drop landed. It must never throw: everything above it\n * has already been written.\n */\nexport type LivePageDropper = (\n target: DatasetLivePageTarget,\n) => Promise<boolean>\n\nlet registeredDropper: LivePageDropper | undefined\n\n/**\n * Registers the dropper for this process, replacing any previous one. Returns\n * the unregister, which removes it only if it is still the one registered.\n *\n * Called from a function the platform calls BY NAME at boot, never from a\n * module's top level: a bundler deletes a module imported only for its side\n * effect when its package says it has none (AGL-3025).\n */\nexport function registerLivePageDropper(drop: LivePageDropper): () => void {\n registeredDropper = drop\n return () => {\n if (registeredDropper === drop) registeredDropper = undefined\n }\n}\n\nexport interface DatasetAnnounceResult {\n /** Sites whose cached pages were dropped. */\n sitesRefreshed: number\n /** Pages named across those sites. */\n pathsRefreshed: number\n /** Sites the drop was attempted on and refused. */\n sitesRefused: number\n /** Sites past the fan-out cap, and sites whose scan was truncated. */\n hostsDropped: number\n scanTruncated: boolean\n /** `'throttled'` when a drop for this pair already went out just now. */\n reason: DatasetLivePageScope['reason'] | 'throttled' | 'no-dropper'\n}\n\nconst EMPTY_RESULT: Omit<DatasetAnnounceResult, 'reason'> = {\n sitesRefreshed: 0,\n pathsRefreshed: 0,\n sitesRefused: 0,\n hostsDropped: 0,\n scanTruncated: false,\n}\n\n/**\n * Refresh every live page that shows this dataset's records.\n *\n * The one call a write path makes. BEST EFFORT, ALWAYS: it never throws and\n * never rejects, so a caller can `await` it inside the try that already guards\n * the write without any chance of turning a stored record into a failed one.\n *\n * `drop` overrides the registered dropper for callers that hold their own —\n * the console, whose announce is an authenticated request rather than a local\n * cache call.\n */\nexport async function announceDatasetRecordChange(options: {\n firestore: Firestore\n orgId: string\n datasetId: string\n drop?: LivePageDropper\n}): Promise<DatasetAnnounceResult> {\n const { firestore, orgId, datasetId } = options\n const drop = options.drop ?? registeredDropper\n if (!drop) {\n // Said out loud: a process that writes records and registered no dropper\n // is one where this feature is silently absent, which reads exactly like\n // a working one until somebody watches a page fail to change.\n console.warn(\n JSON.stringify({\n tag: 'AGL-3113:no-live-page-dropper',\n orgId,\n datasetId,\n }),\n )\n return { ...EMPTY_RESULT, reason: 'no-dropper' }\n }\n const throttleKey = `${orgId}/${datasetId}`\n if (!throttleAllows(throttleKey, Date.now())) {\n return { ...EMPTY_RESULT, reason: 'throttled' }\n }\n const scope = await datasetLivePageScope({ firestore, orgId, datasetId })\n if (!scope.targets.length) {\n return {\n ...EMPTY_RESULT,\n hostsDropped: scope.hostsDropped,\n reason: scope.reason,\n }\n }\n\n let sitesRefreshed = 0\n let sitesRefused = 0\n let pathsRefreshed = 0\n for (const target of scope.targets) {\n let landed = false\n try {\n landed = await drop(target)\n } catch (error) {\n // A dropper that throws is a dropper that failed; it is not a write\n // that failed.\n console.error('[dataset-live-pages] drop failed', target.hostId, error)\n }\n if (landed) {\n sitesRefreshed += 1\n pathsRefreshed += target.paths.length\n } else {\n sitesRefused += 1\n }\n }\n\n const scanTruncated = scope.targets.some((target) => target.truncated)\n const result: DatasetAnnounceResult = {\n sitesRefreshed,\n pathsRefreshed,\n sitesRefused,\n hostsDropped: scope.hostsDropped,\n scanTruncated,\n reason: scope.reason,\n }\n // ONE line per announce, on success as well as failure, for the reason\n // `postTenantRevalidate` gives: a record that only logs its failures cannot\n // tell \"every write refreshed\" apart from \"the call never happened\", and\n // that ambiguity is how an eleven-day propagation outage went unnoticed.\n console.log(\n JSON.stringify({\n tag: 'AGL-3113:dataset-announce',\n orgId,\n datasetId,\n ...result,\n }),\n )\n return result\n}\n\n/**\n * One sentence for a refresh that did not cover every page, or `null` when it\n * did.\n *\n * Beside the call rather than at each surface, so the console cannot word this\n * differently in two places — the same reason `describeRevalidateShortfall`\n * lives beside `revalidateLivePages`. `lead` names the act that succeeded,\n * because only that word differs between surfaces: a record is SAVED, and\n * telling someone who fixed a typo that they published it is a worse error\n * than the staleness the sentence is about.\n */\nexport function describeDatasetAnnounceShortfall(\n result: DatasetAnnounceResult | null,\n lead = 'Saved.',\n): string | null {\n if (!result) return null\n // `not-rendered`, `no-sites` and `throttled` are deliberately silent: a\n // dataset no page shows has no stale page, and a throttled drop means one\n // already went out a moment ago. Saying anything would be alarming and wrong.\n if (result.reason === 'error' || result.sitesRefused) {\n return (\n `${lead} The live pages could not be refreshed just now, so they ` +\n 'may show the previous records for up to an hour.'\n )\n }\n if (result.hostsDropped || result.scanTruncated) {\n return (\n `${lead} Some pages that use this dataset are too many to refresh at ` +\n 'once — they update on their own within an hour.'\n )\n }\n return null\n}\n\nexport default announceDatasetRecordChange\n"],"names":["ORG_SCOPE_TOKEN","hostIdsFromScope","repeatKeys","screenRoutePathToUrl","readUsageSources","screenIdsUsingComponentDeep","screenIdsUsingLayoutDeep","isLiveUsageCandidate","DATASET_SCAN_LIMIT","MAX_DATASET_FANOUT_HOSTS","DATASET_ANNOUNCE_WINDOW_MS","lastAnnouncedAt","Map","MAX_THROTTLE_ENTRIES","throttleAllows","key","now","previous","get","undefined","size","clear","set","resetDatasetAnnounceThrottle","nodesRepeatDataset","nodes","keys","some","has","screenIdsRepeatingDataset","sources","screenIds","Set","candidate","screens","add","id","layouts","screenId","components","datasetKeysOf","snapshot","displayName","String","trim","hostIdsSharingDataset","firestore","orgId","visibleTo","Array","isArray","length","hostIds","hostsDropped","includes","named","hosts","collection","where","limit","all","docs","map","doc","slice","Math","max","targetForHost","hostId","hostSnapshot","hostRef","exists","subdomain","paths","candidates","filter","path","Boolean","index","indexOf","cname","truncated","datasetLivePageScope","options","datasetId","targets","reason","resolved","Promise","error","console","target","registeredDropper","registerLivePageDropper","drop","EMPTY_RESULT","sitesRefreshed","pathsRefreshed","sitesRefused","scanTruncated","announceDatasetRecordChange","warn","JSON","stringify","tag","throttleKey","Date","scope","landed","result","log","describeDatasetAnnounceShortfall","lead"],"mappings":"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"}
@@ -14,7 +14,32 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
+ import { type OrgEntitlements, type OrgFeatureFlags } from '@aglyn/aglyn/server';
17
18
  import { type DuplicableHostResourceKind } from '@aglyn/aglyn/app-utils/duplicate-resource';
19
+ /** How the copy of each kind is made. */
20
+ interface KindRecipe {
21
+ collection: string;
22
+ /** The field the name lives in — `displayName` on core's kinds; a declared kind names its own. */
23
+ nameField: string;
24
+ /** The source fields carried onto the copy; everything else stays behind. */
25
+ fields: readonly string[];
26
+ /** The back-pointer a copied version carries, when the kind versions at all. */
27
+ versionParentField?: 'screenId' | 'layoutId' | 'componentId' | 'formId';
28
+ /** The plan counter the copy is charged to. */
29
+ quotaKey?: keyof OrgEntitlements & string;
30
+ /** The feature the plan must include for the kind to exist at all. */
31
+ entitlement?: keyof OrgFeatureFlags;
32
+ /** Whether a `slug` on the source becomes a unique `-copy` slug. */
33
+ slug?: boolean;
34
+ /** What the copy carries beyond the source's fields. */
35
+ stamps?: Readonly<Record<string, unknown>>;
36
+ }
37
+ /**
38
+ * The recipe for a kind: core's own, else the one its plugin declared, else
39
+ * `null` — which is refused, never guessed at. A declared copy is met against
40
+ * the same plan counter and feature as the kind's create.
41
+ */
42
+ export declare function recipeFor(kind: string): KindRecipe | null;
18
43
  export interface DuplicateResourceOptions {
19
44
  orgId: string;
20
45
  hostId: string;
@@ -53,3 +78,4 @@ export type DuplicateResourceResult = {
53
78
  * may write content on `hostId`; this decides everything after that.
54
79
  */
55
80
  export declare function duplicateResource(kind: DuplicableHostResourceKind, options: DuplicateResourceOptions): Promise<DuplicateResourceResult>;
81
+ export {};
@@ -18,9 +18,10 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  * Duplicating a site resource whole, as a draft (AGL-2936).
19
19
  *
20
20
  * One module for every kind that lives under a site — screens and email
21
- * designs, reusable components, layouts, templates, forms and workflows —
22
- * so the console route, the AI runtime's `duplicate_resource` tool and any
23
- * later door make the same copy: the same fields, the same uniqueness
21
+ * designs, reusable components, layouts, templates and forms, and the kinds
22
+ * a plugin declares a copy for (a workflow) — so the console route, the AI
23
+ * runtime's `duplicate_resource` tool and any later door make the same copy:
24
+ * the same fields, the same uniqueness
24
25
  * rules, the same band arithmetic, the same activity rows. A caller that
25
26
  * has verified who is asking and that they may write the site hands over
26
27
  * the ids and gets back the copy's id, or a refusal it can answer with.
@@ -35,8 +36,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
35
36
  * points at it, and nothing points at the copy;
36
37
  * - a layout, component or form is served only where a page places it,
37
38
  * and nothing places the copy;
38
- * - a workflow runs only when its trigger fires, and the copy's trigger
39
- * is cleared;
39
+ * - a declared kind drops what its plugin says a copy must not carry —
40
+ * a workflow's trigger is cleared, so the copy runs nothing;
40
41
  * - a template is inert until it is used.
41
42
  *
42
43
  * COUNTED LIKE A CREATE. The copy is one more document in a collection the
@@ -69,6 +70,7 @@ import { artifactCreateListKeys } from "@aglyn/aglyn/app-utils/artifact-list-key
69
70
  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";
70
71
  import { DUPLICABLE_RESOURCE_NOUNS, DUPLICATE_BUSY_MESSAGE, duplicateDisplayName, duplicateVersionNote, uniqueDuplicateName, uniqueDuplicateSlug } from "@aglyn/aglyn/app-utils/duplicate-resource";
71
72
  import { formDesignReboundTo } from "@aglyn/aglyn/app-utils/forms";
73
+ import { pluginHostResource } from "@aglyn/aglyn/plugin-manager/plugin-host-resources";
72
74
  import { Timestamp } from "firebase-admin/firestore";
73
75
  import { logResourceDuplicated } from "./duplicate-activity.js";
74
76
  import firebaseAdmin from "./firebase-admin.js";
@@ -76,9 +78,14 @@ import firebaseAdmin from "./firebase-admin.js";
76
78
  * Per kind, the same fields the create route's allow-list admits, less the
77
79
  * ones a copy must not inherit: a screen's `versionId` (the copy mints its
78
80
  * own), a template's `source` (stamped `authored` below — a copy of a
79
- * marketplace template is the customer's own), a workflow's `trigger`
80
- * (cleared, so the copy runs nothing until armed), and anything the server
81
+ * marketplace template is the customer's own), and anything the server
81
82
  * stamps on every create.
83
+ *
84
+ * The platform's own kinds only. A kind a plugin owns — a workflow, whose
85
+ * copy clears its trigger so it runs nothing until armed — is declared by
86
+ * that plugin beside its collection in `plugins.config.json` and read by
87
+ * {@link recipeFor} (AGL-3080), compiled so a copy never depends on this
88
+ * process having loaded the plugin.
82
89
  */ const RECIPES = {
83
90
  screen: {
84
91
  collection: 'screens',
@@ -175,29 +182,44 @@ import firebaseAdmin from "./firebase-admin.js";
175
182
  quotaKey: 'formsPerHost',
176
183
  entitlement: 'reusableComponents',
177
184
  slug: true
178
- },
179
- workflow: {
180
- collection: 'workflows',
181
- nameField: 'name',
182
- fields: [
183
- 'steps',
184
- 'returnValue'
185
- ],
186
- quotaKey: 'workflowsPerHost',
187
- entitlement: 'workflows',
188
- stamps: {
189
- trigger: null
190
- }
191
185
  }
192
186
  };
187
+ /**
188
+ * The recipe for a kind: core's own, else the one its plugin declared, else
189
+ * `null` — which is refused, never guessed at. A declared copy is met against
190
+ * the same plan counter and feature as the kind's create.
191
+ */ export function recipeFor(kind) {
192
+ if (Object.prototype.hasOwnProperty.call(RECIPES, kind)) {
193
+ var _RECIPES_kind;
194
+ return (_RECIPES_kind = RECIPES[kind]) != null ? _RECIPES_kind : null;
195
+ }
196
+ const declared = pluginHostResource(kind);
197
+ if (!(declared == null ? void 0 : declared.duplicate)) return null;
198
+ return _extends({
199
+ collection: declared.collection,
200
+ nameField: declared.duplicate.nameField,
201
+ fields: declared.duplicate.fields
202
+ }, declared.quotaKey ? {
203
+ quotaKey: declared.quotaKey
204
+ } : {}, declared.entitlement ? {
205
+ entitlement: declared.entitlement
206
+ } : {}, declared.duplicate.stamps ? {
207
+ stamps: declared.duplicate.stamps
208
+ } : {});
209
+ }
193
210
  const asRecord = (value)=>value && typeof value === 'object' && !Array.isArray(value) ? value : {};
194
211
  /**
195
212
  * Makes the copy. The caller has authenticated `uid` and verified that they
196
213
  * may write content on `hostId`; this decides everything after that.
197
214
  */ export async function duplicateResource(kind, options) {
198
215
  var _options_sourceId, _options_attemptKey;
199
- const recipe = RECIPES[kind];
216
+ const recipe = recipeFor(kind);
200
217
  const noun = DUPLICABLE_RESOURCE_NOUNS[kind];
218
+ if (!recipe) return {
219
+ ok: false,
220
+ status: 400,
221
+ error: 'That resource cannot be duplicated'
222
+ };
201
223
  const sourceId = String((_options_sourceId = options.sourceId) != null ? _options_sourceId : '').trim().slice(0, 64);
202
224
  if (!sourceId) return {
203
225
  ok: false,
@@ -325,14 +347,14 @@ async function copy(firestore, kind, recipe, options) {
325
347
  return {
326
348
  ok: false,
327
349
  status: 400,
328
- error: 'That screen is not an email design'
350
+ error: 'That page is not an email design'
329
351
  };
330
352
  }
331
353
  if (kind === 'screen' && nonPageScreen && sourceKind !== SCREEN_KIND_TEMPLATE) {
332
354
  return {
333
355
  ok: false,
334
356
  status: 400,
335
- error: 'Only a page or a collection entry template can be duplicated as a screen'
357
+ error: 'Only a page or a collection entry template can be duplicated as a page'
336
358
  };
337
359
  }
338
360
  }
@@ -366,7 +388,7 @@ async function copy(firestore, kind, recipe, options) {
366
388
  return {
367
389
  ok: false,
368
390
  status: 403,
369
- error: 'This site is at its limit of ' + `${NON_PAGE_SCREEN_MAX_PER_HOST} email and template screens — ` + 'delete some to make room'
391
+ error: 'This site is at its limit of ' + `${NON_PAGE_SCREEN_MAX_PER_HOST} email designs and template pages — ` + 'delete some to make room'
370
392
  };
371
393
  }
372
394
  } else if (recipe.quotaKey) {
@@ -376,7 +398,7 @@ async function copy(firestore, kind, recipe, options) {
376
398
  return {
377
399
  ok: false,
378
400
  status: 403,
379
- error: `Your plan includes ${quota.limit} ${noun}s — ` + 'upgrade in Billing for more'
401
+ error: `Your plan includes ${quota.limit} ${noun}${quota.limit === 1 ? '' : 's'} — ` + 'upgrade in Billing for more'
380
402
  };
381
403
  }
382
404
  }