@aglyn/plugins-ai 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 (187) hide show
  1. package/README.md +2 -2
  2. package/package.json +17 -17
  3. package/src/index.d.ts +1 -0
  4. package/src/index.js.map +1 -1
  5. package/src/lib/activity/ai-activity-actions.js +10 -2
  6. package/src/lib/activity/ai-activity-actions.js.map +1 -1
  7. package/src/lib/components/ai-assist-provider.component.js +2 -2
  8. package/src/lib/components/ai-assist-provider.component.js.map +1 -1
  9. package/src/lib/components/ai-brief-dialog.component.js +3 -3
  10. package/src/lib/components/ai-brief-dialog.component.js.map +1 -1
  11. package/src/lib/components/ai-experiment-cards.component.js +1 -1
  12. package/src/lib/components/ai-experiment-cards.component.js.map +1 -1
  13. package/src/lib/components/ai-job-plan.component.d.ts +8 -0
  14. package/src/lib/components/ai-job-plan.component.js +21 -4
  15. package/src/lib/components/ai-job-plan.component.js.map +1 -1
  16. package/src/lib/components/ai-permissions-on-host.component.d.ts +12 -2
  17. package/src/lib/components/ai-permissions-on-host.component.js +12 -3
  18. package/src/lib/components/ai-permissions-on-host.component.js.map +1 -1
  19. package/src/lib/components/ai-product-copy-card.component.d.ts +1 -1
  20. package/src/lib/components/ai-product-copy-card.component.js.map +1 -1
  21. package/src/lib/components/ai-product-import-option.component.d.ts +1 -1
  22. package/src/lib/components/ai-product-import-option.component.js.map +1 -1
  23. package/src/lib/components/ai-product-zones.d.ts +124 -0
  24. package/src/lib/components/ai-product-zones.js +32 -0
  25. package/src/lib/components/ai-product-zones.js.map +1 -0
  26. package/src/lib/components/ai-products-hub-card.component.d.ts +1 -1
  27. package/src/lib/components/ai-products-hub-card.component.js.map +1 -1
  28. package/src/lib/components/ai-products-proposals.component.d.ts +1 -1
  29. package/src/lib/components/ai-products-proposals.component.js.map +1 -1
  30. package/src/lib/components/ai-seo-audit-card.component.js +35 -92
  31. package/src/lib/components/ai-seo-audit-card.component.js.map +1 -1
  32. package/src/lib/components/ai-seo-fields-card.component.js.map +1 -1
  33. package/src/lib/components/ai-theme-proposal-card.component.js.map +1 -1
  34. package/src/lib/components/assist-jobs-drawer.component.js +21 -15
  35. package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
  36. package/src/lib/components/assist-panel.component.js +2 -2
  37. package/src/lib/components/assist-panel.component.js.map +1 -1
  38. package/src/lib/components/assist-signals-page.component.js +2 -2
  39. package/src/lib/components/assist-signals-page.component.js.map +1 -1
  40. package/src/lib/jobs/ai-email-bindings.d.ts +28 -21
  41. package/src/lib/jobs/ai-email-bindings.js +75 -42
  42. package/src/lib/jobs/ai-email-bindings.js.map +1 -1
  43. package/src/lib/jobs/ai-job-campaign-step.d.ts +0 -2
  44. package/src/lib/jobs/ai-job-campaign-step.js +5 -10
  45. package/src/lib/jobs/ai-job-campaign-step.js.map +1 -1
  46. package/src/lib/jobs/ai-job-component-step.js +9 -3
  47. package/src/lib/jobs/ai-job-component-step.js.map +1 -1
  48. package/src/lib/jobs/ai-job-drafts.d.ts +9 -1
  49. package/src/lib/jobs/ai-job-drafts.js +12 -5
  50. package/src/lib/jobs/ai-job-drafts.js.map +1 -1
  51. package/src/lib/jobs/ai-job-email-step.d.ts +0 -2
  52. package/src/lib/jobs/ai-job-email-step.js +1 -3
  53. package/src/lib/jobs/ai-job-email-step.js.map +1 -1
  54. package/src/lib/jobs/ai-job-experiment-step.d.ts +1 -1
  55. package/src/lib/jobs/ai-job-experiment-step.js +1 -1
  56. package/src/lib/jobs/ai-job-experiment-step.js.map +1 -1
  57. package/src/lib/jobs/ai-job-generation.d.ts +1 -1
  58. package/src/lib/jobs/ai-job-generation.js +10 -2
  59. package/src/lib/jobs/ai-job-generation.js.map +1 -1
  60. package/src/lib/jobs/ai-job-layout-step.js +1 -1
  61. package/src/lib/jobs/ai-job-layout-step.js.map +1 -1
  62. package/src/lib/jobs/ai-job-page-sections.d.ts +4 -2
  63. package/src/lib/jobs/ai-job-page-sections.js +7 -4
  64. package/src/lib/jobs/ai-job-page-sections.js.map +1 -1
  65. package/src/lib/jobs/ai-job-page-step.js +7 -2
  66. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  67. package/src/lib/jobs/ai-job-plan-conformance.js +33 -0
  68. package/src/lib/jobs/ai-job-plan-conformance.js.map +1 -1
  69. package/src/lib/jobs/ai-job-plan-step.d.ts +3 -0
  70. package/src/lib/jobs/ai-job-plan-step.js +29 -17
  71. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  72. package/src/lib/jobs/ai-job-plugin-drafts.d.ts +14 -6
  73. package/src/lib/jobs/ai-job-plugin-drafts.js +12 -4
  74. package/src/lib/jobs/ai-job-plugin-drafts.js.map +1 -1
  75. package/src/lib/jobs/ai-job-products-step.d.ts +7 -0
  76. package/src/lib/jobs/ai-job-products-step.js +54 -32
  77. package/src/lib/jobs/ai-job-products-step.js.map +1 -1
  78. package/src/lib/jobs/ai-job-seo-step.d.ts +3 -24
  79. package/src/lib/jobs/ai-job-seo-step.js +35 -108
  80. package/src/lib/jobs/ai-job-seo-step.js.map +1 -1
  81. package/src/lib/jobs/ai-job-template-step.js +1 -0
  82. package/src/lib/jobs/ai-job-template-step.js.map +1 -1
  83. package/src/lib/jobs/ai-job-theme-step.d.ts +1 -1
  84. package/src/lib/jobs/ai-job-theme-step.js +1 -1
  85. package/src/lib/jobs/ai-job-theme-step.js.map +1 -1
  86. package/src/lib/jobs/ai-job-workflow-step.js +17 -9
  87. package/src/lib/jobs/ai-job-workflow-step.js.map +1 -1
  88. package/src/lib/jobs/ai-workflow-records.d.ts +19 -11
  89. package/src/lib/jobs/ai-workflow-records.js +60 -27
  90. package/src/lib/jobs/ai-workflow-records.js.map +1 -1
  91. package/src/lib/model/ai-allotments.d.ts +11 -3
  92. package/src/lib/model/ai-allotments.js +21 -4
  93. package/src/lib/model/ai-allotments.js.map +1 -1
  94. package/src/lib/model/ai-automation-outline.d.ts +21 -2
  95. package/src/lib/model/ai-automation-outline.js.map +1 -1
  96. package/src/lib/model/ai-build-plan.d.ts +57 -0
  97. package/src/lib/model/ai-build-plan.js +116 -7
  98. package/src/lib/model/ai-build-plan.js.map +1 -1
  99. package/src/lib/model/ai-seo.d.ts +11 -51
  100. package/src/lib/model/ai-seo.js +0 -17
  101. package/src/lib/model/ai-seo.js.map +1 -1
  102. package/src/lib/model/ai-site-inventory.js +1 -1
  103. package/src/lib/model/ai-site-inventory.js.map +1 -1
  104. package/src/lib/model/ai-template-subjects.js +2 -2
  105. package/src/lib/model/ai-template-subjects.js.map +1 -1
  106. package/src/lib/model/ai-theme-proposal.d.ts +1 -1
  107. package/src/lib/model/ai-theme-proposal.js +1 -1
  108. package/src/lib/model/ai-theme-proposal.js.map +1 -1
  109. package/src/lib/model/ai-workflow-job.d.ts +1 -3
  110. package/src/lib/model/ai-workflow-job.js +1 -2
  111. package/src/lib/model/ai-workflow-job.js.map +1 -1
  112. package/src/lib/plan-entitlements.d.ts +149 -0
  113. package/src/lib/plan-entitlements.js +168 -0
  114. package/src/lib/plan-entitlements.js.map +1 -0
  115. package/src/lib/plugin.js +1 -1
  116. package/src/lib/plugin.js.map +1 -1
  117. package/src/lib/runtime/ai-doctrine-validators.d.ts +25 -4
  118. package/src/lib/runtime/ai-doctrine-validators.js +179 -23
  119. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  120. package/src/lib/runtime/ai-doctrine.js +5 -5
  121. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  122. package/src/lib/runtime/ai-eval-live.js +29 -14
  123. package/src/lib/runtime/ai-eval-live.js.map +1 -1
  124. package/src/lib/runtime/ai-eval.d.ts +8 -1
  125. package/src/lib/runtime/ai-eval.js +29 -2
  126. package/src/lib/runtime/ai-eval.js.map +1 -1
  127. package/src/lib/runtime/ai-node-tree.js +3 -3
  128. package/src/lib/runtime/ai-node-tree.js.map +1 -1
  129. package/src/lib/runtime/ai-palette.d.ts +6 -0
  130. package/src/lib/runtime/ai-palette.generated.js +21 -255
  131. package/src/lib/runtime/ai-palette.generated.js.map +1 -1
  132. package/src/lib/runtime/ai-palette.js +7 -1
  133. package/src/lib/runtime/ai-palette.js.map +1 -1
  134. package/src/lib/runtime/seo-audit.d.ts +13 -63
  135. package/src/lib/runtime/seo-audit.js +22 -207
  136. package/src/lib/runtime/seo-audit.js.map +1 -1
  137. package/src/lib/runtime/site-inventory.js +1 -1
  138. package/src/lib/runtime/site-inventory.js.map +1 -1
  139. package/src/lib/server/ai-admin-org.js +4 -25
  140. package/src/lib/server/ai-admin-org.js.map +1 -1
  141. package/src/lib/server/ai-admin-user.js +2 -19
  142. package/src/lib/server/ai-admin-user.js.map +1 -1
  143. package/src/lib/server/ai-assist-prompts.js +3 -3
  144. package/src/lib/server/ai-assist-prompts.js.map +1 -1
  145. package/src/lib/server/ai-insight-digest-route.js +8 -1
  146. package/src/lib/server/ai-insight-digest-route.js.map +1 -1
  147. package/src/lib/server/assist-chat.js +11 -10
  148. package/src/lib/server/assist-chat.js.map +1 -1
  149. package/src/lib/server/assist-view-context.js +23 -23
  150. package/src/lib/server/assist-view-context.js.map +1 -1
  151. package/src/lib/server.d.ts +1 -0
  152. package/src/lib/server.js.map +1 -1
  153. package/src/lib/testing/stand-in-automation-index.d.ts +20 -0
  154. package/src/lib/testing/stand-in-automation-index.js +102 -0
  155. package/src/lib/testing/stand-in-automation-index.js.map +1 -0
  156. package/src/lib/testing/stand-in-list-send-time.d.ts +4 -0
  157. package/src/lib/testing/stand-in-list-send-time.js +77 -0
  158. package/src/lib/testing/stand-in-list-send-time.js.map +1 -0
  159. package/src/lib/testing/stand-in-pipeline-index.d.ts +32 -0
  160. package/src/lib/testing/stand-in-pipeline-index.js +59 -0
  161. package/src/lib/testing/stand-in-pipeline-index.js.map +1 -0
  162. package/src/lib/testing/stand-in-product-index.d.ts +20 -0
  163. package/src/lib/testing/stand-in-product-index.js +105 -0
  164. package/src/lib/testing/stand-in-product-index.js.map +1 -0
  165. package/src/lib/tools/ai-component-tool.js +2 -2
  166. package/src/lib/tools/ai-component-tool.js.map +1 -1
  167. package/src/lib/tools/ai-duplicate-resource-tool.js +1 -1
  168. package/src/lib/tools/ai-duplicate-resource-tool.js.map +1 -1
  169. package/src/lib/tools/ai-seo-tool.d.ts +6 -18
  170. package/src/lib/tools/ai-seo-tool.js +7 -38
  171. package/src/lib/tools/ai-seo-tool.js.map +1 -1
  172. package/src/lib/tools/ai-workflow-tool.d.ts +1 -1
  173. package/src/lib/tools/ai-workflow-tool.js +1 -1
  174. package/src/lib/tools/ai-workflow-tool.js.map +1 -1
  175. package/src/lib/usage/ai-allotment-alerts.d.ts +3 -0
  176. package/src/lib/usage/ai-allotment-alerts.js +20 -8
  177. package/src/lib/usage/ai-allotment-alerts.js.map +1 -1
  178. package/src/lib/usage/assist-free-taste.js +2 -1
  179. package/src/lib/usage/assist-free-taste.js.map +1 -1
  180. package/src/lib/usage/assist-signal-mining.js +1 -1
  181. package/src/lib/usage/assist-signal-mining.js.map +1 -1
  182. package/src/lib/usage-axes.d.ts +39 -0
  183. package/src/lib/usage-axes.js +70 -0
  184. package/src/lib/usage-axes.js.map +1 -0
  185. package/src/lib/runtime/seo-page-facts.d.ts +0 -61
  186. package/src/lib/runtime/seo-page-facts.js +0 -150
  187. package/src/lib/runtime/seo-page-facts.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/site-inventory.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 { hostCollectionKind } from '@aglyn/aglyn/app-utils/collection-kind'\nimport { effectiveDatasetModel } from '@aglyn/aglyn/app-utils/dataset-models'\nimport { datasetDisplayName } from '@aglyn/aglyn/app-utils/datasets'\nimport { isFormArchived } from '@aglyn/aglyn/app-utils/forms'\nimport {\n describeTheme,\n resolveSiteTheme,\n type ThemeHostDocument,\n} from '@aglyn/aglyn/app-utils/marketplace-theme'\nimport {\n REUSABLE_COMPONENT_KIND_EMAIL,\n reusableComponentKindOf,\n} from '@aglyn/aglyn/app-utils/reusable-component-kind'\nimport { SCREEN_KIND_TEMPLATE } from '@aglyn/aglyn/app-utils/screen-route'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport {\n resolveOrgIdForHost,\n scopedToHost,\n} from '@aglyn/tenant-data-admin/server/organizations'\nimport {\n AI_INVENTORY_KINDS,\n AI_SITE_INVENTORY_MAX_PER_KIND,\n AiInventoryScopeError,\n type AiComponentPropTypes,\n type AiInventoryCollection,\n type AiInventoryComponent,\n type AiInventoryDataset,\n type AiInventoryForm,\n type AiInventoryKind,\n type AiInventoryLayout,\n type AiInventoryScreen,\n type AiInventoryTemplate,\n type AiInventoryTheme,\n type AiSiteInventory,\n} from '../model/ai-site-inventory'\nimport { aiBracketedFacts } from './ai-doctrine-validators'\n\n/**\n * The reader behind the site inventory (AGL-2935): one site's components,\n * layouts, templates, forms, datasets, content collections, screens and\n * theme, read as projections through the Admin SDK and reduced to the\n * compact shape `model/ai-site-inventory.ts` describes.\n *\n * Every kind is a projection (`select`) of the fields the inventory names —\n * never a node map, never a record's content — read one row past the cap so\n * a full page is told apart from a cut one. Retired rows (soft-deleted,\n * archived, a component never published) are filtered after the read, so a\n * site with many retired rows can list fewer than the cap; the kind is still\n * marked truncated when the read was cut, which is the honest answer.\n *\n * The window is WIDER than the prompt lists (AGL-2937). The prompt block\n * lists `AI_SITE_INVENTORY_LISTED_PER_KIND` records of a kind because every\n * character of it is billed on every attempt; the rest of the window is what\n * the lookup tool searches, and a record never read cannot be found. The\n * difference is paid in document reads rather than tokens — a projection of\n * a few fields, once per job step — which is the cheaper of the two by some\n * orders of magnitude.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Data = Record<string, unknown>\n\nexport interface ReadSiteInventoryOptions {\n /** The Admin SDK handle; the platform's default app when absent. */\n firestore?: Firestore\n /** Rows read per kind; `AI_SITE_INVENTORY_MAX_PER_KIND` when absent, and never above it. */\n maxPerKind?: number\n}\n\nfunction text(value: unknown): string {\n return typeof value === 'string' ? value.trim() : ''\n}\n\n/** A record's name, or its id when it was saved without one. */\nfunction nameOf(data: Data, id: string, ...fields: string[]): string {\n for (const field of fields) {\n const value = text(data[field])\n if (value) return value\n }\n return id\n}\n\n/** The flat `path → color` leaves of one scheme, e.g. `primary.main`. */\nfunction schemeColors(scheme: unknown): Record<string, string> {\n const colors: Record<string, string> = {}\n const visit = (value: unknown, path: string) => {\n if (typeof value === 'string') {\n if (path) colors[path] = value\n return\n }\n if (!value || typeof value !== 'object' || Array.isArray(value)) return\n for (const [key, inner] of Object.entries(value as Data)) {\n visit(inner, path ? `${path}.${key}` : key)\n }\n }\n visit(scheme, '')\n return colors\n}\n\n/** The theme a site renders with, reduced to what a plan and an email can act on. */\nexport function aiInventoryTheme(host: Data | null): AiInventoryTheme | null {\n const theme = resolveSiteTheme(host as ThemeHostDocument | null)\n if (!theme) return null\n const fonts = new Set<string>()\n for (const font of theme.fonts ?? []) {\n if (text(font?.family)) fonts.add(text(font.family))\n }\n return {\n summary: describeTheme(theme),\n colors: schemeColors(theme.colorSchemes?.light),\n fonts: [...fonts],\n }\n}\n\ninterface Window<T> {\n rows: T[]\n truncated: boolean\n}\n\nasync function readWindow<T>(\n query: FirebaseFirestore.Query,\n fields: string[],\n cap: number,\n map: (id: string, data: Data) => T | null,\n): Promise<Window<T>> {\n const snapshot = await query\n .select(...fields)\n .limit(cap + 1)\n .get()\n const rows: T[] = []\n for (const doc of snapshot.docs.slice(0, cap)) {\n const row = map(doc.id, (doc.data() ?? {}) as Data)\n if (row) rows.push(row)\n }\n return { rows, truncated: snapshot.docs.length > cap }\n}\n\n/**\n * Read one site's inventory for a generation.\n *\n * SCOPED before anything is read. The job names a site and an org, and\n * nothing the reader is handed proves the site belongs to the org, so it asks\n * the host index first and refuses a pairing it does not record: another\n * workspace's component names are not this workspace's prompt. Datasets are\n * org-owned and narrowed to what this site may see.\n *\n * Throws `AiInventoryScopeError` for a site outside the org, and lets a\n * Firestore failure propagate: a planner handed an empty inventory by\n * mistake would create everything the site already has.\n */\nexport async function readSiteInventory(\n orgId: string,\n hostId: string,\n options: ReadSiteInventoryOptions = {},\n): Promise<AiSiteInventory> {\n const owner = await resolveOrgIdForHost(hostId)\n if (!owner || owner !== orgId) throw new AiInventoryScopeError(hostId)\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const cap = Math.min(\n AI_SITE_INVENTORY_MAX_PER_KIND,\n Math.max(1, Math.floor(options.maxPerKind ?? AI_SITE_INVENTORY_MAX_PER_KIND)),\n )\n const host = firestore.collection('hosts').doc(hostId)\n\n const [components, layouts, templates, forms, datasets, collections, screens, hostDoc] =\n await Promise.all([\n readWindow<AiInventoryComponent>(\n host.collection('components'),\n ['displayName', 'props', 'rootId', 'versionId', 'deletedAt', 'kind'],\n cap,\n (id, data) => {\n // Soft-deleted, or never published: an instance of either renders nothing.\n if (data['deletedAt'] || !(data['rootId'] || data['versionId'])) return null\n // An email block (AGL-3287) is built from email elements and placed\n // in emails alone: no page, layout or component a job builds can\n // render one, and the email a job builds places no instances.\n if (reusableComponentKindOf(data) === REUSABLE_COMPONENT_KIND_EMAIL) return null\n const props: AiComponentPropTypes = {}\n const bracketedDefaults: Record<string, string[]> = {}\n for (const prop of Array.isArray(data['props']) ? (data['props'] as Data[]) : []) {\n const name = text(prop?.['name'])\n if (!name) continue\n props[name] = text(prop['type']) || 'text'\n const facts = aiBracketedFacts([text(prop['defaultValue'])])\n if (facts.length) bracketedDefaults[name] = facts\n }\n return {\n id,\n name: nameOf(data, id, 'displayName'),\n props,\n ...(Object.keys(bracketedDefaults).length ? { bracketedDefaults } : {}),\n }\n },\n ),\n readWindow<AiInventoryLayout>(\n host.collection('layouts'),\n ['displayName', 'layoutId', 'deletedAt'],\n cap,\n (id, data) =>\n data['deletedAt']\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName'),\n parentId: text(data['layoutId']) || null,\n },\n ),\n readWindow<AiInventoryTemplate>(\n host.collection('templates'),\n ['displayName', 'kind', 'deletedAt'],\n cap,\n (id, data) =>\n data['deletedAt']\n ? null\n : { id, name: nameOf(data, id, 'displayName'), kind: text(data['kind']) || 'page' },\n ),\n readWindow<AiInventoryForm>(\n host.collection('forms'),\n ['displayName', 'slug', 'fields', 'archivedAt'],\n cap,\n (id, data) =>\n isFormArchived(data as { archivedAt?: unknown })\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName', 'slug'),\n fields: (Array.isArray(data['fields']) ? (data['fields'] as Data[]) : [])\n .map((field) => text(field?.['fieldName']))\n .filter(Boolean),\n },\n ),\n readWindow<AiInventoryDataset>(\n scopedToHost(firestore.collection('orgs').doc(orgId).collection('datasets'), hostId),\n ['displayName', 'name', 'fields', 'model', 'deletedAt'],\n cap,\n (id, data) => {\n if (data['deletedAt']) return null\n const model = effectiveDatasetModel(\n data as Parameters<typeof effectiveDatasetModel>[0],\n )\n const fields = model.order\n .map((fieldId) => text(model.fields[fieldId]?.name) || fieldId)\n .filter(Boolean)\n return { id, name: datasetDisplayName(data) || id, fields }\n },\n ),\n readWindow<AiInventoryCollection>(\n host.collection('collections'),\n ['displayName', 'name', 'slug', 'kind'],\n cap,\n (id, data) =>\n hostCollectionKind(data) !== 'content'\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName', 'name', 'slug'),\n slug: text(data['slug']),\n },\n ),\n readWindow<AiInventoryScreen>(\n host.collection('screens'),\n ['displayName', 'slug', 'layoutId', 'kind', 'deletedAt'],\n cap,\n (id, data) => {\n const kind = text(data['kind'])\n // An email design and an error body are not pages a plan links or duplicates.\n if (data['deletedAt'] || (kind && kind !== SCREEN_KIND_TEMPLATE)) return null\n return {\n id,\n name: nameOf(data, id, 'displayName', 'slug'),\n slug: text(data['slug']),\n layoutId: text(data['layoutId']) || null,\n template: kind === SCREEN_KIND_TEMPLATE,\n }\n },\n ),\n host.get(),\n ])\n\n const windows: Record<AiInventoryKind, Window<unknown>> = {\n components,\n layouts,\n templates,\n forms,\n datasets,\n collections,\n screens,\n }\n return {\n hostId,\n components: components.rows,\n layouts: layouts.rows,\n templates: templates.rows,\n forms: forms.rows,\n datasets: datasets.rows,\n collections: collections.rows,\n screens: screens.rows,\n theme: aiInventoryTheme(hostDoc.exists ? ((hostDoc.data() ?? {}) as Data) : null),\n truncated: AI_INVENTORY_KINDS.filter((kind) => windows[kind].truncated),\n }\n}\n"],"names":["hostCollectionKind","effectiveDatasetModel","datasetDisplayName","isFormArchived","describeTheme","resolveSiteTheme","REUSABLE_COMPONENT_KIND_EMAIL","reusableComponentKindOf","SCREEN_KIND_TEMPLATE","firebaseAdmin","resolveOrgIdForHost","scopedToHost","AI_INVENTORY_KINDS","AI_SITE_INVENTORY_MAX_PER_KIND","AiInventoryScopeError","aiBracketedFacts","text","value","trim","nameOf","data","id","fields","field","schemeColors","scheme","colors","visit","path","Array","isArray","key","inner","Object","entries","aiInventoryTheme","host","theme","fonts","Set","font","family","add","summary","colorSchemes","light","readWindow","query","cap","map","snapshot","select","limit","get","rows","doc","docs","slice","row","push","truncated","length","readSiteInventory","orgId","hostId","options","hostDoc","owner","firestore","app","Math","min","max","floor","maxPerKind","collection","components","layouts","templates","forms","datasets","collections","screens","Promise","all","props","bracketedDefaults","prop","name","facts","keys","parentId","kind","filter","Boolean","model","order","fieldId","slug","layoutId","template","windows","exists"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/site-inventory.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 { hostCollectionKind } from '@aglyn/aglyn/app-utils/collection-kind'\nimport { effectiveDatasetModel } from '@aglyn/aglyn/app-utils/dataset-models'\nimport { datasetDisplayName } from '@aglyn/aglyn/app-utils/datasets'\nimport { isFormArchived } from '@aglyn/aglyn/app-utils/forms'\nimport {\n describeTheme,\n resolveSiteTheme,\n type ThemeHostDocument,\n} from '@aglyn/aglyn/app-utils/site-theme'\nimport {\n REUSABLE_COMPONENT_KIND_EMAIL,\n reusableComponentKindOf,\n} from '@aglyn/aglyn/app-utils/reusable-component-kind'\nimport { SCREEN_KIND_TEMPLATE } from '@aglyn/aglyn/app-utils/screen-route'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport {\n resolveOrgIdForHost,\n scopedToHost,\n} from '@aglyn/tenant-data-admin/server/organizations'\nimport {\n AI_INVENTORY_KINDS,\n AI_SITE_INVENTORY_MAX_PER_KIND,\n AiInventoryScopeError,\n type AiComponentPropTypes,\n type AiInventoryCollection,\n type AiInventoryComponent,\n type AiInventoryDataset,\n type AiInventoryForm,\n type AiInventoryKind,\n type AiInventoryLayout,\n type AiInventoryScreen,\n type AiInventoryTemplate,\n type AiInventoryTheme,\n type AiSiteInventory,\n} from '../model/ai-site-inventory'\nimport { aiBracketedFacts } from './ai-doctrine-validators'\n\n/**\n * The reader behind the site inventory (AGL-2935): one site's components,\n * layouts, templates, forms, datasets, content collections, screens and\n * theme, read as projections through the Admin SDK and reduced to the\n * compact shape `model/ai-site-inventory.ts` describes.\n *\n * Every kind is a projection (`select`) of the fields the inventory names —\n * never a node map, never a record's content — read one row past the cap so\n * a full page is told apart from a cut one. Retired rows (soft-deleted,\n * archived, a component never published) are filtered after the read, so a\n * site with many retired rows can list fewer than the cap; the kind is still\n * marked truncated when the read was cut, which is the honest answer.\n *\n * The window is WIDER than the prompt lists (AGL-2937). The prompt block\n * lists `AI_SITE_INVENTORY_LISTED_PER_KIND` records of a kind because every\n * character of it is billed on every attempt; the rest of the window is what\n * the lookup tool searches, and a record never read cannot be found. The\n * difference is paid in document reads rather than tokens — a projection of\n * a few fields, once per job step — which is the cheaper of the two by some\n * orders of magnitude.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Data = Record<string, unknown>\n\nexport interface ReadSiteInventoryOptions {\n /** The Admin SDK handle; the platform's default app when absent. */\n firestore?: Firestore\n /** Rows read per kind; `AI_SITE_INVENTORY_MAX_PER_KIND` when absent, and never above it. */\n maxPerKind?: number\n}\n\nfunction text(value: unknown): string {\n return typeof value === 'string' ? value.trim() : ''\n}\n\n/** A record's name, or its id when it was saved without one. */\nfunction nameOf(data: Data, id: string, ...fields: string[]): string {\n for (const field of fields) {\n const value = text(data[field])\n if (value) return value\n }\n return id\n}\n\n/** The flat `path → color` leaves of one scheme, e.g. `primary.main`. */\nfunction schemeColors(scheme: unknown): Record<string, string> {\n const colors: Record<string, string> = {}\n const visit = (value: unknown, path: string) => {\n if (typeof value === 'string') {\n if (path) colors[path] = value\n return\n }\n if (!value || typeof value !== 'object' || Array.isArray(value)) return\n for (const [key, inner] of Object.entries(value as Data)) {\n visit(inner, path ? `${path}.${key}` : key)\n }\n }\n visit(scheme, '')\n return colors\n}\n\n/** The theme a site renders with, reduced to what a plan and an email can act on. */\nexport function aiInventoryTheme(host: Data | null): AiInventoryTheme | null {\n const theme = resolveSiteTheme(host as ThemeHostDocument | null)\n if (!theme) return null\n const fonts = new Set<string>()\n for (const font of theme.fonts ?? []) {\n if (text(font?.family)) fonts.add(text(font.family))\n }\n return {\n summary: describeTheme(theme),\n colors: schemeColors(theme.colorSchemes?.light),\n fonts: [...fonts],\n }\n}\n\ninterface Window<T> {\n rows: T[]\n truncated: boolean\n}\n\nasync function readWindow<T>(\n query: FirebaseFirestore.Query,\n fields: string[],\n cap: number,\n map: (id: string, data: Data) => T | null,\n): Promise<Window<T>> {\n const snapshot = await query\n .select(...fields)\n .limit(cap + 1)\n .get()\n const rows: T[] = []\n for (const doc of snapshot.docs.slice(0, cap)) {\n const row = map(doc.id, (doc.data() ?? {}) as Data)\n if (row) rows.push(row)\n }\n return { rows, truncated: snapshot.docs.length > cap }\n}\n\n/**\n * Read one site's inventory for a generation.\n *\n * SCOPED before anything is read. The job names a site and an org, and\n * nothing the reader is handed proves the site belongs to the org, so it asks\n * the host index first and refuses a pairing it does not record: another\n * workspace's component names are not this workspace's prompt. Datasets are\n * org-owned and narrowed to what this site may see.\n *\n * Throws `AiInventoryScopeError` for a site outside the org, and lets a\n * Firestore failure propagate: a planner handed an empty inventory by\n * mistake would create everything the site already has.\n */\nexport async function readSiteInventory(\n orgId: string,\n hostId: string,\n options: ReadSiteInventoryOptions = {},\n): Promise<AiSiteInventory> {\n const owner = await resolveOrgIdForHost(hostId)\n if (!owner || owner !== orgId) throw new AiInventoryScopeError(hostId)\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const cap = Math.min(\n AI_SITE_INVENTORY_MAX_PER_KIND,\n Math.max(1, Math.floor(options.maxPerKind ?? AI_SITE_INVENTORY_MAX_PER_KIND)),\n )\n const host = firestore.collection('hosts').doc(hostId)\n\n const [components, layouts, templates, forms, datasets, collections, screens, hostDoc] =\n await Promise.all([\n readWindow<AiInventoryComponent>(\n host.collection('components'),\n ['displayName', 'props', 'rootId', 'versionId', 'deletedAt', 'kind'],\n cap,\n (id, data) => {\n // Soft-deleted, or never published: an instance of either renders nothing.\n if (data['deletedAt'] || !(data['rootId'] || data['versionId'])) return null\n // An email block (AGL-3287) is built from email elements and placed\n // in emails alone: no page, layout or component a job builds can\n // render one, and the email a job builds places no instances.\n if (reusableComponentKindOf(data) === REUSABLE_COMPONENT_KIND_EMAIL) return null\n const props: AiComponentPropTypes = {}\n const bracketedDefaults: Record<string, string[]> = {}\n for (const prop of Array.isArray(data['props']) ? (data['props'] as Data[]) : []) {\n const name = text(prop?.['name'])\n if (!name) continue\n props[name] = text(prop['type']) || 'text'\n const facts = aiBracketedFacts([text(prop['defaultValue'])])\n if (facts.length) bracketedDefaults[name] = facts\n }\n return {\n id,\n name: nameOf(data, id, 'displayName'),\n props,\n ...(Object.keys(bracketedDefaults).length ? { bracketedDefaults } : {}),\n }\n },\n ),\n readWindow<AiInventoryLayout>(\n host.collection('layouts'),\n ['displayName', 'layoutId', 'deletedAt'],\n cap,\n (id, data) =>\n data['deletedAt']\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName'),\n parentId: text(data['layoutId']) || null,\n },\n ),\n readWindow<AiInventoryTemplate>(\n host.collection('templates'),\n ['displayName', 'kind', 'deletedAt'],\n cap,\n (id, data) =>\n data['deletedAt']\n ? null\n : { id, name: nameOf(data, id, 'displayName'), kind: text(data['kind']) || 'page' },\n ),\n readWindow<AiInventoryForm>(\n host.collection('forms'),\n ['displayName', 'slug', 'fields', 'archivedAt'],\n cap,\n (id, data) =>\n isFormArchived(data as { archivedAt?: unknown })\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName', 'slug'),\n fields: (Array.isArray(data['fields']) ? (data['fields'] as Data[]) : [])\n .map((field) => text(field?.['fieldName']))\n .filter(Boolean),\n },\n ),\n readWindow<AiInventoryDataset>(\n scopedToHost(firestore.collection('orgs').doc(orgId).collection('datasets'), hostId),\n ['displayName', 'name', 'fields', 'model', 'deletedAt'],\n cap,\n (id, data) => {\n if (data['deletedAt']) return null\n const model = effectiveDatasetModel(\n data as Parameters<typeof effectiveDatasetModel>[0],\n )\n const fields = model.order\n .map((fieldId) => text(model.fields[fieldId]?.name) || fieldId)\n .filter(Boolean)\n return { id, name: datasetDisplayName(data) || id, fields }\n },\n ),\n readWindow<AiInventoryCollection>(\n host.collection('collections'),\n ['displayName', 'name', 'slug', 'kind'],\n cap,\n (id, data) =>\n hostCollectionKind(data) !== 'content'\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName', 'name', 'slug'),\n slug: text(data['slug']),\n },\n ),\n readWindow<AiInventoryScreen>(\n host.collection('screens'),\n ['displayName', 'slug', 'layoutId', 'kind', 'deletedAt'],\n cap,\n (id, data) => {\n const kind = text(data['kind'])\n // An email design and an error body are not pages a plan links or duplicates.\n if (data['deletedAt'] || (kind && kind !== SCREEN_KIND_TEMPLATE)) return null\n return {\n id,\n name: nameOf(data, id, 'displayName', 'slug'),\n slug: text(data['slug']),\n layoutId: text(data['layoutId']) || null,\n template: kind === SCREEN_KIND_TEMPLATE,\n }\n },\n ),\n host.get(),\n ])\n\n const windows: Record<AiInventoryKind, Window<unknown>> = {\n components,\n layouts,\n templates,\n forms,\n datasets,\n collections,\n screens,\n }\n return {\n hostId,\n components: components.rows,\n layouts: layouts.rows,\n templates: templates.rows,\n forms: forms.rows,\n datasets: datasets.rows,\n collections: collections.rows,\n screens: screens.rows,\n theme: aiInventoryTheme(hostDoc.exists ? ((hostDoc.data() ?? {}) as Data) : null),\n truncated: AI_INVENTORY_KINDS.filter((kind) => windows[kind].truncated),\n }\n}\n"],"names":["hostCollectionKind","effectiveDatasetModel","datasetDisplayName","isFormArchived","describeTheme","resolveSiteTheme","REUSABLE_COMPONENT_KIND_EMAIL","reusableComponentKindOf","SCREEN_KIND_TEMPLATE","firebaseAdmin","resolveOrgIdForHost","scopedToHost","AI_INVENTORY_KINDS","AI_SITE_INVENTORY_MAX_PER_KIND","AiInventoryScopeError","aiBracketedFacts","text","value","trim","nameOf","data","id","fields","field","schemeColors","scheme","colors","visit","path","Array","isArray","key","inner","Object","entries","aiInventoryTheme","host","theme","fonts","Set","font","family","add","summary","colorSchemes","light","readWindow","query","cap","map","snapshot","select","limit","get","rows","doc","docs","slice","row","push","truncated","length","readSiteInventory","orgId","hostId","options","hostDoc","owner","firestore","app","Math","min","max","floor","maxPerKind","collection","components","layouts","templates","forms","datasets","collections","screens","Promise","all","props","bracketedDefaults","prop","name","facts","keys","parentId","kind","filter","Boolean","model","order","fieldId","slug","layoutId","template","windows","exists"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,kBAAkB,QAAQ,yCAAwC;AAC3E,SAASC,qBAAqB,QAAQ,wCAAuC;AAC7E,SAASC,kBAAkB,QAAQ,kCAAiC;AACpE,SAASC,cAAc,QAAQ,+BAA8B;AAC7D,SACEC,aAAa,EACbC,gBAAgB,QAEX,oCAAmC;AAC1C,SACEC,6BAA6B,EAC7BC,uBAAuB,QAClB,iDAAgD;AACvD,SAASC,oBAAoB,QAAQ,sCAAqC;AAC1E,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SACEC,mBAAmB,EACnBC,YAAY,QACP,gDAA+C;AACtD,SACEC,kBAAkB,EAClBC,8BAA8B,EAC9BC,qBAAqB,QAYhB,gCAA4B;AACnC,SAASC,gBAAgB,QAAQ,8BAA0B;AAkC3D,SAASC,KAAKC,KAAc;IAC1B,OAAO,OAAOA,UAAU,WAAWA,MAAMC,IAAI,KAAK;AACpD;AAEA,8DAA8D,GAC9D,SAASC,OAAOC,IAAU,EAAEC,EAAU,EAAE,GAAGC,MAAgB;IACzD,KAAK,MAAMC,SAASD,OAAQ;QAC1B,MAAML,QAAQD,KAAKI,IAAI,CAACG,MAAM;QAC9B,IAAIN,OAAO,OAAOA;IACpB;IACA,OAAOI;AACT;AAEA,uEAAuE,GACvE,SAASG,aAAaC,MAAe;IACnC,MAAMC,SAAiC,CAAC;IACxC,MAAMC,QAAQ,CAACV,OAAgBW;QAC7B,IAAI,OAAOX,UAAU,UAAU;YAC7B,IAAIW,MAAMF,MAAM,CAACE,KAAK,GAAGX;YACzB;QACF;QACA,IAAI,CAACA,SAAS,OAAOA,UAAU,YAAYY,MAAMC,OAAO,CAACb,QAAQ;QACjE,KAAK,MAAM,CAACc,KAAKC,MAAM,IAAIC,OAAOC,OAAO,CAACjB,OAAgB;YACxDU,MAAMK,OAAOJ,OAAO,GAAGA,KAAK,CAAC,EAAEG,KAAK,GAAGA;QACzC;IACF;IACAJ,MAAMF,QAAQ;IACd,OAAOC;AACT;AAEA,mFAAmF,GACnF,OAAO,SAASS,iBAAiBC,IAAiB;QAI7BC;QAKIA;IARvB,MAAMA,QAAQhC,iBAAiB+B;IAC/B,IAAI,CAACC,OAAO,OAAO;IACnB,MAAMC,QAAQ,IAAIC;IAClB,KAAK,MAAMC,SAAQH,eAAAA,MAAMC,KAAK,YAAXD,eAAe,EAAE,CAAE;QACpC,IAAIrB,KAAKwB,wBAAAA,KAAMC,MAAM,GAAGH,MAAMI,GAAG,CAAC1B,KAAKwB,KAAKC,MAAM;IACpD;IACA,OAAO;QACLE,SAASvC,cAAciC;QACvBX,QAAQF,cAAaa,sBAAAA,MAAMO,YAAY,qBAAlBP,oBAAoBQ,KAAK;QAC9CP,OAAO;eAAIA;SAAM;IACnB;AACF;AAOA,eAAeQ,WACbC,KAA8B,EAC9BzB,MAAgB,EAChB0B,GAAW,EACXC,GAAyC;IAEzC,MAAMC,WAAW,MAAMH,MACpBI,MAAM,IAAI7B,QACV8B,KAAK,CAACJ,MAAM,GACZK,GAAG;IACN,MAAMC,OAAY,EAAE;IACpB,KAAK,MAAMC,OAAOL,SAASM,IAAI,CAACC,KAAK,CAAC,GAAGT,KAAM;YACpBO;QAAzB,MAAMG,MAAMT,IAAIM,IAAIlC,EAAE,GAAGkC,YAAAA,IAAInC,IAAI,cAARmC,YAAc,CAAC;QACxC,IAAIG,KAAKJ,KAAKK,IAAI,CAACD;IACrB;IACA,OAAO;QAAEJ;QAAMM,WAAWV,SAASM,IAAI,CAACK,MAAM,GAAGb;IAAI;AACvD;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,eAAec,kBACpBC,KAAa,EACbC,MAAc,EACdC,UAAoC,CAAC,CAAC;QAIpBA,oBAGOA,qBAyIoBC;IA9I7C,MAAMC,QAAQ,MAAMzD,oBAAoBsD;IACxC,IAAI,CAACG,SAASA,UAAUJ,OAAO,MAAM,IAAIjD,sBAAsBkD;IAC/D,MAAMI,aAAYH,qBAAAA,QAAQG,SAAS,YAAjBH,qBAAqBxD,cAAc4D,GAAG,GAAGD,SAAS;IACpE,MAAMpB,MAAMsB,KAAKC,GAAG,CAClB1D,gCACAyD,KAAKE,GAAG,CAAC,GAAGF,KAAKG,KAAK,EAACR,sBAAAA,QAAQS,UAAU,YAAlBT,sBAAsBpD;IAE/C,MAAMuB,OAAOgC,UAAUO,UAAU,CAAC,SAASpB,GAAG,CAACS;IAE/C,MAAM,CAACY,YAAYC,SAASC,WAAWC,OAAOC,UAAUC,aAAaC,SAAShB,QAAQ,GACpF,MAAMiB,QAAQC,GAAG,CAAC;QAChBtC,WACEV,KAAKuC,UAAU,CAAC,eAChB;YAAC;YAAe;YAAS;YAAU;YAAa;YAAa;SAAO,EACpE3B,KACA,CAAC3B,IAAID;YACH,2EAA2E;YAC3E,IAAIA,IAAI,CAAC,YAAY,IAAI,CAAEA,CAAAA,IAAI,CAAC,SAAS,IAAIA,IAAI,CAAC,YAAY,AAAD,GAAI,OAAO;YACxE,oEAAoE;YACpE,iEAAiE;YACjE,8DAA8D;YAC9D,IAAIb,wBAAwBa,UAAUd,+BAA+B,OAAO;YAC5E,MAAM+E,QAA8B,CAAC;YACrC,MAAMC,oBAA8C,CAAC;YACrD,KAAK,MAAMC,QAAQ1D,MAAMC,OAAO,CAACV,IAAI,CAAC,QAAQ,IAAKA,IAAI,CAAC,QAAQ,GAAc,EAAE,CAAE;gBAChF,MAAMoE,OAAOxE,KAAKuE,wBAAAA,IAAM,CAAC,OAAO;gBAChC,IAAI,CAACC,MAAM;gBACXH,KAAK,CAACG,KAAK,GAAGxE,KAAKuE,IAAI,CAAC,OAAO,KAAK;gBACpC,MAAME,QAAQ1E,iBAAiB;oBAACC,KAAKuE,IAAI,CAAC,eAAe;iBAAE;gBAC3D,IAAIE,MAAM5B,MAAM,EAAEyB,iBAAiB,CAACE,KAAK,GAAGC;YAC9C;YACA,OAAO;gBACLpE;gBACAmE,MAAMrE,OAAOC,MAAMC,IAAI;gBACvBgE;eACIpD,OAAOyD,IAAI,CAACJ,mBAAmBzB,MAAM,GAAG;gBAAEyB;YAAkB,IAAI,CAAC;QAEzE;QAEFxC,WACEV,KAAKuC,UAAU,CAAC,YAChB;YAAC;YAAe;YAAY;SAAY,EACxC3B,KACA,CAAC3B,IAAID,OACHA,IAAI,CAAC,YAAY,GACb,OACA;gBACEC;gBACAmE,MAAMrE,OAAOC,MAAMC,IAAI;gBACvBsE,UAAU3E,KAAKI,IAAI,CAAC,WAAW,KAAK;YACtC;QAER0B,WACEV,KAAKuC,UAAU,CAAC,cAChB;YAAC;YAAe;YAAQ;SAAY,EACpC3B,KACA,CAAC3B,IAAID,OACHA,IAAI,CAAC,YAAY,GACb,OACA;gBAAEC;gBAAImE,MAAMrE,OAAOC,MAAMC,IAAI;gBAAgBuE,MAAM5E,KAAKI,IAAI,CAAC,OAAO,KAAK;YAAO;QAExF0B,WACEV,KAAKuC,UAAU,CAAC,UAChB;YAAC;YAAe;YAAQ;YAAU;SAAa,EAC/C3B,KACA,CAAC3B,IAAID,OACHjB,eAAeiB,QACX,OACA;gBACEC;gBACAmE,MAAMrE,OAAOC,MAAMC,IAAI,eAAe;gBACtCC,QAAQ,AAACO,CAAAA,MAAMC,OAAO,CAACV,IAAI,CAAC,SAAS,IAAKA,IAAI,CAAC,SAAS,GAAc,EAAE,AAAD,EACpE6B,GAAG,CAAC,CAAC1B,QAAUP,KAAKO,yBAAAA,KAAO,CAAC,YAAY,GACxCsE,MAAM,CAACC;YACZ;QAERhD,WACEnC,aAAayD,UAAUO,UAAU,CAAC,QAAQpB,GAAG,CAACQ,OAAOY,UAAU,CAAC,aAAaX,SAC7E;YAAC;YAAe;YAAQ;YAAU;YAAS;SAAY,EACvDhB,KACA,CAAC3B,IAAID;YACH,IAAIA,IAAI,CAAC,YAAY,EAAE,OAAO;YAC9B,MAAM2E,QAAQ9F,sBACZmB;YAEF,MAAME,SAASyE,MAAMC,KAAK,CACvB/C,GAAG,CAAC,CAACgD;oBAAiBF;uBAAL/E,MAAK+E,wBAAAA,MAAMzE,MAAM,CAAC2E,QAAQ,qBAArBF,sBAAuBP,IAAI,KAAKS;eACtDJ,MAAM,CAACC;YACV,OAAO;gBAAEzE;gBAAImE,MAAMtF,mBAAmBkB,SAASC;gBAAIC;YAAO;QAC5D;QAEFwB,WACEV,KAAKuC,UAAU,CAAC,gBAChB;YAAC;YAAe;YAAQ;YAAQ;SAAO,EACvC3B,KACA,CAAC3B,IAAID,OACHpB,mBAAmBoB,UAAU,YACzB,OACA;gBACEC;gBACAmE,MAAMrE,OAAOC,MAAMC,IAAI,eAAe,QAAQ;gBAC9C6E,MAAMlF,KAAKI,IAAI,CAAC,OAAO;YACzB;QAER0B,WACEV,KAAKuC,UAAU,CAAC,YAChB;YAAC;YAAe;YAAQ;YAAY;YAAQ;SAAY,EACxD3B,KACA,CAAC3B,IAAID;YACH,MAAMwE,OAAO5E,KAAKI,IAAI,CAAC,OAAO;YAC9B,8EAA8E;YAC9E,IAAIA,IAAI,CAAC,YAAY,IAAKwE,QAAQA,SAASpF,sBAAuB,OAAO;YACzE,OAAO;gBACLa;gBACAmE,MAAMrE,OAAOC,MAAMC,IAAI,eAAe;gBACtC6E,MAAMlF,KAAKI,IAAI,CAAC,OAAO;gBACvB+E,UAAUnF,KAAKI,IAAI,CAAC,WAAW,KAAK;gBACpCgF,UAAUR,SAASpF;YACrB;QACF;QAEF4B,KAAKiB,GAAG;KACT;IAEH,MAAMgD,UAAoD;QACxDzB;QACAC;QACAC;QACAC;QACAC;QACAC;QACAC;IACF;IACA,OAAO;QACLlB;QACAY,YAAYA,WAAWtB,IAAI;QAC3BuB,SAASA,QAAQvB,IAAI;QACrBwB,WAAWA,UAAUxB,IAAI;QACzByB,OAAOA,MAAMzB,IAAI;QACjB0B,UAAUA,SAAS1B,IAAI;QACvB2B,aAAaA,YAAY3B,IAAI;QAC7B4B,SAASA,QAAQ5B,IAAI;QACrBjB,OAAOF,iBAAiB+B,QAAQoC,MAAM,IAAKpC,gBAAAA,QAAQ9C,IAAI,cAAZ8C,gBAAkB,CAAC,IAAc;QAC5EN,WAAWhD,mBAAmBiF,MAAM,CAAC,CAACD,OAASS,OAAO,CAACT,KAAK,CAAChC,SAAS;IACxE;AACF"}
@@ -19,7 +19,6 @@ import { emailUnverifiedResponse, firebaseAdmin, isImpersonationSession, readOrg
19
19
  import { assistUsageMonth } from "../usage/assist-usage.js";
20
20
  import { assistRefusalCounts } from "../usage/assist-refusals.js";
21
21
  import { readOrgAiUsageByUser } from "../usage/ai-usage-by-user.js";
22
- import { recordAdminAudit } from "@aglyn/tenant-data-admin/server/admin-audit";
23
22
  import { invalidIdTokenResponse } from "@aglyn/tenant-data-admin/server/id-token-refusal";
24
23
  import { addonKindFromPriceId } from "@aglyn/tenant-data-admin/server/billing-addons";
25
24
  import { orgMarginRow } from "@aglyn/aglyn/app-utils/margin-utilization";
@@ -42,15 +41,11 @@ import { composeStaffOrgAiAddon, composeStaffOrgAiMargin, composeStaffOrgAiOvera
42
41
  * rollup for the month; and, when the add-on is on, ONE Stripe subscription
43
42
  * lookup that is cached in-process — see `addonSinceFor`.
44
43
  *
45
- * ## The `access` audit row
44
+ * ## No audit row
46
45
  *
47
- * Opening the card reads per-user attribution, which is the one thing on it
48
- * that is about a person rather than a workspace. So the open is recorded to
49
- * `adminAudit` as an ACCESS — `org.ai-viewed`, target the org's assist usage
50
- * path — through the same writer that records a staff member opening a
51
- * customer's email. No `subjectUid`: the row is about the org, and naming
52
- * every uid on the leaderboard would put a row on ten people's pages for one
53
- * staff glance at a table.
46
+ * The card loads every time the staff org page opens, so an `adminAudit` row
47
+ * per load recorded page views rather than decisions and buried the org's
48
+ * real admin actions under them. The route only reads.
54
49
  */ /** Generation jobs read per request; the counts are a floor past it. */ const JOBS_SCAN = 200;
55
50
  /** How many jobs the card lists. */ const JOBS_RECENT = 10;
56
51
  /** How many people the leaderboard names. */ const TOP_USERS = 10;
@@ -292,22 +287,6 @@ async function handler(request) {
292
287
  // mirror — the same merge the margin route and the org detail read make.
293
288
  const org = _extends({}, (_orgSnap_data = orgSnap.data()) != null ? _orgSnap_data : {}, billingSnap.exists ? billingSnap.data() : {});
294
289
  const monthDoc = monthSnap.exists ? (_monthSnap_data = monthSnap.data()) != null ? _monthSnap_data : {} : null;
295
- /*
296
- * The row is written BEFORE the person-shaped read is served, and it is
297
- * awaited: a card that rendered the leaderboard and then failed to
298
- * record the look would be the access this collection exists to never
299
- * lose. A failed write logs and still serves — the card is not a closed
300
- * collection the way the delivery log is — but the order is the point.
301
- */ try {
302
- await recordAdminAudit({
303
- actorUid: decoded.uid,
304
- action: 'org.ai-viewed',
305
- target: `orgs/${orgId}/assistUsage`,
306
- note: `AI card opened for ${month}`
307
- });
308
- } catch (error) {
309
- console.error('[ai/admin/org] audit write failed', error);
310
- }
311
290
  const [jobs, users, since] = await Promise.all([
312
291
  readOrgAiJobsSummary(db, orgId, month),
313
292
  readOrgAiTopUsers(db, orgId, month),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-admin-org.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 {\n isLiveSubscriptionStatus,\n ORG_BILLING_DOC_ID,\n ORG_BILLING_SUBCOLLECTION,\n orgCogsInputFrom,\n pluginRequestFromWeb,\n} from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n readOrgBilling,\n} from '@aglyn/tenant-data-admin'\nimport { assistUsageMonth } from '../usage/assist-usage'\nimport { assistRefusalCounts } from '../usage/assist-refusals'\nimport { readOrgAiUsageByUser } from '../usage/ai-usage-by-user'\nimport { recordAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { addonKindFromPriceId } from '@aglyn/tenant-data-admin/server/billing-addons'\nimport { orgMarginRow } from '@aglyn/aglyn/app-utils/margin-utilization'\nimport {\n assistCogsAlertThresholdUsd,\n assistMarginMultiple,\n} from '@aglyn/aglyn/app-utils/usage-budget'\nimport {\n composeStaffOrgAiAddon,\n composeStaffOrgAiMargin,\n composeStaffOrgAiOverage,\n composeStaffOrgAiPool,\n composeStaffOrgAiTokens,\n emptyJobCounts,\n STAFF_ORG_AI_JOB_STATUSES,\n type StaffOrgAiAddon,\n type StaffOrgAiJob,\n type StaffOrgAiJobs,\n type StaffOrgAiResponse,\n type StaffOrgAiUser,\n} from '../usage/staff-org-ai'\n\n/**\n * THE STAFF AI CARD'S BACKING READ (AGL-2930).\n *\n * One request composes everything the card on the staff org page shows: the\n * add-on and when it was bought, the credit pool and its parts, the month's\n * overage and the org's own ceilings, the refusal counter, the generation\n * jobs, the per-user rollup and the margin. Staff-gated exactly as\n * `/api/admin/org-usage` is, and read-only — every access below is a `get()`.\n *\n * ## The reads, stated\n *\n * The org document and its billing mirror; this month's `assistUsage`\n * document; the newest usage rollup (for the contribution margin, the same\n * read the margin route makes); a bounded page of `aiJobs`; the per-user\n * rollup for the month; and, when the add-on is on, ONE Stripe subscription\n * lookup that is cached in-process — see `addonSinceFor`.\n *\n * ## The `access` audit row\n *\n * Opening the card reads per-user attribution, which is the one thing on it\n * that is about a person rather than a workspace. So the open is recorded to\n * `adminAudit` as an ACCESS — `org.ai-viewed`, target the org's assist usage\n * path — through the same writer that records a staff member opening a\n * customer's email. No `subjectUid`: the row is about the org, and naming\n * every uid on the leaderboard would put a row on ten people's pages for one\n * staff glance at a table.\n */\n\n/** Generation jobs read per request; the counts are a floor past it. */\nconst JOBS_SCAN = 200\n\n/** How many jobs the card lists. */\nconst JOBS_RECENT = 10\n\n/** How many people the leaderboard names. */\nconst TOP_USERS = 10\n\n/**\n * How long a Stripe answer to \"when was the add-on bought\" is kept.\n *\n * The date does not move once the item exists, and the card is opened far\n * more often than an add-on is bought, so the only cost of a stale entry is\n * a card that says \"since —\" for up to this long after a purchase. The\n * alternative — a Stripe round trip on every render — is the thing the brief\n * forbade.\n */\nconst ADDON_SINCE_TTL_MS = 15 * 60 * 1000\n\ninterface AddonSince {\n since: string | null\n sinceSource: StaffOrgAiAddon['sinceSource']\n at: number\n}\n\nconst addonSinceCache = new Map<string, AddonSince>()\n\n/** Test seam: drop every cached Stripe answer. */\nexport function resetAddonSinceCache(): void {\n addonSinceCache.clear()\n}\n\n/**\n * The instant the add-on's subscription item was created, from Stripe.\n *\n * The org document carries the add-on as a quantity (`seatAddons.aiAddon`)\n * and nothing about WHEN — the webhook mirrors items, not their dates. Stripe\n * has it as `created` on the subscription item, found by the price id the\n * add-on sells at (`addonKindFromPriceId`), so a rename or a price change\n * does not lose it.\n */\nasync function addonSinceFor(orgId: string): Promise<AddonSince> {\n const cached = addonSinceCache.get(orgId)\n if (cached && Date.now() - cached.at < ADDON_SINCE_TTL_MS) return cached\n const answer = await readAddonSince(orgId)\n addonSinceCache.set(orgId, answer)\n return answer\n}\n\nasync function readAddonSince(orgId: string): Promise<AddonSince> {\n const at = Date.now()\n const stripeKey = process.env.STRIPE_SECRET_KEY\n if (!stripeKey) return { since: null, sinceSource: 'unavailable', at }\n try {\n const customerId = (await readOrgBilling(orgId)).stripeCustomerId\n if (!customerId) return { since: null, sinceSource: 'no-subscription', at }\n const response = await fetch(\n `https://api.stripe.com/v1/subscriptions?customer=${encodeURIComponent(\n String(customerId),\n )}&status=all&limit=10`,\n { headers: { Authorization: `Bearer ${stripeKey}` } },\n )\n const payload = await response.json().catch(() => null)\n if (!response.ok) {\n console.error('[ai/admin/org] Stripe subscription lookup failed', {\n orgId,\n status: response.status,\n detail: payload?.error?.message ?? null,\n })\n return { since: null, sinceSource: 'unavailable', at }\n }\n const subscriptions = (payload?.data ?? []) as Array<{\n status?: string\n items?: { data?: Array<{ created?: number; price?: { id?: string } }> }\n }>\n // The LIVE subscription's item, when one exists: an add-on on a canceled\n // subscription is not an add-on the org has, and `hasAiAddon` already\n // says so — the date beside \"off\" would only confuse.\n const live = subscriptions.filter((subscription) =>\n isLiveSubscriptionStatus(subscription.status),\n )\n for (const subscription of live.length ? live : subscriptions) {\n for (const item of subscription.items?.data ?? []) {\n if (addonKindFromPriceId(item.price?.id) !== 'aiAddon') continue\n const created = Number(item.created)\n if (Number.isFinite(created) && created > 0) {\n return {\n since: new Date(created * 1000).toISOString(),\n sinceSource: 'stripe',\n at,\n }\n }\n }\n }\n return { since: null, sinceSource: 'stripe', at }\n } catch (error) {\n console.error('[ai/admin/org] Stripe subscription lookup threw', error)\n return { since: null, sinceSource: 'unavailable', at }\n }\n}\n\nconst toIso = (value: unknown): string | null => {\n const converted = (value as { toDate?: () => Date } | null)?.toDate?.()\n if (converted instanceof Date) return converted.toISOString()\n if (value instanceof Date) return value.toISOString()\n return null\n}\n\n/**\n * The month's generation jobs, summarized (AGL-2904's collection).\n *\n * Reads `orgs/{orgId}/aiJobs` by `createdAt` from the first instant of the\n * month, newest first, up to `JOBS_SCAN`. An org with no such collection\n * answers an empty snapshot, which is the honest \"no generation jobs yet\";\n * a read that FAILED answers `null`, which the card renders as a failure\n * rather than as an idle org. The field names are the ones `AiJob` declares\n * in `ai-jobs.types.ts`: `kind`, `status`, `creditsReserved`, `creditsSpent`,\n * `createdAt`, `createdBy`.\n */\nexport async function readOrgAiJobsSummary(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n month: string,\n): Promise<StaffOrgAiJobs | null> {\n try {\n const monthStart = new Date(`${month}-01T00:00:00.000Z`)\n const snapshot = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('aiJobs')\n .where('createdAt', '>=', monthStart)\n .orderBy('createdAt', 'desc')\n .limit(JOBS_SCAN + 1)\n .get()\n const truncated = snapshot.docs.length > JOBS_SCAN\n const docs = truncated ? snapshot.docs.slice(0, JOBS_SCAN) : snapshot.docs\n const counts = emptyJobCounts()\n const recent: StaffOrgAiJob[] = []\n for (const doc of docs) {\n const status = String(doc.get('status') ?? '')\n if ((STAFF_ORG_AI_JOB_STATUSES as readonly string[]).includes(status)) {\n counts[status as keyof typeof counts] += 1\n }\n if (recent.length < JOBS_RECENT) {\n recent.push({\n id: doc.id,\n kind: String(doc.get('kind') ?? ''),\n status,\n creditsReserved: Number(doc.get('creditsReserved') ?? 0) || 0,\n creditsSpent: Number(doc.get('creditsSpent') ?? 0) || 0,\n createdAt: toIso(doc.get('createdAt')),\n createdBy:\n typeof doc.get('createdBy') === 'string'\n ? String(doc.get('createdBy'))\n : null,\n })\n }\n }\n return { counts, recent, truncated }\n } catch (error) {\n console.error('[ai/admin/org] jobs read failed', error)\n return null\n }\n}\n\n/**\n * The month's per-user rollup, dearest first (AGL-2928).\n *\n * `readOrgAiUsageByUser` is the one reader of\n * `orgs/{orgId}/aiUsageByUser/{uid}/months/{month}` — the customer's Usage\n * table reads through it too — joined to the roster for names and carrying\n * each person's share of the org's measured spend. The card keeps the\n * dearest `TOP_USERS`.\n */\nasync function readOrgAiTopUsers(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n month: string,\n): Promise<StaffOrgAiUser[]> {\n try {\n const usage = await readOrgAiUsageByUser(firestore, orgId, month)\n return usage.rows.slice(0, TOP_USERS).map((row) => ({\n uid: row.uid,\n name: row.name,\n credits: row.credits,\n estCostUsd: row.estCostUsd,\n share: row.share,\n requests: row.requests,\n refusals: row.refusals,\n byKind: row.byKind,\n byHost: row.byHost,\n }))\n } catch (error) {\n console.error('[ai/admin/org] per-user read failed', error)\n return []\n }\n}\n\nasync function handler(request: Request): Promise<Response> {\n const {\n method,\n query,\n headers: rawHeaders,\n } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken)\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n const orgId = String(query.orgId ?? '')\n if (!orgId) return Response.json({ error: 'Missing orgId' }, { status: 400 })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n if (!decoded['staff']) {\n return Response.json({ error: 'Staff only' }, { status: 403 })\n }\n\n const now = new Date()\n const month = assistUsageMonth(now)\n const db = firebaseAdmin.app().firestore()\n const orgRef = db.collection('orgs').doc(orgId)\n\n const [orgSnap, billingSnap, monthSnap, rollupSnap] = await Promise.all([\n orgRef.get(),\n orgRef.collection(ORG_BILLING_SUBCOLLECTION).doc(ORG_BILLING_DOC_ID).get(),\n orgRef.collection('assistUsage').doc(month).get(),\n orgRef.collection('usage').orderBy('month', 'desc').limit(1).get(),\n ])\n if (!orgSnap.exists) {\n return Response.json({ error: 'No such organization' }, { status: 404 })\n }\n // Org doc FIRST so a stale inline `subscription` loses to the billing\n // mirror — the same merge the margin route and the org detail read make.\n const org = {\n ...(orgSnap.data() ?? {}),\n ...(billingSnap.exists ? billingSnap.data() : {}),\n } as Record<string, unknown>\n const monthDoc = monthSnap.exists ? (monthSnap.data() ?? {}) : null\n\n /*\n * The row is written BEFORE the person-shaped read is served, and it is\n * awaited: a card that rendered the leaderboard and then failed to\n * record the look would be the access this collection exists to never\n * lose. A failed write logs and still serves — the card is not a closed\n * collection the way the delivery log is — but the order is the point.\n */\n try {\n await recordAdminAudit({\n actorUid: decoded.uid,\n action: 'org.ai-viewed',\n target: `orgs/${orgId}/assistUsage`,\n note: `AI card opened for ${month}`,\n })\n } catch (error) {\n console.error('[ai/admin/org] audit write failed', error)\n }\n\n const [jobs, users, since] = await Promise.all([\n readOrgAiJobsSummary(db, orgId, month),\n readOrgAiTopUsers(db, orgId, month),\n addonSinceFor(orgId),\n ])\n\n const pool = composeStaffOrgAiPool(org as never, monthDoc, now)\n // The overage line is an invoice, so it is priced off what the month\n // DREW; everything below it is our bill, so it reads what the month\n // COST (AGL-3015).\n const overage = composeStaffOrgAiOverage(org as never, pool.billedUsd)\n const addon = composeStaffOrgAiAddon(org as never, {\n since: since.since,\n sinceSource: since.sinceSource,\n })\n const thresholdUsd = assistCogsAlertThresholdUsd(\n process.env.ASSIST_ORG_MONTHLY_COGS_ALERT_USD,\n )\n const rollupDoc = rollupSnap.docs[0]\n // The whole-org contribution margin, on the same row the margin\n // utilization page builds — with THIS month's live assist spend in\n // place of the figure frozen onto the rollup, as that route does.\n const contributionRow = rollupDoc\n ? orgMarginRow({\n orgId,\n name: (org['name'] as string | undefined) ?? null,\n org: org as never,\n month: String(rollupDoc.get('month') ?? rollupDoc.id),\n rollup: {\n ...orgCogsInputFrom(rollupDoc.data()),\n assistCostUsd: pool.providerUsd,\n },\n })\n : null\n const margin = composeStaffOrgAiMargin({\n org: org as never,\n pool,\n overage,\n addon,\n thresholdUsd,\n multiple: assistMarginMultiple(pool.providerUsd, thresholdUsd),\n contribution: {\n month: contributionRow?.month ?? null,\n marginPct: contributionRow?.marginPct ?? null,\n rating: contributionRow?.rating ?? null,\n },\n })\n\n const body: StaffOrgAiResponse = {\n month,\n addon,\n pool,\n overage,\n refusals: assistRefusalCounts(monthDoc?.['refusals']),\n jobs,\n users,\n margin,\n tokens: composeStaffOrgAiTokens(monthDoc),\n }\n return Response.json(body, { status: 200 })\n } catch (error) {\n // An unverifiable credential is a 401, not a fault of ours\n // (AGL-1993). Null for anything else, so a real failure keeps its 500.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error('[ai/admin/org]', error)\n return Response.json({ error: 'AI lookup failed' }, { status: 500 })\n }\n}\n\nexport { handler as GET }\n"],"names":["isLiveSubscriptionStatus","ORG_BILLING_DOC_ID","ORG_BILLING_SUBCOLLECTION","orgCogsInputFrom","pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","readOrgBilling","assistUsageMonth","assistRefusalCounts","readOrgAiUsageByUser","recordAdminAudit","invalidIdTokenResponse","addonKindFromPriceId","orgMarginRow","assistCogsAlertThresholdUsd","assistMarginMultiple","composeStaffOrgAiAddon","composeStaffOrgAiMargin","composeStaffOrgAiOverage","composeStaffOrgAiPool","composeStaffOrgAiTokens","emptyJobCounts","STAFF_ORG_AI_JOB_STATUSES","JOBS_SCAN","JOBS_RECENT","TOP_USERS","ADDON_SINCE_TTL_MS","addonSinceCache","Map","resetAddonSinceCache","clear","addonSinceFor","orgId","cached","get","Date","now","at","answer","readAddonSince","set","stripeKey","process","env","STRIPE_SECRET_KEY","since","sinceSource","customerId","stripeCustomerId","response","fetch","encodeURIComponent","String","headers","Authorization","payload","json","catch","ok","console","error","status","detail","message","subscriptions","data","live","filter","subscription","length","item","items","price","id","created","Number","isFinite","toISOString","toIso","value","converted","toDate","readOrgAiJobsSummary","firestore","month","monthStart","snapshot","collection","doc","where","orderBy","limit","truncated","docs","slice","counts","recent","includes","push","kind","creditsReserved","creditsSpent","createdAt","createdBy","readOrgAiTopUsers","usage","rows","map","row","uid","name","credits","estCostUsd","share","requests","refusals","byKind","byHost","handler","request","query","method","rawHeaders","Response","authorization","idToken","startsWith","undefined","orgSnap","monthSnap","org","rollupDoc","decoded","app","auth","verifyIdToken","email_verified","db","orgRef","billingSnap","rollupSnap","Promise","all","exists","monthDoc","actorUid","action","target","note","jobs","users","pool","overage","billedUsd","addon","thresholdUsd","ASSIST_ORG_MONTHLY_COGS_ALERT_USD","contributionRow","rollup","assistCostUsd","providerUsd","margin","multiple","contribution","marginPct","rating","body","tokens","unauthenticated","GET"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-admin-org.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 {\n isLiveSubscriptionStatus,\n ORG_BILLING_DOC_ID,\n ORG_BILLING_SUBCOLLECTION,\n orgCogsInputFrom,\n pluginRequestFromWeb,\n} from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n readOrgBilling,\n} from '@aglyn/tenant-data-admin'\nimport { assistUsageMonth } from '../usage/assist-usage'\nimport { assistRefusalCounts } from '../usage/assist-refusals'\nimport { readOrgAiUsageByUser } from '../usage/ai-usage-by-user'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { addonKindFromPriceId } from '@aglyn/tenant-data-admin/server/billing-addons'\nimport { orgMarginRow } from '@aglyn/aglyn/app-utils/margin-utilization'\nimport {\n assistCogsAlertThresholdUsd,\n assistMarginMultiple,\n} from '@aglyn/aglyn/app-utils/usage-budget'\nimport {\n composeStaffOrgAiAddon,\n composeStaffOrgAiMargin,\n composeStaffOrgAiOverage,\n composeStaffOrgAiPool,\n composeStaffOrgAiTokens,\n emptyJobCounts,\n STAFF_ORG_AI_JOB_STATUSES,\n type StaffOrgAiAddon,\n type StaffOrgAiJob,\n type StaffOrgAiJobs,\n type StaffOrgAiResponse,\n type StaffOrgAiUser,\n} from '../usage/staff-org-ai'\n\n/**\n * THE STAFF AI CARD'S BACKING READ (AGL-2930).\n *\n * One request composes everything the card on the staff org page shows: the\n * add-on and when it was bought, the credit pool and its parts, the month's\n * overage and the org's own ceilings, the refusal counter, the generation\n * jobs, the per-user rollup and the margin. Staff-gated exactly as\n * `/api/admin/org-usage` is, and read-only — every access below is a `get()`.\n *\n * ## The reads, stated\n *\n * The org document and its billing mirror; this month's `assistUsage`\n * document; the newest usage rollup (for the contribution margin, the same\n * read the margin route makes); a bounded page of `aiJobs`; the per-user\n * rollup for the month; and, when the add-on is on, ONE Stripe subscription\n * lookup that is cached in-process — see `addonSinceFor`.\n *\n * ## No audit row\n *\n * The card loads every time the staff org page opens, so an `adminAudit` row\n * per load recorded page views rather than decisions and buried the org's\n * real admin actions under them. The route only reads.\n */\n\n/** Generation jobs read per request; the counts are a floor past it. */\nconst JOBS_SCAN = 200\n\n/** How many jobs the card lists. */\nconst JOBS_RECENT = 10\n\n/** How many people the leaderboard names. */\nconst TOP_USERS = 10\n\n/**\n * How long a Stripe answer to \"when was the add-on bought\" is kept.\n *\n * The date does not move once the item exists, and the card is opened far\n * more often than an add-on is bought, so the only cost of a stale entry is\n * a card that says \"since —\" for up to this long after a purchase. The\n * alternative — a Stripe round trip on every render — is the thing the brief\n * forbade.\n */\nconst ADDON_SINCE_TTL_MS = 15 * 60 * 1000\n\ninterface AddonSince {\n since: string | null\n sinceSource: StaffOrgAiAddon['sinceSource']\n at: number\n}\n\nconst addonSinceCache = new Map<string, AddonSince>()\n\n/** Test seam: drop every cached Stripe answer. */\nexport function resetAddonSinceCache(): void {\n addonSinceCache.clear()\n}\n\n/**\n * The instant the add-on's subscription item was created, from Stripe.\n *\n * The org document carries the add-on as a quantity (`seatAddons.aiAddon`)\n * and nothing about WHEN — the webhook mirrors items, not their dates. Stripe\n * has it as `created` on the subscription item, found by the price id the\n * add-on sells at (`addonKindFromPriceId`), so a rename or a price change\n * does not lose it.\n */\nasync function addonSinceFor(orgId: string): Promise<AddonSince> {\n const cached = addonSinceCache.get(orgId)\n if (cached && Date.now() - cached.at < ADDON_SINCE_TTL_MS) return cached\n const answer = await readAddonSince(orgId)\n addonSinceCache.set(orgId, answer)\n return answer\n}\n\nasync function readAddonSince(orgId: string): Promise<AddonSince> {\n const at = Date.now()\n const stripeKey = process.env.STRIPE_SECRET_KEY\n if (!stripeKey) return { since: null, sinceSource: 'unavailable', at }\n try {\n const customerId = (await readOrgBilling(orgId)).stripeCustomerId\n if (!customerId) return { since: null, sinceSource: 'no-subscription', at }\n const response = await fetch(\n `https://api.stripe.com/v1/subscriptions?customer=${encodeURIComponent(\n String(customerId),\n )}&status=all&limit=10`,\n { headers: { Authorization: `Bearer ${stripeKey}` } },\n )\n const payload = await response.json().catch(() => null)\n if (!response.ok) {\n console.error('[ai/admin/org] Stripe subscription lookup failed', {\n orgId,\n status: response.status,\n detail: payload?.error?.message ?? null,\n })\n return { since: null, sinceSource: 'unavailable', at }\n }\n const subscriptions = (payload?.data ?? []) as Array<{\n status?: string\n items?: { data?: Array<{ created?: number; price?: { id?: string } }> }\n }>\n // The LIVE subscription's item, when one exists: an add-on on a canceled\n // subscription is not an add-on the org has, and `hasAiAddon` already\n // says so — the date beside \"off\" would only confuse.\n const live = subscriptions.filter((subscription) =>\n isLiveSubscriptionStatus(subscription.status),\n )\n for (const subscription of live.length ? live : subscriptions) {\n for (const item of subscription.items?.data ?? []) {\n if (addonKindFromPriceId(item.price?.id) !== 'aiAddon') continue\n const created = Number(item.created)\n if (Number.isFinite(created) && created > 0) {\n return {\n since: new Date(created * 1000).toISOString(),\n sinceSource: 'stripe',\n at,\n }\n }\n }\n }\n return { since: null, sinceSource: 'stripe', at }\n } catch (error) {\n console.error('[ai/admin/org] Stripe subscription lookup threw', error)\n return { since: null, sinceSource: 'unavailable', at }\n }\n}\n\nconst toIso = (value: unknown): string | null => {\n const converted = (value as { toDate?: () => Date } | null)?.toDate?.()\n if (converted instanceof Date) return converted.toISOString()\n if (value instanceof Date) return value.toISOString()\n return null\n}\n\n/**\n * The month's generation jobs, summarized (AGL-2904's collection).\n *\n * Reads `orgs/{orgId}/aiJobs` by `createdAt` from the first instant of the\n * month, newest first, up to `JOBS_SCAN`. An org with no such collection\n * answers an empty snapshot, which is the honest \"no generation jobs yet\";\n * a read that FAILED answers `null`, which the card renders as a failure\n * rather than as an idle org. The field names are the ones `AiJob` declares\n * in `ai-jobs.types.ts`: `kind`, `status`, `creditsReserved`, `creditsSpent`,\n * `createdAt`, `createdBy`.\n */\nexport async function readOrgAiJobsSummary(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n month: string,\n): Promise<StaffOrgAiJobs | null> {\n try {\n const monthStart = new Date(`${month}-01T00:00:00.000Z`)\n const snapshot = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('aiJobs')\n .where('createdAt', '>=', monthStart)\n .orderBy('createdAt', 'desc')\n .limit(JOBS_SCAN + 1)\n .get()\n const truncated = snapshot.docs.length > JOBS_SCAN\n const docs = truncated ? snapshot.docs.slice(0, JOBS_SCAN) : snapshot.docs\n const counts = emptyJobCounts()\n const recent: StaffOrgAiJob[] = []\n for (const doc of docs) {\n const status = String(doc.get('status') ?? '')\n if ((STAFF_ORG_AI_JOB_STATUSES as readonly string[]).includes(status)) {\n counts[status as keyof typeof counts] += 1\n }\n if (recent.length < JOBS_RECENT) {\n recent.push({\n id: doc.id,\n kind: String(doc.get('kind') ?? ''),\n status,\n creditsReserved: Number(doc.get('creditsReserved') ?? 0) || 0,\n creditsSpent: Number(doc.get('creditsSpent') ?? 0) || 0,\n createdAt: toIso(doc.get('createdAt')),\n createdBy:\n typeof doc.get('createdBy') === 'string'\n ? String(doc.get('createdBy'))\n : null,\n })\n }\n }\n return { counts, recent, truncated }\n } catch (error) {\n console.error('[ai/admin/org] jobs read failed', error)\n return null\n }\n}\n\n/**\n * The month's per-user rollup, dearest first (AGL-2928).\n *\n * `readOrgAiUsageByUser` is the one reader of\n * `orgs/{orgId}/aiUsageByUser/{uid}/months/{month}` — the customer's Usage\n * table reads through it too — joined to the roster for names and carrying\n * each person's share of the org's measured spend. The card keeps the\n * dearest `TOP_USERS`.\n */\nasync function readOrgAiTopUsers(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n month: string,\n): Promise<StaffOrgAiUser[]> {\n try {\n const usage = await readOrgAiUsageByUser(firestore, orgId, month)\n return usage.rows.slice(0, TOP_USERS).map((row) => ({\n uid: row.uid,\n name: row.name,\n credits: row.credits,\n estCostUsd: row.estCostUsd,\n share: row.share,\n requests: row.requests,\n refusals: row.refusals,\n byKind: row.byKind,\n byHost: row.byHost,\n }))\n } catch (error) {\n console.error('[ai/admin/org] per-user read failed', error)\n return []\n }\n}\n\nasync function handler(request: Request): Promise<Response> {\n const {\n method,\n query,\n headers: rawHeaders,\n } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken)\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n const orgId = String(query.orgId ?? '')\n if (!orgId) return Response.json({ error: 'Missing orgId' }, { status: 400 })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n if (!decoded['staff']) {\n return Response.json({ error: 'Staff only' }, { status: 403 })\n }\n\n const now = new Date()\n const month = assistUsageMonth(now)\n const db = firebaseAdmin.app().firestore()\n const orgRef = db.collection('orgs').doc(orgId)\n\n const [orgSnap, billingSnap, monthSnap, rollupSnap] = await Promise.all([\n orgRef.get(),\n orgRef.collection(ORG_BILLING_SUBCOLLECTION).doc(ORG_BILLING_DOC_ID).get(),\n orgRef.collection('assistUsage').doc(month).get(),\n orgRef.collection('usage').orderBy('month', 'desc').limit(1).get(),\n ])\n if (!orgSnap.exists) {\n return Response.json({ error: 'No such organization' }, { status: 404 })\n }\n // Org doc FIRST so a stale inline `subscription` loses to the billing\n // mirror — the same merge the margin route and the org detail read make.\n const org = {\n ...(orgSnap.data() ?? {}),\n ...(billingSnap.exists ? billingSnap.data() : {}),\n } as Record<string, unknown>\n const monthDoc = monthSnap.exists ? (monthSnap.data() ?? {}) : null\n\n const [jobs, users, since] = await Promise.all([\n readOrgAiJobsSummary(db, orgId, month),\n readOrgAiTopUsers(db, orgId, month),\n addonSinceFor(orgId),\n ])\n\n const pool = composeStaffOrgAiPool(org as never, monthDoc, now)\n // The overage line is an invoice, so it is priced off what the month\n // DREW; everything below it is our bill, so it reads what the month\n // COST (AGL-3015).\n const overage = composeStaffOrgAiOverage(org as never, pool.billedUsd)\n const addon = composeStaffOrgAiAddon(org as never, {\n since: since.since,\n sinceSource: since.sinceSource,\n })\n const thresholdUsd = assistCogsAlertThresholdUsd(\n process.env.ASSIST_ORG_MONTHLY_COGS_ALERT_USD,\n )\n const rollupDoc = rollupSnap.docs[0]\n // The whole-org contribution margin, on the same row the margin\n // utilization page builds — with THIS month's live assist spend in\n // place of the figure frozen onto the rollup, as that route does.\n const contributionRow = rollupDoc\n ? orgMarginRow({\n orgId,\n name: (org['name'] as string | undefined) ?? null,\n org: org as never,\n month: String(rollupDoc.get('month') ?? rollupDoc.id),\n rollup: {\n ...orgCogsInputFrom(rollupDoc.data()),\n assistCostUsd: pool.providerUsd,\n },\n })\n : null\n const margin = composeStaffOrgAiMargin({\n org: org as never,\n pool,\n overage,\n addon,\n thresholdUsd,\n multiple: assistMarginMultiple(pool.providerUsd, thresholdUsd),\n contribution: {\n month: contributionRow?.month ?? null,\n marginPct: contributionRow?.marginPct ?? null,\n rating: contributionRow?.rating ?? null,\n },\n })\n\n const body: StaffOrgAiResponse = {\n month,\n addon,\n pool,\n overage,\n refusals: assistRefusalCounts(monthDoc?.['refusals']),\n jobs,\n users,\n margin,\n tokens: composeStaffOrgAiTokens(monthDoc),\n }\n return Response.json(body, { status: 200 })\n } catch (error) {\n // An unverifiable credential is a 401, not a fault of ours\n // (AGL-1993). Null for anything else, so a real failure keeps its 500.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error('[ai/admin/org]', error)\n return Response.json({ error: 'AI lookup failed' }, { status: 500 })\n }\n}\n\nexport { handler as GET }\n"],"names":["isLiveSubscriptionStatus","ORG_BILLING_DOC_ID","ORG_BILLING_SUBCOLLECTION","orgCogsInputFrom","pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","readOrgBilling","assistUsageMonth","assistRefusalCounts","readOrgAiUsageByUser","invalidIdTokenResponse","addonKindFromPriceId","orgMarginRow","assistCogsAlertThresholdUsd","assistMarginMultiple","composeStaffOrgAiAddon","composeStaffOrgAiMargin","composeStaffOrgAiOverage","composeStaffOrgAiPool","composeStaffOrgAiTokens","emptyJobCounts","STAFF_ORG_AI_JOB_STATUSES","JOBS_SCAN","JOBS_RECENT","TOP_USERS","ADDON_SINCE_TTL_MS","addonSinceCache","Map","resetAddonSinceCache","clear","addonSinceFor","orgId","cached","get","Date","now","at","answer","readAddonSince","set","stripeKey","process","env","STRIPE_SECRET_KEY","since","sinceSource","customerId","stripeCustomerId","response","fetch","encodeURIComponent","String","headers","Authorization","payload","json","catch","ok","console","error","status","detail","message","subscriptions","data","live","filter","subscription","length","item","items","price","id","created","Number","isFinite","toISOString","toIso","value","converted","toDate","readOrgAiJobsSummary","firestore","month","monthStart","snapshot","collection","doc","where","orderBy","limit","truncated","docs","slice","counts","recent","includes","push","kind","creditsReserved","creditsSpent","createdAt","createdBy","readOrgAiTopUsers","usage","rows","map","row","uid","name","credits","estCostUsd","share","requests","refusals","byKind","byHost","handler","request","query","method","rawHeaders","Response","authorization","idToken","startsWith","undefined","orgSnap","monthSnap","org","rollupDoc","decoded","app","auth","verifyIdToken","email_verified","db","orgRef","billingSnap","rollupSnap","Promise","all","exists","monthDoc","jobs","users","pool","overage","billedUsd","addon","thresholdUsd","ASSIST_ORG_MONTHLY_COGS_ALERT_USD","contributionRow","rollup","assistCostUsd","providerUsd","margin","multiple","contribution","marginPct","rating","body","tokens","unauthenticated","GET"],"mappings":"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"}
@@ -16,7 +16,6 @@
16
16
  */ import { pluginRequestFromWeb } from "@aglyn/aglyn/server";
17
17
  import { emailUnverifiedResponse, firebaseAdmin, isImpersonationSession } from "@aglyn/tenant-data-admin";
18
18
  import { readUserAiUsageMonths } from "../usage/ai-usage-by-user.js";
19
- import { recordAdminAudit } from "@aglyn/tenant-data-admin/server/admin-audit";
20
19
  import { invalidIdTokenResponse } from "@aglyn/tenant-data-admin/server/id-token-refusal";
21
20
  /**
22
21
  * ONE ACCOUNT'S AI USAGE ACROSS ORGANIZATIONS (AGL-2928) — the staff user
@@ -29,9 +28,8 @@ import { invalidIdTokenResponse } from "@aglyn/tenant-data-admin/server/id-token
29
28
  * person has left is not listed, and its months expire on the retention
30
29
  * window.
31
30
  *
32
- * Opening it is recorded as an ACCESS about THIS person — `subjectUid` set,
33
- * unlike the org card's row — because the whole response is about one
34
- * account, and that is exactly the row their page exists to show.
31
+ * Read only, and not audited: the card loads with every open of the staff
32
+ * user page, the same reason the org card writes no row.
35
33
  */ /** The most workspaces one account is walked across. */ const MAX_ORGS = 50;
36
34
  async function handler(request) {
37
35
  var _headers_authorization, _query_uid;
@@ -71,21 +69,6 @@ async function handler(request) {
71
69
  }
72
70
  const firestore = firebaseAdmin.app().firestore();
73
71
  const reverse = await firestore.collection('users').doc(uid).collection('orgs').limit(MAX_ORGS).get();
74
- // Recorded BEFORE the person-shaped read is served, and awaited, for
75
- // the reason the org card gives: a card that rendered and then failed
76
- // to record the look would be the access this collection exists to
77
- // never lose.
78
- try {
79
- await recordAdminAudit({
80
- actorUid: decoded.uid,
81
- action: 'user.ai-usage-viewed',
82
- target: `users/${uid}`,
83
- subjectUid: uid,
84
- note: `AI usage opened across ${reverse.size} workspace(s)`
85
- });
86
- } catch (error) {
87
- console.error('[ai/admin/user] audit write failed', error);
88
- }
89
72
  const rows = [];
90
73
  for (const entry of reverse.docs){
91
74
  const months = await readUserAiUsageMonths(firestore, entry.id, uid);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-admin-user.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 { pluginRequestFromWeb } from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin'\nimport { readUserAiUsageMonths } from '../usage/ai-usage-by-user'\nimport { recordAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\n\n/**\n * ONE ACCOUNT'S AI USAGE ACROSS ORGANIZATIONS (AGL-2928) — the staff user\n * page's card, beside `admin/users/detail`.\n *\n * Walks the account's reverse index of memberships, the same read the detail\n * route makes, and lists each workspace's months for this uid newest first.\n * A membership walk rather than a collection-group query, so the card works\n * the moment it ships and needs no index deployed first; a workspace the\n * person has left is not listed, and its months expire on the retention\n * window.\n *\n * Opening it is recorded as an ACCESS about THIS person — `subjectUid` set,\n * unlike the org card's row — because the whole response is about one\n * account, and that is exactly the row their page exists to show.\n */\n\n/** The most workspaces one account is walked across. */\nconst MAX_ORGS = 50\n\nexport interface StaffUserAiUsageRow {\n orgId: string\n orgName: string | null\n slug: string | null\n month: string\n credits: number\n requests: number\n refusals: number\n}\n\nasync function handler(request: Request): Promise<Response> {\n const { method, query, headers: rawHeaders } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n const uid = String(query.uid ?? '').trim()\n if (!uid) return Response.json({ error: 'Missing uid' }, { status: 400 })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n if (!decoded['staff']) {\n return Response.json({ error: 'Staff only' }, { status: 403 })\n }\n const firestore = firebaseAdmin.app().firestore()\n const reverse = await firestore\n .collection('users')\n .doc(uid)\n .collection('orgs')\n .limit(MAX_ORGS)\n .get()\n\n // Recorded BEFORE the person-shaped read is served, and awaited, for\n // the reason the org card gives: a card that rendered and then failed\n // to record the look would be the access this collection exists to\n // never lose.\n try {\n await recordAdminAudit({\n actorUid: decoded.uid,\n action: 'user.ai-usage-viewed',\n target: `users/${uid}`,\n subjectUid: uid,\n note: `AI usage opened across ${reverse.size} workspace(s)`,\n })\n } catch (error) {\n console.error('[ai/admin/user] audit write failed', error)\n }\n\n const rows: StaffUserAiUsageRow[] = []\n for (const entry of reverse.docs) {\n const months = await readUserAiUsageMonths(firestore, entry.id, uid)\n for (const month of months) {\n rows.push({\n orgId: entry.id,\n orgName: typeof entry.get('orgName') === 'string' ? String(entry.get('orgName')) : null,\n slug: typeof entry.get('slug') === 'string' ? String(entry.get('slug')) : null,\n month: month.month,\n credits: month.credits,\n requests: month.requests,\n refusals: month.refusals,\n })\n }\n }\n // Newest month first, dearest workspace first within it.\n rows.sort(\n (a, b) => b.month.localeCompare(a.month) || b.credits - a.credits,\n )\n return Response.json(\n { uid, rows, truncated: reverse.size >= MAX_ORGS },\n { status: 200 },\n )\n } catch (error) {\n // An unverifiable credential is a 401, not a fault of ours\n // (AGL-1993). Null for anything else, so a real failure keeps its 500.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error('[ai/admin/user]', error)\n return Response.json({ error: 'AI usage lookup failed' }, { status: 500 })\n }\n}\n\nexport { handler as GET }\n"],"names":["pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","readUserAiUsageMonths","recordAdminAudit","invalidIdTokenResponse","MAX_ORGS","handler","request","headers","query","method","rawHeaders","Response","json","error","status","authorization","idToken","startsWith","slice","length","undefined","uid","String","trim","decoded","app","auth","verifyIdToken","email_verified","firestore","reverse","collection","doc","limit","get","actorUid","action","target","subjectUid","note","size","console","rows","entry","docs","months","id","month","push","orgId","orgName","slug","credits","requests","refusals","sort","a","b","localeCompare","truncated","unauthenticated","GET"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-admin-user.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 { pluginRequestFromWeb } from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin'\nimport { readUserAiUsageMonths } from '../usage/ai-usage-by-user'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\n\n/**\n * ONE ACCOUNT'S AI USAGE ACROSS ORGANIZATIONS (AGL-2928) — the staff user\n * page's card, beside `admin/users/detail`.\n *\n * Walks the account's reverse index of memberships, the same read the detail\n * route makes, and lists each workspace's months for this uid newest first.\n * A membership walk rather than a collection-group query, so the card works\n * the moment it ships and needs no index deployed first; a workspace the\n * person has left is not listed, and its months expire on the retention\n * window.\n *\n * Read only, and not audited: the card loads with every open of the staff\n * user page, the same reason the org card writes no row.\n */\n\n/** The most workspaces one account is walked across. */\nconst MAX_ORGS = 50\n\nexport interface StaffUserAiUsageRow {\n orgId: string\n orgName: string | null\n slug: string | null\n month: string\n credits: number\n requests: number\n refusals: number\n}\n\nasync function handler(request: Request): Promise<Response> {\n const { method, query, headers: rawHeaders } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n const uid = String(query.uid ?? '').trim()\n if (!uid) return Response.json({ error: 'Missing uid' }, { status: 400 })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n if (!decoded['staff']) {\n return Response.json({ error: 'Staff only' }, { status: 403 })\n }\n const firestore = firebaseAdmin.app().firestore()\n const reverse = await firestore\n .collection('users')\n .doc(uid)\n .collection('orgs')\n .limit(MAX_ORGS)\n .get()\n\n const rows: StaffUserAiUsageRow[] = []\n for (const entry of reverse.docs) {\n const months = await readUserAiUsageMonths(firestore, entry.id, uid)\n for (const month of months) {\n rows.push({\n orgId: entry.id,\n orgName: typeof entry.get('orgName') === 'string' ? String(entry.get('orgName')) : null,\n slug: typeof entry.get('slug') === 'string' ? String(entry.get('slug')) : null,\n month: month.month,\n credits: month.credits,\n requests: month.requests,\n refusals: month.refusals,\n })\n }\n }\n // Newest month first, dearest workspace first within it.\n rows.sort(\n (a, b) => b.month.localeCompare(a.month) || b.credits - a.credits,\n )\n return Response.json(\n { uid, rows, truncated: reverse.size >= MAX_ORGS },\n { status: 200 },\n )\n } catch (error) {\n // An unverifiable credential is a 401, not a fault of ours\n // (AGL-1993). Null for anything else, so a real failure keeps its 500.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error('[ai/admin/user]', error)\n return Response.json({ error: 'AI usage lookup failed' }, { status: 500 })\n }\n}\n\nexport { handler as GET }\n"],"names":["pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","readUserAiUsageMonths","invalidIdTokenResponse","MAX_ORGS","handler","request","headers","query","method","rawHeaders","Response","json","error","status","authorization","idToken","startsWith","slice","length","undefined","uid","String","trim","decoded","app","auth","verifyIdToken","email_verified","firestore","reverse","collection","doc","limit","get","rows","entry","docs","months","id","month","push","orgId","orgName","slug","credits","requests","refusals","sort","a","b","localeCompare","truncated","size","unauthenticated","console","GET"],"mappings":"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"}
@@ -13,7 +13,7 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { MARKETPLACE_COMPONENT_ID_ALLOWLIST, sanitizeMarketplaceDefinition } from "@aglyn/aglyn/app-utils/node-definition-sanitizer";
16
+ */ import { PORTABLE_COMPONENT_ID_ALLOWLIST, sanitizePortableDefinition } from "@aglyn/aglyn/app-utils/node-definition-sanitizer";
17
17
  import { AI_PALETTE } from "../runtime/ai-palette.generated.js";
18
18
  import { AI_ACCEPTABLE_USE_BLOCK } from "../runtime/ai-runtime.js";
19
19
  /** The mode prompts. Static text: no byte of any workspace is in one. */ const MODE_PROMPT = {
@@ -57,7 +57,7 @@ const strictObject = (properties)=>({
57
57
  componentId: {
58
58
  type: 'string',
59
59
  enum: [
60
- ...MARKETPLACE_COMPONENT_ID_ALLOWLIST
60
+ ...PORTABLE_COMPONENT_ID_ALLOWLIST
61
61
  ]
62
62
  },
63
63
  parentId: STRING,
@@ -189,7 +189,7 @@ const isRecord = (value)=>typeof value === 'object' && value !== null && !Array.
189
189
  status: 'unreadable',
190
190
  error: 'AI returned invalid JSON'
191
191
  };
192
- const sanitized = sanitizeMarketplaceDefinition(definition);
192
+ const sanitized = sanitizePortableDefinition(definition);
193
193
  // `=== false`, not `!`: see `AssistSectionRead`.
194
194
  if (sanitized.ok === false) return {
195
195
  status: 'rejected',
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-assist-prompts.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 {\n MARKETPLACE_COMPONENT_ID_ALLOWLIST,\n sanitizeMarketplaceDefinition,\n} from '@aglyn/aglyn/app-utils/node-definition-sanitizer'\nimport { AI_PALETTE } from '../runtime/ai-palette.generated'\nimport { AI_ACCEPTABLE_USE_BLOCK, type AiSystemBlock, type AiTool } from '../runtime/ai-runtime'\nimport type { AiResult } from '../providers/contract'\n\n/**\n * The copy assistant's prompts and its section tool (AGL-2937), by their own\n * module so a spec can read them without a Firestore, an ID token or a\n * provider — the handler beside them needs all three.\n *\n * ── Why the section mode answers through a tool ───────────────────────────\n *\n * It asked for \"ONLY a JSON object, no prose or code fences\" and then parsed\n * whatever came back, stripping a fence if it found one. When that parse\n * failed the handler answered 502 and the tokens were already spent: the\n * whole request, prompt and answer, bought nothing, and the member's next\n * move is to press the button again and spend it twice. A strict tool is the\n * cheaper shape for exactly that reason — the answer arrives as validated\n * arguments rather than as text that has to be guessed at — and it is what\n * AGL-2937 asks of every door.\n *\n * The parse stays as a fallback. A provider whose adapter has no tool\n * support, or a model that answers in prose anyway, still gets read rather\n * than refused: the tool is how the answer is asked for, not the only way it\n * can arrive.\n *\n * ── Why the flat node list ────────────────────────────────────────────────\n *\n * A strict schema forbids additional properties, so a map keyed by node id\n * cannot be described in one: every key would have to be a declared\n * property. The tool therefore takes a LIST of nodes, each naming its own id\n * and its children by id, and `readAssistSection` rebuilds the map the\n * besigner consumer already reads. The same reason `assistEditTool` takes\n * name/value pairs rather than a props object.\n *\n * ── Caching ───────────────────────────────────────────────────────────────\n *\n * Each mode's static text carries a breakpoint. None of the three is long\n * enough for its model to cache today — `runtime/ai-prompt-cache.spec.ts`\n * measures all three against their minimums and records the verdict — so the\n * marker is a statement about the block rather than a saving. It costs\n * nothing, it is correct if a mode is ever served by a model with a lower\n * minimum, and the ledger is what says whether it pays.\n */\n\nexport type AiAssistMode = 'element' | 'blog' | 'section'\n\n/** The mode prompts. Static text: no byte of any workspace is in one. */\nconst MODE_PROMPT: Readonly<Record<AiAssistMode, string>> = {\n element:\n 'You write website copy inside a site builder. Reply with ONLY the final text for the element — no quotes, preamble, or markdown. Keep roughly the same length and role as the current text unless the instruction says otherwise.',\n blog:\n 'You write blog posts inside a site builder. Reply with ONLY the post body in markdown-lite: **bold**, *italic*, ## headings, - lists, [links](https://url). No front matter, title line, or preamble — the title renders separately.',\n section:\n 'You design one website section inside a site builder. Answer by calling ' +\n `${'submit_section'} exactly once, with a flat list of nodes: each node ` +\n 'names its own id, the component it is, its parent (empty for the root) ' +\n 'and its children by id, and fills props as name/value pairs. Useful ' +\n 'props — muiStack: direction (row|column), spacing, justifyContent, ' +\n 'alignItems; muiTypography: children, variant (h1..h6, body1, ' +\n 'subtitle1); muiButton: children, variant (contained|outlined), color; ' +\n 'image: src, alt; muiContainer: maxWidth (sm|md|lg). Keep it small: 4-12 ' +\n 'nodes, one root container. Leave image src empty. Write real copy, not ' +\n 'lorem ipsum.',\n}\n\n/**\n * One mode's system blocks: the acceptable-use rules ahead of the mode's own\n * prompt (AGL-2925), as one cached block with no per-org byte in it.\n */\nexport function assistModeSystemBlocks(mode: AiAssistMode): AiSystemBlock[] {\n return [{ text: `${AI_ACCEPTABLE_USE_BLOCK}\\n\\n${MODE_PROMPT[mode]}`, cacheBreakpoint: true }]\n}\n\nexport const ASSIST_SECTION_TOOL_NAME = 'submit_section'\n\nconst STRING = { type: 'string' } as const\n\nconst strictObject = (properties: Record<string, unknown>) => ({\n type: 'object',\n properties,\n required: Object.keys(properties),\n additionalProperties: false,\n})\n\n/**\n * The strict tool a generated section arrives through. `componentId` is an\n * enum of the marketplace allowlist — the same list the sanitizer holds the\n * answer to afterward — so an unpublishable component is refused by the\n * schema before it costs a re-ask.\n */\nexport function assistSectionTool(): AiTool {\n const pair = strictObject({ name: STRING, value: STRING })\n const node = strictObject({\n id: STRING,\n componentId: { type: 'string', enum: [...MARKETPLACE_COMPONENT_ID_ALLOWLIST] },\n parentId: STRING,\n children: { type: 'array', items: STRING },\n props: { type: 'array', items: pair },\n })\n return {\n name: ASSIST_SECTION_TOOL_NAME,\n description:\n 'Submit the section you designed, as a flat list of nodes. The first ' +\n 'node with an empty parentId is the root. The member reviews the ' +\n 'section and places it, or discards it.',\n inputSchema: {\n type: 'object',\n properties: { nodes: { type: 'array', items: node } },\n required: ['nodes'],\n additionalProperties: false,\n },\n strict: true,\n }\n}\n\n/** The sanitized section, in the shape the besigner consumer already reads. */\nexport interface AssistSection {\n rootId: string\n nodes: Record<string, unknown>\n}\n\ninterface RawNode {\n id: string\n componentId: string\n parentId: string\n children: string[]\n props: Array<{ name: string; value: string }>\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value)\n\n/**\n * A pair's value in the type the component's own schema declares for that\n * prop, since a strict tool can only carry strings. `children` stays text\n * whatever a schema says: a number in it is copy a visitor reads, not a\n * count.\n */\nfunction propValue(componentId: string, name: string, value: string): unknown {\n if (name === 'children') return value\n const declared = AI_PALETTE[componentId]?.propsSchema?.properties?.[name] as\n | { type?: string }\n | undefined\n const type = declared?.type\n if (type === 'boolean') return value === 'true'\n if (type === 'number' || type === 'integer') {\n const parsed = Number(value)\n return Number.isFinite(parsed) ? parsed : value\n }\n if (type) return value\n // No schema for this prop: a bare number is read as one, so `spacing: \"2\"`\n // does not reach the canvas as text. The palette's own typing wins above,\n // which is why muiStack's string-typed `spacing` stays a string.\n const parsed = Number(value)\n return value.trim() !== '' && Number.isFinite(parsed) ? parsed : value\n}\n\n/** The flat node list as the stored map: `{ $id, componentId, parentId, props, nodes }`. */\nfunction definitionFromNodes(nodes: readonly RawNode[]): {\n rootId: string\n nodes: Record<string, unknown>\n} {\n const ids = new Set(nodes.map((node) => node.id))\n const root = nodes.find((node) => !node.parentId || !ids.has(node.parentId))\n const map: Record<string, unknown> = {}\n for (const node of nodes) {\n const props: Record<string, unknown> = {}\n for (const { name, value } of node.props) {\n if (name) props[name] = propValue(node.componentId, name, value)\n }\n map[node.id] = {\n $id: node.id,\n componentId: node.componentId,\n parentId: node === root ? null : node.parentId,\n props,\n nodes: node.children.filter((child) => ids.has(child)),\n }\n }\n return { rootId: root?.id ?? '', nodes: map }\n}\n\n/** A tool call's arguments as nodes, or `null` when the shape is not one. */\nfunction nodesFromToolUse(input: Record<string, unknown>): RawNode[] | null {\n const raw = input['nodes']\n if (!Array.isArray(raw) || !raw.length) return null\n const nodes: RawNode[] = []\n for (const entry of raw) {\n if (!isRecord(entry)) return null\n const id = String(entry['id'] ?? '')\n const componentId = String(entry['componentId'] ?? '')\n if (!id || !componentId) return null\n const props = Array.isArray(entry['props']) ? entry['props'] : []\n nodes.push({\n id,\n componentId,\n parentId: String(entry['parentId'] ?? ''),\n children: (Array.isArray(entry['children']) ? entry['children'] : []).map(String),\n props: props.filter(isRecord).map((pair) => ({\n name: String(pair['name'] ?? ''),\n value: String(pair['value'] ?? ''),\n })),\n })\n }\n return nodes\n}\n\n/** The old shape: a JSON object of `{ rootId, nodes }`, possibly fenced. */\nfunction definitionFromText(text: string): { rootId: string; nodes: Record<string, unknown> } | null {\n const raw = text\n .trim()\n .replace(/^```(?:json)?\\s*/i, '')\n .replace(/\\s*```$/, '')\n let parsed: unknown\n try {\n parsed = JSON.parse(raw)\n } catch {\n return null\n }\n if (!isRecord(parsed)) return null\n return {\n rootId: String(parsed['rootId'] ?? ''),\n nodes: isRecord(parsed['nodes']) ? parsed['nodes'] : {},\n }\n}\n\n/**\n * What reading a section answer came to. Discriminated by a string rather\n * than a boolean, like every other result in the plugin: this tree compiles\n * without `strictNullChecks`, where a `true`/`false` discriminant does not\n * narrow.\n */\nexport type AssistSectionRead =\n | { status: 'ok'; section: AssistSection }\n /** Neither a tool call nor JSON: nothing could be read out of the answer. */\n | { status: 'unreadable'; error: string }\n /** Read, and refused by the sanitizer every marketplace install passes. */\n | { status: 'rejected'; error: string }\n\n/**\n * Read a section answer: the tool call first, the text parse behind it, then\n * the marketplace sanitizer every generated subtree has always passed before\n * it reaches a canvas.\n */\nexport function readAssistSection(result: AiResult): AssistSectionRead {\n const call =\n result.kind === 'completion'\n ? result.toolUse.find((use) => use.name === ASSIST_SECTION_TOOL_NAME)\n : undefined\n const nodes = call ? nodesFromToolUse(call.input) : null\n const definition = nodes\n ? definitionFromNodes(nodes)\n : definitionFromText(result.kind === 'completion' ? result.text : '')\n if (!definition) return { status: 'unreadable', error: 'AI returned invalid JSON' }\n const sanitized = sanitizeMarketplaceDefinition(definition)\n // `=== false`, not `!`: see `AssistSectionRead`.\n if (sanitized.ok === false) return { status: 'rejected', error: sanitized.error }\n return { status: 'ok', section: { rootId: sanitized.rootId, nodes: sanitized.nodes } }\n}\n"],"names":["MARKETPLACE_COMPONENT_ID_ALLOWLIST","sanitizeMarketplaceDefinition","AI_PALETTE","AI_ACCEPTABLE_USE_BLOCK","MODE_PROMPT","element","blog","section","assistModeSystemBlocks","mode","text","cacheBreakpoint","ASSIST_SECTION_TOOL_NAME","STRING","type","strictObject","properties","required","Object","keys","additionalProperties","assistSectionTool","pair","name","value","node","id","componentId","enum","parentId","children","items","props","description","inputSchema","nodes","strict","isRecord","Array","isArray","propValue","declared","propsSchema","parsed","Number","isFinite","trim","definitionFromNodes","ids","Set","map","root","find","has","$id","filter","child","rootId","nodesFromToolUse","input","raw","length","entry","String","push","definitionFromText","replace","JSON","parse","readAssistSection","result","call","kind","toolUse","use","undefined","definition","status","error","sanitized","ok"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-assist-prompts.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 {\n PORTABLE_COMPONENT_ID_ALLOWLIST,\n sanitizePortableDefinition,\n} from '@aglyn/aglyn/app-utils/node-definition-sanitizer'\nimport { AI_PALETTE } from '../runtime/ai-palette.generated'\nimport { AI_ACCEPTABLE_USE_BLOCK, type AiSystemBlock, type AiTool } from '../runtime/ai-runtime'\nimport type { AiResult } from '../providers/contract'\n\n/**\n * The copy assistant's prompts and its section tool (AGL-2937), by their own\n * module so a spec can read them without a Firestore, an ID token or a\n * provider — the handler beside them needs all three.\n *\n * ── Why the section mode answers through a tool ───────────────────────────\n *\n * It asked for \"ONLY a JSON object, no prose or code fences\" and then parsed\n * whatever came back, stripping a fence if it found one. When that parse\n * failed the handler answered 502 and the tokens were already spent: the\n * whole request, prompt and answer, bought nothing, and the member's next\n * move is to press the button again and spend it twice. A strict tool is the\n * cheaper shape for exactly that reason — the answer arrives as validated\n * arguments rather than as text that has to be guessed at — and it is what\n * AGL-2937 asks of every door.\n *\n * The parse stays as a fallback. A provider whose adapter has no tool\n * support, or a model that answers in prose anyway, still gets read rather\n * than refused: the tool is how the answer is asked for, not the only way it\n * can arrive.\n *\n * ── Why the flat node list ────────────────────────────────────────────────\n *\n * A strict schema forbids additional properties, so a map keyed by node id\n * cannot be described in one: every key would have to be a declared\n * property. The tool therefore takes a LIST of nodes, each naming its own id\n * and its children by id, and `readAssistSection` rebuilds the map the\n * besigner consumer already reads. The same reason `assistEditTool` takes\n * name/value pairs rather than a props object.\n *\n * ── Caching ───────────────────────────────────────────────────────────────\n *\n * Each mode's static text carries a breakpoint. None of the three is long\n * enough for its model to cache today — `runtime/ai-prompt-cache.spec.ts`\n * measures all three against their minimums and records the verdict — so the\n * marker is a statement about the block rather than a saving. It costs\n * nothing, it is correct if a mode is ever served by a model with a lower\n * minimum, and the ledger is what says whether it pays.\n */\n\nexport type AiAssistMode = 'element' | 'blog' | 'section'\n\n/** The mode prompts. Static text: no byte of any workspace is in one. */\nconst MODE_PROMPT: Readonly<Record<AiAssistMode, string>> = {\n element:\n 'You write website copy inside a site builder. Reply with ONLY the final text for the element — no quotes, preamble, or markdown. Keep roughly the same length and role as the current text unless the instruction says otherwise.',\n blog:\n 'You write blog posts inside a site builder. Reply with ONLY the post body in markdown-lite: **bold**, *italic*, ## headings, - lists, [links](https://url). No front matter, title line, or preamble — the title renders separately.',\n section:\n 'You design one website section inside a site builder. Answer by calling ' +\n `${'submit_section'} exactly once, with a flat list of nodes: each node ` +\n 'names its own id, the component it is, its parent (empty for the root) ' +\n 'and its children by id, and fills props as name/value pairs. Useful ' +\n 'props — muiStack: direction (row|column), spacing, justifyContent, ' +\n 'alignItems; muiTypography: children, variant (h1..h6, body1, ' +\n 'subtitle1); muiButton: children, variant (contained|outlined), color; ' +\n 'image: src, alt; muiContainer: maxWidth (sm|md|lg). Keep it small: 4-12 ' +\n 'nodes, one root container. Leave image src empty. Write real copy, not ' +\n 'lorem ipsum.',\n}\n\n/**\n * One mode's system blocks: the acceptable-use rules ahead of the mode's own\n * prompt (AGL-2925), as one cached block with no per-org byte in it.\n */\nexport function assistModeSystemBlocks(mode: AiAssistMode): AiSystemBlock[] {\n return [{ text: `${AI_ACCEPTABLE_USE_BLOCK}\\n\\n${MODE_PROMPT[mode]}`, cacheBreakpoint: true }]\n}\n\nexport const ASSIST_SECTION_TOOL_NAME = 'submit_section'\n\nconst STRING = { type: 'string' } as const\n\nconst strictObject = (properties: Record<string, unknown>) => ({\n type: 'object',\n properties,\n required: Object.keys(properties),\n additionalProperties: false,\n})\n\n/**\n * The strict tool a generated section arrives through. `componentId` is an\n * enum of the marketplace allowlist — the same list the sanitizer holds the\n * answer to afterward — so an unpublishable component is refused by the\n * schema before it costs a re-ask.\n */\nexport function assistSectionTool(): AiTool {\n const pair = strictObject({ name: STRING, value: STRING })\n const node = strictObject({\n id: STRING,\n componentId: { type: 'string', enum: [...PORTABLE_COMPONENT_ID_ALLOWLIST] },\n parentId: STRING,\n children: { type: 'array', items: STRING },\n props: { type: 'array', items: pair },\n })\n return {\n name: ASSIST_SECTION_TOOL_NAME,\n description:\n 'Submit the section you designed, as a flat list of nodes. The first ' +\n 'node with an empty parentId is the root. The member reviews the ' +\n 'section and places it, or discards it.',\n inputSchema: {\n type: 'object',\n properties: { nodes: { type: 'array', items: node } },\n required: ['nodes'],\n additionalProperties: false,\n },\n strict: true,\n }\n}\n\n/** The sanitized section, in the shape the besigner consumer already reads. */\nexport interface AssistSection {\n rootId: string\n nodes: Record<string, unknown>\n}\n\ninterface RawNode {\n id: string\n componentId: string\n parentId: string\n children: string[]\n props: Array<{ name: string; value: string }>\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value)\n\n/**\n * A pair's value in the type the component's own schema declares for that\n * prop, since a strict tool can only carry strings. `children` stays text\n * whatever a schema says: a number in it is copy a visitor reads, not a\n * count.\n */\nfunction propValue(componentId: string, name: string, value: string): unknown {\n if (name === 'children') return value\n const declared = AI_PALETTE[componentId]?.propsSchema?.properties?.[name] as\n | { type?: string }\n | undefined\n const type = declared?.type\n if (type === 'boolean') return value === 'true'\n if (type === 'number' || type === 'integer') {\n const parsed = Number(value)\n return Number.isFinite(parsed) ? parsed : value\n }\n if (type) return value\n // No schema for this prop: a bare number is read as one, so `spacing: \"2\"`\n // does not reach the canvas as text. The palette's own typing wins above,\n // which is why muiStack's string-typed `spacing` stays a string.\n const parsed = Number(value)\n return value.trim() !== '' && Number.isFinite(parsed) ? parsed : value\n}\n\n/** The flat node list as the stored map: `{ $id, componentId, parentId, props, nodes }`. */\nfunction definitionFromNodes(nodes: readonly RawNode[]): {\n rootId: string\n nodes: Record<string, unknown>\n} {\n const ids = new Set(nodes.map((node) => node.id))\n const root = nodes.find((node) => !node.parentId || !ids.has(node.parentId))\n const map: Record<string, unknown> = {}\n for (const node of nodes) {\n const props: Record<string, unknown> = {}\n for (const { name, value } of node.props) {\n if (name) props[name] = propValue(node.componentId, name, value)\n }\n map[node.id] = {\n $id: node.id,\n componentId: node.componentId,\n parentId: node === root ? null : node.parentId,\n props,\n nodes: node.children.filter((child) => ids.has(child)),\n }\n }\n return { rootId: root?.id ?? '', nodes: map }\n}\n\n/** A tool call's arguments as nodes, or `null` when the shape is not one. */\nfunction nodesFromToolUse(input: Record<string, unknown>): RawNode[] | null {\n const raw = input['nodes']\n if (!Array.isArray(raw) || !raw.length) return null\n const nodes: RawNode[] = []\n for (const entry of raw) {\n if (!isRecord(entry)) return null\n const id = String(entry['id'] ?? '')\n const componentId = String(entry['componentId'] ?? '')\n if (!id || !componentId) return null\n const props = Array.isArray(entry['props']) ? entry['props'] : []\n nodes.push({\n id,\n componentId,\n parentId: String(entry['parentId'] ?? ''),\n children: (Array.isArray(entry['children']) ? entry['children'] : []).map(String),\n props: props.filter(isRecord).map((pair) => ({\n name: String(pair['name'] ?? ''),\n value: String(pair['value'] ?? ''),\n })),\n })\n }\n return nodes\n}\n\n/** The old shape: a JSON object of `{ rootId, nodes }`, possibly fenced. */\nfunction definitionFromText(text: string): { rootId: string; nodes: Record<string, unknown> } | null {\n const raw = text\n .trim()\n .replace(/^```(?:json)?\\s*/i, '')\n .replace(/\\s*```$/, '')\n let parsed: unknown\n try {\n parsed = JSON.parse(raw)\n } catch {\n return null\n }\n if (!isRecord(parsed)) return null\n return {\n rootId: String(parsed['rootId'] ?? ''),\n nodes: isRecord(parsed['nodes']) ? parsed['nodes'] : {},\n }\n}\n\n/**\n * What reading a section answer came to. Discriminated by a string rather\n * than a boolean, like every other result in the plugin: this tree compiles\n * without `strictNullChecks`, where a `true`/`false` discriminant does not\n * narrow.\n */\nexport type AssistSectionRead =\n | { status: 'ok'; section: AssistSection }\n /** Neither a tool call nor JSON: nothing could be read out of the answer. */\n | { status: 'unreadable'; error: string }\n /** Read, and refused by the sanitizer every marketplace install passes. */\n | { status: 'rejected'; error: string }\n\n/**\n * Read a section answer: the tool call first, the text parse behind it, then\n * the marketplace sanitizer every generated subtree has always passed before\n * it reaches a canvas.\n */\nexport function readAssistSection(result: AiResult): AssistSectionRead {\n const call =\n result.kind === 'completion'\n ? result.toolUse.find((use) => use.name === ASSIST_SECTION_TOOL_NAME)\n : undefined\n const nodes = call ? nodesFromToolUse(call.input) : null\n const definition = nodes\n ? definitionFromNodes(nodes)\n : definitionFromText(result.kind === 'completion' ? result.text : '')\n if (!definition) return { status: 'unreadable', error: 'AI returned invalid JSON' }\n const sanitized = sanitizePortableDefinition(definition)\n // `=== false`, not `!`: see `AssistSectionRead`.\n if (sanitized.ok === false) return { status: 'rejected', error: sanitized.error }\n return { status: 'ok', section: { rootId: sanitized.rootId, nodes: sanitized.nodes } }\n}\n"],"names":["PORTABLE_COMPONENT_ID_ALLOWLIST","sanitizePortableDefinition","AI_PALETTE","AI_ACCEPTABLE_USE_BLOCK","MODE_PROMPT","element","blog","section","assistModeSystemBlocks","mode","text","cacheBreakpoint","ASSIST_SECTION_TOOL_NAME","STRING","type","strictObject","properties","required","Object","keys","additionalProperties","assistSectionTool","pair","name","value","node","id","componentId","enum","parentId","children","items","props","description","inputSchema","nodes","strict","isRecord","Array","isArray","propValue","declared","propsSchema","parsed","Number","isFinite","trim","definitionFromNodes","ids","Set","map","root","find","has","$id","filter","child","rootId","nodesFromToolUse","input","raw","length","entry","String","push","definitionFromText","replace","JSON","parse","readAssistSection","result","call","kind","toolUse","use","undefined","definition","status","error","sanitized","ok"],"mappings":"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"}
@@ -19,6 +19,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
19
19
  // and the jobs it creates are held by the same pause on the beat.
20
20
  import { writeCronBeat } from "@aglyn/aglyn/app-utils/health-report";
21
21
  import { rateLimitedRetryAtMs, sendEmail } from "@aglyn/shared-util-email";
22
+ import { withoutMailWithheldAccounts } from "@aglyn/tenant-data-admin/server/account-mail";
22
23
  import { filterSuppressedEmails } from "@aglyn/tenant-data-admin/server/email-suppression";
23
24
  import { meterPlatformEmail } from "@aglyn/tenant-data-admin/server/email-metering";
24
25
  import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
@@ -95,7 +96,13 @@ import { runAiInsightDigestSweep } from "../insights/ai-insight-digest.js";
95
96
  firestore,
96
97
  now: new Date(),
97
98
  flagValue: flags['release_ai_generative'],
98
- listMembers: listOrgMembers,
99
+ // The digest's audience: a locked or disabled account is not told
100
+ // (AGL-3418).
101
+ listMembers: async (orgId)=>{
102
+ const members = await listOrgMembers(orgId);
103
+ const reachable = new Set(await withoutMailWithheldAccounts(members.map((member)=>member.$id)));
104
+ return members.filter((member)=>reachable.has(member.$id));
105
+ },
99
106
  mayGenerate: (orgId, hostId, member)=>memberHasPermissionOnHost(orgId, hostId, member, 'ai.generate'),
100
107
  paused: async (orgId)=>Boolean(await featureLockdownRefusal({
101
108
  feature: 'ai-generate',
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-insight-digest-route.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// lockdown-423: exempt — server-internal cron (x-cron-secret) with no user\n// caller; it asks each workspace's ai-generate pause before it creates a job,\n// and the jobs it creates are held by the same pause on the beat.\n\nimport { writeCronBeat } from '@aglyn/aglyn/app-utils/health-report'\nimport { rateLimitedRetryAtMs, sendEmail } from '@aglyn/shared-util-email'\nimport { filterSuppressedEmails } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport { meterPlatformEmail } from '@aglyn/tenant-data-admin/server/email-metering'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { featureLockdownRefusal } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { notifyUsers } from '@aglyn/tenant-data-admin/server/notifications'\nimport {\n renderSystemEmailContent,\n systemEmailBrand,\n} from '@aglyn/tenant-data-admin/server/render-system-email'\nimport { listOrgMembers, memberHasPermissionOnHost } from '@aglyn/tenant-data-admin/server/organizations'\nimport { getServerReleaseFlagValues } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { safeEqual } from '@aglyn/tenant-data-admin/server/safe-equal'\nimport {\n AI_INSIGHTS_DIGEST_CRON_ID,\n runAiInsightDigestSweep,\n type AiInsightDigestDeps,\n} from '../insights/ai-insight-digest'\n\n/**\n * The weekly insights' scheduled route (AGL-2915):\n * `POST /api/admin/ai-insights-digest`.\n *\n * Cloud Scheduler calls it at 06:00 and 14:00 UTC through\n * `consoleAiInsightsDigest` in `cloud/functions`, on the console's\n * `CRON_SECRET`: 501 while the secret is unset, 401 without it. Monday's\n * first run makes the week's digests, which the AI jobs beat then writes, and\n * every run after it delivers what is written — see `ai-insight-digest.ts`.\n * `/api/admin/` is the path because the console's edge lets the cron secret's\n * header through there. It stamps `ai-insights-digest` for\n * `/api/health/crons` on every authorized call.\n */\nexport async function POST(request: Request): Promise<Response> {\n if (request.method !== 'POST') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const secret = process.env.CRON_SECRET\n if (!secret) {\n return Response.json({ error: 'The weekly insights are not configured (CRON_SECRET).' }, { status: 501 })\n }\n if (\n !safeEqual(request.headers.get('x-cron-secret'), secret) &&\n !safeEqual(request.headers.get('authorization'), `Bearer ${secret}`)\n ) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n const firestore = firebaseAdmin.app().firestore()\n // The inventory row's id, written out: `cron-beat-wiring.spec.ts` finds a\n // job's stamp by the quoted id beside the call.\n await writeCronBeat(firestore, 'ai-insights-digest' satisfies typeof AI_INSIGHTS_DIGEST_CRON_ID)\n\n let cursor: string | null = null\n try {\n const body = (await request.json()) as { cursor?: unknown } | null\n const raw = body?.cursor\n cursor = typeof raw === 'string' && raw && !raw.includes('/') ? raw : null\n } catch {\n // A bodyless POST is the first call of a run.\n }\n\n try {\n // The platform switch holds the whole run, as it holds the jobs beat.\n if (await featureLockdownRefusal({ feature: 'ai-generate' })) {\n return Response.json({ held: true, done: true, nextCursor: null }, { status: 200 })\n }\n const flags = await getServerReleaseFlagValues()\n const deps: AiInsightDigestDeps = {\n firestore,\n now: new Date(),\n flagValue: flags['release_ai_generative'],\n listMembers: listOrgMembers,\n mayGenerate: (orgId, hostId, member) => memberHasPermissionOnHost(orgId, hostId, member, 'ai.generate'),\n paused: async (orgId) => Boolean(await featureLockdownRefusal({ feature: 'ai-generate', orgId })),\n notify: (uid, payload) => notifyUsers([uid], { type: 'content.insightsDigest', ...payload }),\n send: async (email) => {\n const recipients = await filterSuppressedEmails([email.to], firestore)\n if (!recipients.length) return { sent: false, rateLimited: false }\n // The `ai-insights-digest` system email (AGL-3367), in the org's brand.\n const content = await renderSystemEmailContent(\n 'ai-insights-digest',\n { 'site.name': email.siteName, 'digest.body': email.text },\n systemEmailBrand(email.org),\n { subject: email.subject, text: email.text },\n )\n const result = await sendEmail({\n to: recipients,\n ...content,\n fromName: email.fromName,\n context: 'ai-insights-digest',\n priority: 'bulk',\n })\n if (result.sent) {\n await meterPlatformEmail().catch(() => undefined)\n return { sent: true, rateLimited: false }\n }\n return { sent: false, rateLimited: rateLimitedRetryAtMs(result) !== null }\n },\n // An email link must be absolute; with no console URL configured the\n // digest says where to look without one.\n consoleOrigin: (process.env.NEXT_PUBLIC_CONSOLE_URL ?? '').trim().replace(/\\/+$/, ''),\n }\n const report = await runAiInsightDigestSweep(deps, { cursor })\n return Response.json(report, { status: 200 })\n } catch (error) {\n console.error('ai insights digest run failed', error)\n return Response.json({ error: 'The weekly insights run failed' }, { status: 500 })\n }\n}\n"],"names":["writeCronBeat","rateLimitedRetryAtMs","sendEmail","filterSuppressedEmails","meterPlatformEmail","firebaseAdmin","featureLockdownRefusal","notifyUsers","renderSystemEmailContent","systemEmailBrand","listOrgMembers","memberHasPermissionOnHost","getServerReleaseFlagValues","safeEqual","runAiInsightDigestSweep","POST","request","method","Response","json","error","status","secret","process","env","CRON_SECRET","headers","get","firestore","app","cursor","body","raw","includes","feature","held","done","nextCursor","flags","deps","now","Date","flagValue","listMembers","mayGenerate","orgId","hostId","member","paused","Boolean","notify","uid","payload","type","send","email","recipients","to","length","sent","rateLimited","content","siteName","text","org","subject","result","fromName","context","priority","catch","undefined","consoleOrigin","NEXT_PUBLIC_CONSOLE_URL","trim","replace","report","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-insight-digest-route.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// lockdown-423: exempt — server-internal cron (x-cron-secret) with no user\n// caller; it asks each workspace's ai-generate pause before it creates a job,\n// and the jobs it creates are held by the same pause on the beat.\n\nimport { writeCronBeat } from '@aglyn/aglyn/app-utils/health-report'\nimport { rateLimitedRetryAtMs, sendEmail } from '@aglyn/shared-util-email'\nimport { withoutMailWithheldAccounts } from '@aglyn/tenant-data-admin/server/account-mail'\nimport { filterSuppressedEmails } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport { meterPlatformEmail } from '@aglyn/tenant-data-admin/server/email-metering'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { featureLockdownRefusal } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { notifyUsers } from '@aglyn/tenant-data-admin/server/notifications'\nimport {\n renderSystemEmailContent,\n systemEmailBrand,\n} from '@aglyn/tenant-data-admin/server/render-system-email'\nimport { listOrgMembers, memberHasPermissionOnHost } from '@aglyn/tenant-data-admin/server/organizations'\nimport { getServerReleaseFlagValues } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { safeEqual } from '@aglyn/tenant-data-admin/server/safe-equal'\nimport {\n AI_INSIGHTS_DIGEST_CRON_ID,\n runAiInsightDigestSweep,\n type AiInsightDigestDeps,\n} from '../insights/ai-insight-digest'\n\n/**\n * The weekly insights' scheduled route (AGL-2915):\n * `POST /api/admin/ai-insights-digest`.\n *\n * Cloud Scheduler calls it at 06:00 and 14:00 UTC through\n * `consoleAiInsightsDigest` in `cloud/functions`, on the console's\n * `CRON_SECRET`: 501 while the secret is unset, 401 without it. Monday's\n * first run makes the week's digests, which the AI jobs beat then writes, and\n * every run after it delivers what is written — see `ai-insight-digest.ts`.\n * `/api/admin/` is the path because the console's edge lets the cron secret's\n * header through there. It stamps `ai-insights-digest` for\n * `/api/health/crons` on every authorized call.\n */\nexport async function POST(request: Request): Promise<Response> {\n if (request.method !== 'POST') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const secret = process.env.CRON_SECRET\n if (!secret) {\n return Response.json({ error: 'The weekly insights are not configured (CRON_SECRET).' }, { status: 501 })\n }\n if (\n !safeEqual(request.headers.get('x-cron-secret'), secret) &&\n !safeEqual(request.headers.get('authorization'), `Bearer ${secret}`)\n ) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n const firestore = firebaseAdmin.app().firestore()\n // The inventory row's id, written out: `cron-beat-wiring.spec.ts` finds a\n // job's stamp by the quoted id beside the call.\n await writeCronBeat(firestore, 'ai-insights-digest' satisfies typeof AI_INSIGHTS_DIGEST_CRON_ID)\n\n let cursor: string | null = null\n try {\n const body = (await request.json()) as { cursor?: unknown } | null\n const raw = body?.cursor\n cursor = typeof raw === 'string' && raw && !raw.includes('/') ? raw : null\n } catch {\n // A bodyless POST is the first call of a run.\n }\n\n try {\n // The platform switch holds the whole run, as it holds the jobs beat.\n if (await featureLockdownRefusal({ feature: 'ai-generate' })) {\n return Response.json({ held: true, done: true, nextCursor: null }, { status: 200 })\n }\n const flags = await getServerReleaseFlagValues()\n const deps: AiInsightDigestDeps = {\n firestore,\n now: new Date(),\n flagValue: flags['release_ai_generative'],\n // The digest's audience: a locked or disabled account is not told\n // (AGL-3418).\n listMembers: async (orgId) => {\n const members = await listOrgMembers(orgId)\n const reachable = new Set(\n await withoutMailWithheldAccounts(members.map((member) => member.$id)),\n )\n return members.filter((member) => reachable.has(member.$id))\n },\n mayGenerate: (orgId, hostId, member) => memberHasPermissionOnHost(orgId, hostId, member, 'ai.generate'),\n paused: async (orgId) => Boolean(await featureLockdownRefusal({ feature: 'ai-generate', orgId })),\n notify: (uid, payload) => notifyUsers([uid], { type: 'content.insightsDigest', ...payload }),\n send: async (email) => {\n const recipients = await filterSuppressedEmails([email.to], firestore)\n if (!recipients.length) return { sent: false, rateLimited: false }\n // The `ai-insights-digest` system email (AGL-3367), in the org's brand.\n const content = await renderSystemEmailContent(\n 'ai-insights-digest',\n { 'site.name': email.siteName, 'digest.body': email.text },\n systemEmailBrand(email.org),\n { subject: email.subject, text: email.text },\n )\n const result = await sendEmail({\n to: recipients,\n ...content,\n fromName: email.fromName,\n context: 'ai-insights-digest',\n priority: 'bulk',\n })\n if (result.sent) {\n await meterPlatformEmail().catch(() => undefined)\n return { sent: true, rateLimited: false }\n }\n return { sent: false, rateLimited: rateLimitedRetryAtMs(result) !== null }\n },\n // An email link must be absolute; with no console URL configured the\n // digest says where to look without one.\n consoleOrigin: (process.env.NEXT_PUBLIC_CONSOLE_URL ?? '').trim().replace(/\\/+$/, ''),\n }\n const report = await runAiInsightDigestSweep(deps, { cursor })\n return Response.json(report, { status: 200 })\n } catch (error) {\n console.error('ai insights digest run failed', error)\n return Response.json({ error: 'The weekly insights run failed' }, { status: 500 })\n }\n}\n"],"names":["writeCronBeat","rateLimitedRetryAtMs","sendEmail","withoutMailWithheldAccounts","filterSuppressedEmails","meterPlatformEmail","firebaseAdmin","featureLockdownRefusal","notifyUsers","renderSystemEmailContent","systemEmailBrand","listOrgMembers","memberHasPermissionOnHost","getServerReleaseFlagValues","safeEqual","runAiInsightDigestSweep","POST","request","method","Response","json","error","status","secret","process","env","CRON_SECRET","headers","get","firestore","app","cursor","body","raw","includes","feature","held","done","nextCursor","flags","deps","now","Date","flagValue","listMembers","orgId","members","reachable","Set","map","member","$id","filter","has","mayGenerate","hostId","paused","Boolean","notify","uid","payload","type","send","email","recipients","to","length","sent","rateLimited","content","siteName","text","org","subject","result","fromName","context","priority","catch","undefined","consoleOrigin","NEXT_PUBLIC_CONSOLE_URL","trim","replace","report","console"],"mappings":"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"}