@aglyn/plugins-ai 1.0.0-beta.225 → 1.0.0-beta.227

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 (161) hide show
  1. package/README.md +2 -1
  2. package/package.json +17 -17
  3. package/src/lib/billing/ai-month-meter.js +5 -3
  4. package/src/lib/billing/ai-month-meter.js.map +1 -1
  5. package/src/lib/billing/ai-overage-ledger.js +3 -1
  6. package/src/lib/billing/ai-overage-ledger.js.map +1 -1
  7. package/src/lib/components/ai-brief-dialog.component.d.ts +6 -2
  8. package/src/lib/components/ai-brief-dialog.component.js +35 -17
  9. package/src/lib/components/ai-brief-dialog.component.js.map +1 -1
  10. package/src/lib/components/ai-describe-automation.component.js +17 -2
  11. package/src/lib/components/ai-describe-automation.component.js.map +1 -1
  12. package/src/lib/components/ai-insight-dialog.component.js +24 -2
  13. package/src/lib/components/ai-insight-dialog.component.js.map +1 -1
  14. package/src/lib/components/ai-job-events.d.ts +14 -0
  15. package/src/lib/components/ai-job-events.js +76 -0
  16. package/src/lib/components/ai-job-events.js.map +1 -0
  17. package/src/lib/components/ai-job-follow.component.d.ts +49 -0
  18. package/src/lib/components/ai-job-follow.component.js +210 -0
  19. package/src/lib/components/ai-job-follow.component.js.map +1 -0
  20. package/src/lib/components/ai-job-links.d.ts +42 -0
  21. package/src/lib/components/ai-job-links.js +140 -0
  22. package/src/lib/components/ai-job-links.js.map +1 -0
  23. package/src/lib/components/ai-job-plan.component.d.ts +13 -6
  24. package/src/lib/components/ai-job-plan.component.js +29 -3
  25. package/src/lib/components/ai-job-plan.component.js.map +1 -1
  26. package/src/lib/components/ai-job-requests.d.ts +24 -0
  27. package/src/lib/components/ai-job-requests.js +76 -0
  28. package/src/lib/components/ai-job-requests.js.map +1 -0
  29. package/src/lib/components/ai-jobs-indicator.component.d.ts +25 -0
  30. package/src/lib/components/ai-jobs-indicator.component.js +80 -0
  31. package/src/lib/components/ai-jobs-indicator.component.js.map +1 -0
  32. package/src/lib/components/ai-jobs-store.d.ts +81 -0
  33. package/src/lib/components/ai-jobs-store.js +218 -0
  34. package/src/lib/components/ai-jobs-store.js.map +1 -0
  35. package/src/lib/components/ai-page-brief-dialog.component.d.ts +3 -3
  36. package/src/lib/components/ai-page-brief-dialog.component.js.map +1 -1
  37. package/src/lib/components/ai-site-build-page.component.d.ts +23 -0
  38. package/src/lib/components/ai-site-build-page.component.js +377 -0
  39. package/src/lib/components/ai-site-build-page.component.js.map +1 -0
  40. package/src/lib/components/ai-site-start-card.component.d.ts +1 -1
  41. package/src/lib/components/ai-site-start-card.component.js +522 -130
  42. package/src/lib/components/ai-site-start-card.component.js.map +1 -1
  43. package/src/lib/components/ai-site-starter-fallback.component.d.ts +24 -0
  44. package/src/lib/components/ai-site-starter-fallback.component.js +112 -0
  45. package/src/lib/components/ai-site-starter-fallback.component.js.map +1 -0
  46. package/src/lib/components/assist-jobs-drawer.component.d.ts +18 -20
  47. package/src/lib/components/assist-jobs-drawer.component.js +152 -172
  48. package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
  49. package/src/lib/components/assist-panel.component.js +62 -6
  50. package/src/lib/components/assist-panel.component.js.map +1 -1
  51. package/src/lib/components/staff-ai-credit-return.component.d.ts +55 -0
  52. package/src/lib/components/staff-ai-credit-return.component.js +346 -0
  53. package/src/lib/components/staff-ai-credit-return.component.js.map +1 -0
  54. package/src/lib/components/staff-org-ai-card.component.js +364 -279
  55. package/src/lib/components/staff-org-ai-card.component.js.map +1 -1
  56. package/src/lib/components/staff-user-ai-usage-card.component.d.ts +4 -0
  57. package/src/lib/components/staff-user-ai-usage-card.component.js +83 -28
  58. package/src/lib/components/staff-user-ai-usage-card.component.js.map +1 -1
  59. package/src/lib/components/use-ai-job-run.js +5 -18
  60. package/src/lib/components/use-ai-job-run.js.map +1 -1
  61. package/src/lib/jobs/ai-job-auto-confirm.d.ts +52 -0
  62. package/src/lib/jobs/ai-job-auto-confirm.js +62 -0
  63. package/src/lib/jobs/ai-job-auto-confirm.js.map +1 -0
  64. package/src/lib/jobs/ai-job-plan-step.d.ts +33 -2
  65. package/src/lib/jobs/ai-job-plan-step.js +161 -17
  66. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  67. package/src/lib/jobs/ai-job-site-step.d.ts +7 -0
  68. package/src/lib/jobs/ai-job-site-step.js +34 -4
  69. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  70. package/src/lib/jobs/ai-job-text-step.d.ts +7 -0
  71. package/src/lib/jobs/ai-job-text-step.js.map +1 -1
  72. package/src/lib/jobs/ai-jobs-notify.d.ts +37 -0
  73. package/src/lib/jobs/ai-jobs-notify.js +51 -0
  74. package/src/lib/jobs/ai-jobs-notify.js.map +1 -0
  75. package/src/lib/jobs/ai-jobs.d.ts +31 -4
  76. package/src/lib/jobs/ai-jobs.js +196 -32
  77. package/src/lib/jobs/ai-jobs.js.map +1 -1
  78. package/src/lib/model/ai-job-activity.d.ts +112 -0
  79. package/src/lib/model/ai-job-activity.js +144 -0
  80. package/src/lib/model/ai-job-activity.js.map +1 -0
  81. package/src/lib/model/ai-job-failure-copy.d.ts +69 -0
  82. package/src/lib/model/ai-job-failure-copy.js +78 -0
  83. package/src/lib/model/ai-job-failure-copy.js.map +1 -0
  84. package/src/lib/model/ai-job-notice.d.ts +61 -0
  85. package/src/lib/model/ai-job-notice.js +112 -0
  86. package/src/lib/model/ai-job-notice.js.map +1 -0
  87. package/src/lib/model/ai-jobs.types.d.ts +26 -0
  88. package/src/lib/model/ai-jobs.types.js +1 -0
  89. package/src/lib/model/ai-jobs.types.js.map +1 -1
  90. package/src/lib/model/ai-plan-capabilities.d.ts +7 -0
  91. package/src/lib/model/ai-plan-capabilities.js.map +1 -1
  92. package/src/lib/model/ai-site-build-progress.d.ts +45 -0
  93. package/src/lib/model/ai-site-build-progress.js +76 -0
  94. package/src/lib/model/ai-site-build-progress.js.map +1 -0
  95. package/src/lib/model/ai-site-inventory.d.ts +9 -0
  96. package/src/lib/model/ai-site-inventory.js +7 -2
  97. package/src/lib/model/ai-site-inventory.js.map +1 -1
  98. package/src/lib/model/ai-site-job.d.ts +93 -2
  99. package/src/lib/model/ai-site-job.js +88 -7
  100. package/src/lib/model/ai-site-job.js.map +1 -1
  101. package/src/lib/model/ai-site-start.d.ts +3 -1
  102. package/src/lib/model/ai-site-start.js +6 -4
  103. package/src/lib/model/ai-site-start.js.map +1 -1
  104. package/src/lib/model/ai-template-subjects.d.ts +4 -3
  105. package/src/lib/model/ai-template-subjects.js +5 -4
  106. package/src/lib/model/ai-template-subjects.js.map +1 -1
  107. package/src/lib/plan-entitlements.d.ts +3 -3
  108. package/src/lib/plan-entitlements.js +3 -3
  109. package/src/lib/plan-entitlements.js.map +1 -1
  110. package/src/lib/plugin.js +28 -0
  111. package/src/lib/plugin.js.map +1 -1
  112. package/src/lib/runtime/ai-abuse-guards.d.ts +9 -6
  113. package/src/lib/runtime/ai-abuse-guards.js +10 -7
  114. package/src/lib/runtime/ai-abuse-guards.js.map +1 -1
  115. package/src/lib/runtime/ai-doctrine-validators.js +31 -4
  116. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  117. package/src/lib/runtime/ai-gate.d.ts +3 -2
  118. package/src/lib/runtime/ai-gate.js +3 -2
  119. package/src/lib/runtime/ai-gate.js.map +1 -1
  120. package/src/lib/runtime/ai-palette.generated.js +12 -4
  121. package/src/lib/runtime/ai-palette.generated.js.map +1 -1
  122. package/src/lib/runtime/site-inventory.d.ts +16 -1
  123. package/src/lib/runtime/site-inventory.js +43 -3
  124. package/src/lib/runtime/site-inventory.js.map +1 -1
  125. package/src/lib/server/ai-admin-credits.d.ts +21 -0
  126. package/src/lib/server/ai-admin-credits.js +213 -0
  127. package/src/lib/server/ai-admin-credits.js.map +1 -0
  128. package/src/lib/server/ai-admin-org.js +12 -5
  129. package/src/lib/server/ai-admin-org.js.map +1 -1
  130. package/src/lib/server/ai-admin-signals.js +3 -1
  131. package/src/lib/server/ai-admin-signals.js.map +1 -1
  132. package/src/lib/server/ai-admin-user.js +12 -1
  133. package/src/lib/server/ai-admin-user.js.map +1 -1
  134. package/src/lib/server/ai-allotments.js +3 -2
  135. package/src/lib/server/ai-allotments.js.map +1 -1
  136. package/src/lib/server/ai-jobs-route.js +20 -2
  137. package/src/lib/server/ai-jobs-route.js.map +1 -1
  138. package/src/lib/server/assist-chat.js +3 -2
  139. package/src/lib/server/assist-chat.js.map +1 -1
  140. package/src/lib/server/billing-credits.js +4 -3
  141. package/src/lib/server/billing-credits.js.map +1 -1
  142. package/src/lib/server/billing-overage.js +3 -2
  143. package/src/lib/server/billing-overage.js.map +1 -1
  144. package/src/lib/server.js +11 -0
  145. package/src/lib/server.js.map +1 -1
  146. package/src/lib/usage/assist-credit-returns-write.d.ts +75 -0
  147. package/src/lib/usage/assist-credit-returns-write.js +150 -0
  148. package/src/lib/usage/assist-credit-returns-write.js.map +1 -0
  149. package/src/lib/usage/assist-credit-returns.d.ts +68 -0
  150. package/src/lib/usage/assist-credit-returns.js +79 -0
  151. package/src/lib/usage/assist-credit-returns.js.map +1 -0
  152. package/src/lib/usage/assist-free-taste.js +6 -3
  153. package/src/lib/usage/assist-free-taste.js.map +1 -1
  154. package/src/lib/usage/assist-job-refund.d.ts +100 -0
  155. package/src/lib/usage/assist-job-refund.js +109 -0
  156. package/src/lib/usage/assist-job-refund.js.map +1 -0
  157. package/src/lib/usage/assist-usage.js +5 -2
  158. package/src/lib/usage/assist-usage.js.map +1 -1
  159. package/src/lib/usage/staff-org-ai.d.ts +31 -1
  160. package/src/lib/usage/staff-org-ai.js +19 -2
  161. package/src/lib/usage/staff-org-ai.js.map +1 -1
@@ -14,7 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- import { type AiInventoryTheme, type AiSiteInventory } from '../model/ai-site-inventory';
17
+ import { type AiInventoryScreen, type AiInventoryTheme, type AiSiteInventory } from '../model/ai-site-inventory';
18
18
  /**
19
19
  * The reader behind the site inventory (AGL-2935): one site's components,
20
20
  * layouts, templates, forms, datasets, content collections, screens and
@@ -46,6 +46,21 @@ export interface ReadSiteInventoryOptions {
46
46
  }
47
47
  /** The theme a site renders with, reduced to what a plan and an email can act on. */
48
48
  export declare function aiInventoryTheme(host: Data | null): AiInventoryTheme | null;
49
+ /**
50
+ * The screen a site's starter home may be (AGL-3594, AGL-3408): the one
51
+ * `host.defaultHomeScreenId` names, while it still answers `/` in the routing
52
+ * map and is listed as a page. `null` on every site whose owner published a
53
+ * home page of their own, which clears the marker (AGL-3478), and on every
54
+ * site created before the marker existed, whose home page is the owner's.
55
+ */
56
+ export declare function aiStarterHomeCandidate(host: Readonly<Record<string, unknown>> | null, screens: ReadonlyArray<Pick<AiInventoryScreen, 'id' | 'template'>>): string | null;
57
+ /**
58
+ * Whether the starter home is as the platform wrote it: its one version is
59
+ * the one it was provisioned and published with. A version saved in the
60
+ * editor is an edit, published or not, and an edited starter is the owner's
61
+ * page — a plan keeps it.
62
+ */
63
+ export declare function aiStarterHomeUntouched(publishedVersionId: string, versionIds: readonly string[]): boolean;
49
64
  /**
50
65
  * Read one site's inventory for a generation.
51
66
  *
@@ -18,7 +18,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { isFormArchived } from "@aglyn/aglyn/app-utils/forms";
19
19
  import { describeTheme, resolveSiteTheme } from "@aglyn/aglyn/app-utils/site-theme";
20
20
  import { REUSABLE_COMPONENT_KIND_EMAIL, reusableComponentKindOf } from "@aglyn/aglyn/app-utils/reusable-component-kind";
21
- import { SCREEN_KIND_TEMPLATE } from "@aglyn/aglyn/app-utils/screen-route";
21
+ import { SCREEN_KIND_TEMPLATE, SCREEN_ROOT_PATH } from "@aglyn/aglyn/app-utils/screen-route";
22
22
  import { pluginRecordIndex } from "@aglyn/aglyn/plugin-manager/plugin-record-index";
23
23
  import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
24
24
  import { resolveOrgIdForHost } from "@aglyn/tenant-data-admin/server/organizations";
@@ -79,6 +79,28 @@ async function readWindow(query, fields, cap, map) {
79
79
  truncated: snapshot.docs.length > cap
80
80
  };
81
81
  }
82
+ /**
83
+ * The screen a site's starter home may be (AGL-3594, AGL-3408): the one
84
+ * `host.defaultHomeScreenId` names, while it still answers `/` in the routing
85
+ * map and is listed as a page. `null` on every site whose owner published a
86
+ * home page of their own, which clears the marker (AGL-3478), and on every
87
+ * site created before the marker existed, whose home page is the owner's.
88
+ */ export function aiStarterHomeCandidate(host, screens) {
89
+ const id = text(host == null ? void 0 : host['defaultHomeScreenId']);
90
+ if (!id) return null;
91
+ const routing = host == null ? void 0 : host['screens'];
92
+ const path = routing && typeof routing === 'object' ? routing[id] : undefined;
93
+ if (path !== SCREEN_ROOT_PATH) return null;
94
+ return screens.some((screen)=>screen.id === id && !screen.template) ? id : null;
95
+ }
96
+ /**
97
+ * Whether the starter home is as the platform wrote it: its one version is
98
+ * the one it was provisioned and published with. A version saved in the
99
+ * editor is an edit, published or not, and an edited starter is the owner's
100
+ * page — a plan keeps it.
101
+ */ export function aiStarterHomeUntouched(publishedVersionId, versionIds) {
102
+ return Boolean(publishedVersionId) && versionIds.length === 1 && versionIds[0] === publishedVersionId;
103
+ }
82
104
  /**
83
105
  * The org's datasets shared with the site, through the `dataset` index the
84
106
  * data plugin publishes (AGL-3080) rather than its collection: their names
@@ -130,6 +152,9 @@ async function readWindow(query, fields, cap, map) {
130
152
  const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
131
153
  const cap = Math.min(AI_SITE_INVENTORY_MAX_PER_KIND, Math.max(1, Math.floor((_options_maxPerKind = options.maxPerKind) != null ? _options_maxPerKind : AI_SITE_INVENTORY_MAX_PER_KIND)));
132
154
  const host = firestore.collection('hosts').doc(hostId);
155
+ // Each listed screen's published version, read in the same window, for the
156
+ // starter home's untouched test below.
157
+ const screenVersions = new Map();
133
158
  const [components, layouts, templates, forms, datasets, collections, screens, hostDoc] = await Promise.all([
134
159
  readWindow(host.collection('components'), [
135
160
  'displayName',
@@ -208,11 +233,13 @@ async function readWindow(query, fields, cap, map) {
208
233
  'slug',
209
234
  'layoutId',
210
235
  'kind',
211
- 'deletedAt'
236
+ 'deletedAt',
237
+ 'versionId'
212
238
  ], cap, (id, data)=>{
213
239
  const kind = text(data['kind']);
214
240
  // An email design and an error body are not pages a plan links or duplicates.
215
241
  if (data['deletedAt'] || kind && kind !== SCREEN_KIND_TEMPLATE) return null;
242
+ screenVersions.set(id, text(data['versionId']));
216
243
  return {
217
244
  id,
218
245
  name: nameOf(data, id, 'displayName', 'slug'),
@@ -223,6 +250,19 @@ async function readWindow(query, fields, cap, map) {
223
250
  }),
224
251
  host.get()
225
252
  ]);
253
+ const hostData = hostDoc.exists ? (_hostDoc_data = hostDoc.data()) != null ? _hostDoc_data : {} : null;
254
+ const starter = aiStarterHomeCandidate(hostData, screens.rows);
255
+ if (starter) {
256
+ var _screenVersions_get;
257
+ // One more read, and only on a site that still carries the marker: the
258
+ // starter's versions, of which an untouched one has exactly the one it
259
+ // was provisioned with.
260
+ const versions = await host.collection('screens').doc(starter).collection('versions').select().limit(2).get();
261
+ if (aiStarterHomeUntouched((_screenVersions_get = screenVersions.get(starter)) != null ? _screenVersions_get : '', versions.docs.map((doc)=>doc.id))) {
262
+ const row = screens.rows.find((screen)=>screen.id === starter);
263
+ if (row) row.replaceable = true;
264
+ }
265
+ }
226
266
  const windows = {
227
267
  components,
228
268
  layouts,
@@ -241,7 +281,7 @@ async function readWindow(query, fields, cap, map) {
241
281
  datasets: datasets.rows,
242
282
  collections: collections.rows,
243
283
  screens: screens.rows,
244
- theme: aiInventoryTheme(hostDoc.exists ? (_hostDoc_data = hostDoc.data()) != null ? _hostDoc_data : {} : null),
284
+ theme: aiInventoryTheme(hostData),
245
285
  truncated: AI_INVENTORY_KINDS.filter((kind)=>windows[kind].truncated)
246
286
  };
247
287
  }
@@ -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 { 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 { pluginRecordIndex } from '@aglyn/aglyn/plugin-manager/plugin-record-index'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { resolveOrgIdForHost } 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 * The org's datasets shared with the site, through the `dataset` index the\n * data plugin publishes (AGL-3080) rather than its collection: their names\n * and their fields' names, in the dataset page's order. None where no plugin\n * keeps datasets here.\n */\nasync function readIndexedDatasets(\n orgId: string,\n hostId: string,\n cap: number,\n): Promise<Window<AiInventoryDataset>> {\n const datasets = pluginRecordIndex('dataset')\n if (!datasets) return { rows: [], truncated: false }\n const { records, truncated } = await datasets.index.list({ orgId, hostId, limit: cap })\n return {\n rows: records.map((record) => {\n const fields = (Array.isArray(record.facts['fields']) ? (record.facts['fields'] as Data[]) : [])\n .map((field) => ({ id: text(field?.['id']), name: text(field?.['name']) || text(field?.['id']) }))\n .filter((field) => field.name)\n return {\n id: record.id,\n name: record.name,\n fields: fields.map((field) => field.name),\n fieldIds: fields.map((field) => field.id || field.name),\n }\n }),\n truncated,\n }\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 readIndexedDatasets(orgId, hostId, cap),\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","isFormArchived","describeTheme","resolveSiteTheme","REUSABLE_COMPONENT_KIND_EMAIL","reusableComponentKindOf","SCREEN_KIND_TEMPLATE","pluginRecordIndex","firebaseAdmin","resolveOrgIdForHost","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","readIndexedDatasets","orgId","hostId","datasets","records","index","list","record","facts","name","filter","fieldIds","readSiteInventory","options","hostDoc","owner","firestore","app","Math","min","max","floor","maxPerKind","collection","components","layouts","templates","forms","collections","screens","Promise","all","props","bracketedDefaults","prop","keys","parentId","kind","Boolean","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 { 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, SCREEN_ROOT_PATH } from '@aglyn/aglyn/app-utils/screen-route'\nimport { pluginRecordIndex } from '@aglyn/aglyn/plugin-manager/plugin-record-index'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { resolveOrgIdForHost } 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 * The screen a site's starter home may be (AGL-3594, AGL-3408): the one\n * `host.defaultHomeScreenId` names, while it still answers `/` in the routing\n * map and is listed as a page. `null` on every site whose owner published a\n * home page of their own, which clears the marker (AGL-3478), and on every\n * site created before the marker existed, whose home page is the owner's.\n */\nexport function aiStarterHomeCandidate(\n host: Readonly<Record<string, unknown>> | null,\n screens: ReadonlyArray<Pick<AiInventoryScreen, 'id' | 'template'>>,\n): string | null {\n const id = text(host?.['defaultHomeScreenId'])\n if (!id) return null\n const routing = host?.['screens']\n const path =\n routing && typeof routing === 'object' ? (routing as Record<string, unknown>)[id] : undefined\n if (path !== SCREEN_ROOT_PATH) return null\n return screens.some((screen) => screen.id === id && !screen.template) ? id : null\n}\n\n/**\n * Whether the starter home is as the platform wrote it: its one version is\n * the one it was provisioned and published with. A version saved in the\n * editor is an edit, published or not, and an edited starter is the owner's\n * page — a plan keeps it.\n */\nexport function aiStarterHomeUntouched(\n publishedVersionId: string,\n versionIds: readonly string[],\n): boolean {\n return Boolean(publishedVersionId) && versionIds.length === 1 && versionIds[0] === publishedVersionId\n}\n\n/**\n * The org's datasets shared with the site, through the `dataset` index the\n * data plugin publishes (AGL-3080) rather than its collection: their names\n * and their fields' names, in the dataset page's order. None where no plugin\n * keeps datasets here.\n */\nasync function readIndexedDatasets(\n orgId: string,\n hostId: string,\n cap: number,\n): Promise<Window<AiInventoryDataset>> {\n const datasets = pluginRecordIndex('dataset')\n if (!datasets) return { rows: [], truncated: false }\n const { records, truncated } = await datasets.index.list({ orgId, hostId, limit: cap })\n return {\n rows: records.map((record) => {\n const fields = (Array.isArray(record.facts['fields']) ? (record.facts['fields'] as Data[]) : [])\n .map((field) => ({ id: text(field?.['id']), name: text(field?.['name']) || text(field?.['id']) }))\n .filter((field) => field.name)\n return {\n id: record.id,\n name: record.name,\n fields: fields.map((field) => field.name),\n fieldIds: fields.map((field) => field.id || field.name),\n }\n }),\n truncated,\n }\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 // Each listed screen's published version, read in the same window, for the\n // starter home's untouched test below.\n const screenVersions = new Map<string, string>()\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 readIndexedDatasets(orgId, hostId, cap),\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', 'versionId'],\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 screenVersions.set(id, text(data['versionId']))\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 hostData = hostDoc.exists ? ((hostDoc.data() ?? {}) as Data) : null\n const starter = aiStarterHomeCandidate(hostData, screens.rows)\n if (starter) {\n // One more read, and only on a site that still carries the marker: the\n // starter's versions, of which an untouched one has exactly the one it\n // was provisioned with.\n const versions = await host\n .collection('screens')\n .doc(starter)\n .collection('versions')\n .select()\n .limit(2)\n .get()\n if (aiStarterHomeUntouched(screenVersions.get(starter) ?? '', versions.docs.map((doc) => doc.id))) {\n const row = screens.rows.find((screen) => screen.id === starter)\n if (row) row.replaceable = true\n }\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(hostData),\n truncated: AI_INVENTORY_KINDS.filter((kind) => windows[kind].truncated),\n }\n}\n"],"names":["hostCollectionKind","isFormArchived","describeTheme","resolveSiteTheme","REUSABLE_COMPONENT_KIND_EMAIL","reusableComponentKindOf","SCREEN_KIND_TEMPLATE","SCREEN_ROOT_PATH","pluginRecordIndex","firebaseAdmin","resolveOrgIdForHost","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","aiStarterHomeCandidate","screens","routing","undefined","some","screen","template","aiStarterHomeUntouched","publishedVersionId","versionIds","Boolean","readIndexedDatasets","orgId","hostId","datasets","records","index","list","record","facts","name","filter","fieldIds","readSiteInventory","options","hostDoc","owner","firestore","app","Math","min","max","floor","maxPerKind","collection","screenVersions","Map","components","layouts","templates","forms","collections","Promise","all","props","bracketedDefaults","prop","keys","parentId","kind","slug","set","layoutId","hostData","exists","starter","versions","find","replaceable","windows"],"mappings":"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"}
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /** The staff roles that may give credits back. */
18
+ export declare const AI_CREDIT_RETURN_ROLES: readonly ["super", "billing"];
19
+ declare function handler(request: Request): Promise<Response>;
20
+ export declare const dynamic = "force-dynamic";
21
+ export { handler as POST };
@@ -0,0 +1,213 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { ORG_BILLING_DOC_ID, ORG_BILLING_SUBCOLLECTION, pluginRequestFromWeb } from "@aglyn/aglyn/server";
18
+ import { emailUnverifiedResponse, firebaseAdmin, isImpersonationSession } from "@aglyn/tenant-data-admin";
19
+ import { setAdminAudit } from "@aglyn/tenant-data-admin/server/admin-audit-write";
20
+ import { invalidIdTokenResponse } from "@aglyn/tenant-data-admin/server/id-token-refusal";
21
+ import { FieldValue } from "firebase-admin/firestore";
22
+ import { freeAssistAccount } from "../usage/assist-free-taste.js";
23
+ import { assistUsageMonth } from "../usage/assist-usage.js";
24
+ import { accountCreditMeter, isAssistCreditReturnKey, returnAssistCredits, workspaceCreditMeter } from "../usage/assist-credit-returns-write.js";
25
+ /**
26
+ * STAFF GIVE AI CREDITS BACK (AGL-3595) — compensation for a fault of ours.
27
+ *
28
+ * A planner bug refused a Free workspace's plan after it had spent 227 of
29
+ * its 300 monthly credits, and the person could not try again until the
30
+ * month rolled. This is the control that undoes that: staff return credits
31
+ * to the meter they were drawn from, with a reason, and the person's next
32
+ * request is admitted against the reduced figure.
33
+ *
34
+ * POST `{ orgId | uid, action, meter, credits?, reason, jobId?, idempotencyKey }`
35
+ *
36
+ * action `give` return `credits` to each named meter
37
+ * `reset` return everything each meter used this month
38
+ * meter `workspace` the workspace's band (needs `orgId`)
39
+ * `account` a Free owner's allowance (`orgId` of a Free
40
+ * workspace, or the account's `uid`)
41
+ * `both` the two together, for a Free workspace
42
+ *
43
+ * ## The gate
44
+ *
45
+ * The staff claim, and then the `super` or `billing` staff role — the bar
46
+ * `/api/admin/org-override` holds for a quota write, because a give-back is
47
+ * one: it hands out credits that are worth money. A missing role is
48
+ * `support`, and refused (AGL-495).
49
+ *
50
+ * ## What it writes
51
+ *
52
+ * An adjustment, never an edit: `returnAssistCredits` adds to the month's
53
+ * `returnedUsd` and keeps the act under `creditReturns.{key}`, and one
54
+ * `adminAudit` row commits in the same transaction. A repeated key writes
55
+ * nothing and answers `duplicate`, so a double-click returns once.
56
+ */ /** The longest a staff reason may be, so a paste cannot fill the document. */ const REASON_MAX = 500;
57
+ /** The staff roles that may give credits back. */ export const AI_CREDIT_RETURN_ROLES = [
58
+ 'super',
59
+ 'billing'
60
+ ];
61
+ const refuse = (error, status, code, extra = {})=>Response.json(_extends({
62
+ error
63
+ }, code ? {
64
+ code
65
+ } : {}, extra), {
66
+ status
67
+ });
68
+ async function handler(request) {
69
+ var _headers_authorization;
70
+ const { method, body, headers: rawHeaders } = await pluginRequestFromWeb(request);
71
+ const headers = rawHeaders;
72
+ if (method !== 'POST') return refuse('Method not allowed', 405);
73
+ const authorization = (_headers_authorization = headers.authorization) != null ? _headers_authorization : '';
74
+ const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
75
+ if (!idToken) return refuse('Unauthenticated', 401);
76
+ try {
77
+ var _decoded_staffRole, _ref, _ref1, _ref2, _ref3, _ref4, _ref5;
78
+ const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
79
+ if (!decoded.email_verified && !isImpersonationSession(decoded)) {
80
+ return emailUnverifiedResponse();
81
+ }
82
+ if (!decoded['staff']) return refuse('Staff only', 403);
83
+ const role = String((_decoded_staffRole = decoded['staffRole']) != null ? _decoded_staffRole : 'support');
84
+ if (!AI_CREDIT_RETURN_ROLES.includes(role)) {
85
+ return refuse('Requires the billing or super staff role', 403, 'role');
86
+ }
87
+ const orgId = String((_ref = body == null ? void 0 : body.orgId) != null ? _ref : '').trim();
88
+ const uid = String((_ref1 = body == null ? void 0 : body.uid) != null ? _ref1 : '').trim();
89
+ const action = String((_ref2 = body == null ? void 0 : body.action) != null ? _ref2 : '');
90
+ const meter = String((_ref3 = body == null ? void 0 : body.meter) != null ? _ref3 : '');
91
+ if (!orgId && !uid) return refuse('Name an organization or an account', 400);
92
+ if (action !== 'give' && action !== 'reset') return refuse('Bad action', 400);
93
+ if (![
94
+ 'workspace',
95
+ 'account',
96
+ 'both'
97
+ ].includes(meter)) {
98
+ return refuse('Bad meter', 400);
99
+ }
100
+ // Refused before anything is read: the reason is the audit row's whole
101
+ // value, and a row written without one cannot be corrected later.
102
+ const reason = String((_ref4 = body == null ? void 0 : body.reason) != null ? _ref4 : '').trim().slice(0, REASON_MAX);
103
+ if (!reason) {
104
+ return refuse('Say why the credits are being given back', 400, 'reason_required');
105
+ }
106
+ const key = body == null ? void 0 : body.idempotencyKey;
107
+ if (!isAssistCreditReturnKey(key)) {
108
+ return refuse('Missing idempotency key', 400, 'idempotency_key');
109
+ }
110
+ const credits = Number(body == null ? void 0 : body.credits);
111
+ if (action === 'give' && !(Number.isInteger(credits) && credits > 0)) {
112
+ return refuse('Give back a positive whole number of credits', 400, 'invalid_credits');
113
+ }
114
+ const jobId = String((_ref5 = body == null ? void 0 : body.jobId) != null ? _ref5 : '').trim().slice(0, 200) || null;
115
+ const firestore = firebaseAdmin.app().firestore();
116
+ const month = assistUsageMonth(new Date());
117
+ const targets = [];
118
+ let accountUid = null;
119
+ if (orgId) {
120
+ var _orgSnap_data;
121
+ const orgRef = firestore.collection('orgs').doc(orgId);
122
+ const [orgSnap, billingSnap] = await Promise.all([
123
+ orgRef.get(),
124
+ orgRef.collection(ORG_BILLING_SUBCOLLECTION).doc(ORG_BILLING_DOC_ID).get()
125
+ ]);
126
+ if (!orgSnap.exists) return refuse('No such organization', 404);
127
+ // Org doc first, billing mirror over it — the merge the staff card
128
+ // reads, so a workspace whose subscription died resolves as Free here
129
+ // exactly as it does at the gate.
130
+ const org = _extends({}, (_orgSnap_data = orgSnap.data()) != null ? _orgSnap_data : {}, billingSnap.exists ? billingSnap.data() : {});
131
+ if (meter !== 'account') targets.push(workspaceCreditMeter(firestore, orgId, month));
132
+ if (meter !== 'workspace') {
133
+ var _ref6;
134
+ var _freeAssistAccount;
135
+ accountUid = (_ref6 = (_freeAssistAccount = freeAssistAccount(org)) == null ? void 0 : _freeAssistAccount.accountUid) != null ? _ref6 : null;
136
+ if (!accountUid) {
137
+ return refuse('Only a Free workspace with an owner draws on an account allowance', 409, 'no_account_allowance');
138
+ }
139
+ targets.push(accountCreditMeter(firestore, accountUid, month));
140
+ }
141
+ } else {
142
+ // From the staff user page: the account's own allowance, nothing else.
143
+ if (meter !== 'account') {
144
+ return refuse('An account has only its allowance to give back to', 400);
145
+ }
146
+ accountUid = uid;
147
+ targets.push(accountCreditMeter(firestore, uid, month));
148
+ }
149
+ const outcome = await returnAssistCredits(firestore, {
150
+ month,
151
+ meters: targets,
152
+ credits: action === 'reset' ? 'all' : credits,
153
+ key,
154
+ reason,
155
+ actorUid: decoded.uid,
156
+ source: 'staff',
157
+ jobId
158
+ }, (tx, lines)=>{
159
+ var _ref;
160
+ var _returned_;
161
+ const returned = lines.filter((line)=>line.credits > 0);
162
+ setAdminAudit(tx, firestore, _extends({
163
+ actorUid: decoded.uid,
164
+ action: action === 'reset' ? 'ai.credits.reset' : 'ai.credits.giveBack',
165
+ target: (_ref = (_returned_ = returned[0]) == null ? void 0 : _returned_.path) != null ? _ref : targets[0].ref.path
166
+ }, accountUid ? {
167
+ subjectUid: accountUid
168
+ } : {}, {
169
+ note: `${reason} — ` + returned.map((line)=>`${line.credits} credits to the ${line.meter}`).join(', ') + (jobId ? ` (job ${jobId})` : ''),
170
+ after: {
171
+ orgId: orgId || null,
172
+ accountUid,
173
+ month,
174
+ jobId,
175
+ idempotencyKey: key,
176
+ meters: returned.map((line)=>({
177
+ meter: line.meter,
178
+ path: line.path,
179
+ credits: line.credits,
180
+ usedBefore: line.usedBefore
181
+ }))
182
+ },
183
+ at: FieldValue.serverTimestamp()
184
+ }));
185
+ });
186
+ if (outcome.status === 'over') {
187
+ return refuse('That is more than this month used: ' + outcome.lines.map((line)=>`${line.usedBefore} on the ${line.meter}`).join(', '), 409, 'over_return', {
188
+ lines: outcome.lines
189
+ });
190
+ }
191
+ if (outcome.status === 'nothing') {
192
+ return refuse('Nothing used this month to give back', 409, 'nothing_used', {
193
+ lines: outcome.lines
194
+ });
195
+ }
196
+ return Response.json({
197
+ month,
198
+ duplicate: outcome.status === 'duplicate',
199
+ lines: outcome.lines
200
+ }, {
201
+ status: 200
202
+ });
203
+ } catch (error) {
204
+ const unauthenticated = invalidIdTokenResponse(error);
205
+ if (unauthenticated) return unauthenticated;
206
+ console.error('[ai/admin/credits]', error);
207
+ return refuse('Giving credits back failed', 500);
208
+ }
209
+ }
210
+ export const dynamic = 'force-dynamic';
211
+ export { handler as POST };
212
+
213
+ //# sourceMappingURL=ai-admin-credits.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-admin-credits.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 ORG_BILLING_DOC_ID,\n ORG_BILLING_SUBCOLLECTION,\n pluginRequestFromWeb,\n} from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin'\nimport { setAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit-write'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { freeAssistAccount } from '../usage/assist-free-taste'\nimport { assistUsageMonth } from '../usage/assist-usage'\nimport {\n accountCreditMeter,\n isAssistCreditReturnKey,\n returnAssistCredits,\n workspaceCreditMeter,\n type AssistCreditMeterTarget,\n} from '../usage/assist-credit-returns-write'\n\n/**\n * STAFF GIVE AI CREDITS BACK (AGL-3595) — compensation for a fault of ours.\n *\n * A planner bug refused a Free workspace's plan after it had spent 227 of\n * its 300 monthly credits, and the person could not try again until the\n * month rolled. This is the control that undoes that: staff return credits\n * to the meter they were drawn from, with a reason, and the person's next\n * request is admitted against the reduced figure.\n *\n * POST `{ orgId | uid, action, meter, credits?, reason, jobId?, idempotencyKey }`\n *\n * action `give` return `credits` to each named meter\n * `reset` return everything each meter used this month\n * meter `workspace` the workspace's band (needs `orgId`)\n * `account` a Free owner's allowance (`orgId` of a Free\n * workspace, or the account's `uid`)\n * `both` the two together, for a Free workspace\n *\n * ## The gate\n *\n * The staff claim, and then the `super` or `billing` staff role — the bar\n * `/api/admin/org-override` holds for a quota write, because a give-back is\n * one: it hands out credits that are worth money. A missing role is\n * `support`, and refused (AGL-495).\n *\n * ## What it writes\n *\n * An adjustment, never an edit: `returnAssistCredits` adds to the month's\n * `returnedUsd` and keeps the act under `creditReturns.{key}`, and one\n * `adminAudit` row commits in the same transaction. A repeated key writes\n * nothing and answers `duplicate`, so a double-click returns once.\n */\n\n/** The longest a staff reason may be, so a paste cannot fill the document. */\nconst REASON_MAX = 500\n\n/** The staff roles that may give credits back. */\nexport const AI_CREDIT_RETURN_ROLES = ['super', 'billing'] as const\n\ntype Meter = 'workspace' | 'account' | 'both'\n\nconst refuse = (error: string, status: number, code?: string, extra = {}) =>\n Response.json({ error, ...(code ? { code } : {}), ...extra }, { status })\n\nasync function handler(request: Request): Promise<Response> {\n const { method, body, headers: rawHeaders } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'POST') return refuse('Method not allowed', 405)\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return refuse('Unauthenticated', 401)\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']) return refuse('Staff only', 403)\n const role = String(decoded['staffRole'] ?? 'support')\n if (!(AI_CREDIT_RETURN_ROLES as readonly string[]).includes(role)) {\n return refuse('Requires the billing or super staff role', 403, 'role')\n }\n\n const orgId = String(body?.orgId ?? '').trim()\n const uid = String(body?.uid ?? '').trim()\n const action = String(body?.action ?? '')\n const meter = String(body?.meter ?? '') as Meter\n if (!orgId && !uid) return refuse('Name an organization or an account', 400)\n if (action !== 'give' && action !== 'reset') return refuse('Bad action', 400)\n if (!['workspace', 'account', 'both'].includes(meter)) {\n return refuse('Bad meter', 400)\n }\n // Refused before anything is read: the reason is the audit row's whole\n // value, and a row written without one cannot be corrected later.\n const reason = String(body?.reason ?? '').trim().slice(0, REASON_MAX)\n if (!reason) {\n return refuse('Say why the credits are being given back', 400, 'reason_required')\n }\n const key = body?.idempotencyKey\n if (!isAssistCreditReturnKey(key)) {\n return refuse('Missing idempotency key', 400, 'idempotency_key')\n }\n const credits = Number(body?.credits)\n if (action === 'give' && !(Number.isInteger(credits) && credits > 0)) {\n return refuse('Give back a positive whole number of credits', 400, 'invalid_credits')\n }\n const jobId = String(body?.jobId ?? '').trim().slice(0, 200) || null\n\n const firestore = firebaseAdmin.app().firestore()\n const month = assistUsageMonth(new Date())\n const targets: AssistCreditMeterTarget[] = []\n let accountUid: string | null = null\n\n if (orgId) {\n const orgRef = firestore.collection('orgs').doc(orgId)\n const [orgSnap, billingSnap] = await Promise.all([\n orgRef.get(),\n orgRef.collection(ORG_BILLING_SUBCOLLECTION).doc(ORG_BILLING_DOC_ID).get(),\n ])\n if (!orgSnap.exists) return refuse('No such organization', 404)\n // Org doc first, billing mirror over it — the merge the staff card\n // reads, so a workspace whose subscription died resolves as Free here\n // exactly as it does at the gate.\n const org = {\n ...(orgSnap.data() ?? {}),\n ...(billingSnap.exists ? billingSnap.data() : {}),\n }\n if (meter !== 'account') targets.push(workspaceCreditMeter(firestore, orgId, month))\n if (meter !== 'workspace') {\n accountUid = freeAssistAccount(org as never)?.accountUid ?? null\n if (!accountUid) {\n return refuse(\n 'Only a Free workspace with an owner draws on an account allowance',\n 409,\n 'no_account_allowance',\n )\n }\n targets.push(accountCreditMeter(firestore, accountUid, month))\n }\n } else {\n // From the staff user page: the account's own allowance, nothing else.\n if (meter !== 'account') {\n return refuse('An account has only its allowance to give back to', 400)\n }\n accountUid = uid\n targets.push(accountCreditMeter(firestore, uid, month))\n }\n\n const outcome = await returnAssistCredits(\n firestore,\n {\n month,\n meters: targets,\n credits: action === 'reset' ? 'all' : credits,\n key,\n reason,\n actorUid: decoded.uid,\n source: 'staff',\n jobId,\n },\n (tx, lines) => {\n const returned = lines.filter((line) => line.credits > 0)\n setAdminAudit(tx, firestore, {\n actorUid: decoded.uid,\n action: action === 'reset' ? 'ai.credits.reset' : 'ai.credits.giveBack',\n target: returned[0]?.path ?? targets[0]!.ref.path,\n ...(accountUid ? { subjectUid: accountUid } : {}),\n note:\n `${reason} — ` +\n returned.map((line) => `${line.credits} credits to the ${line.meter}`).join(', ') +\n (jobId ? ` (job ${jobId})` : ''),\n after: {\n orgId: orgId || null,\n accountUid,\n month,\n jobId,\n idempotencyKey: key,\n meters: returned.map((line) => ({\n meter: line.meter,\n path: line.path,\n credits: line.credits,\n usedBefore: line.usedBefore,\n })),\n },\n at: FieldValue.serverTimestamp(),\n })\n },\n )\n\n if (outcome.status === 'over') {\n return refuse(\n 'That is more than this month used: ' +\n outcome.lines.map((line) => `${line.usedBefore} on the ${line.meter}`).join(', '),\n 409,\n 'over_return',\n { lines: outcome.lines },\n )\n }\n if (outcome.status === 'nothing') {\n return refuse('Nothing used this month to give back', 409, 'nothing_used', {\n lines: outcome.lines,\n })\n }\n return Response.json(\n { month, duplicate: outcome.status === 'duplicate', lines: outcome.lines },\n { status: 200 },\n )\n } catch (error) {\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error('[ai/admin/credits]', error)\n return refuse('Giving credits back failed', 500)\n }\n}\n\nexport const dynamic = 'force-dynamic'\nexport { handler as POST }\n"],"names":["ORG_BILLING_DOC_ID","ORG_BILLING_SUBCOLLECTION","pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","setAdminAudit","invalidIdTokenResponse","FieldValue","freeAssistAccount","assistUsageMonth","accountCreditMeter","isAssistCreditReturnKey","returnAssistCredits","workspaceCreditMeter","REASON_MAX","AI_CREDIT_RETURN_ROLES","refuse","error","status","code","extra","Response","json","handler","request","headers","method","body","rawHeaders","authorization","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","email_verified","role","String","includes","orgId","trim","uid","action","meter","reason","key","idempotencyKey","credits","Number","isInteger","jobId","firestore","month","Date","targets","accountUid","orgSnap","orgRef","collection","doc","billingSnap","Promise","all","get","exists","org","data","push","outcome","meters","actorUid","source","tx","lines","returned","filter","line","target","path","ref","subjectUid","note","map","join","after","usedBefore","at","serverTimestamp","duplicate","unauthenticated","console","dynamic","POST"],"mappings":"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"}
@@ -23,7 +23,8 @@ import { invalidIdTokenResponse } from "@aglyn/tenant-data-admin/server/id-token
23
23
  import { addonKindFromPriceId } from "@aglyn/tenant-data-admin/server/billing-addons";
24
24
  import { orgMarginRow } from "@aglyn/aglyn/app-utils/margin-utilization";
25
25
  import { assistCogsAlertThresholdUsd, assistMarginMultiple } from "../usage/assist-spend-guards.js";
26
- import { composeStaffOrgAiAddon, composeStaffOrgAiMargin, composeStaffOrgAiOverage, composeStaffOrgAiPool, composeStaffOrgAiTokens, emptyJobCounts, STAFF_ORG_AI_JOB_STATUSES } from "../usage/staff-org-ai.js";
26
+ import { freeAccountUsageRef, freeAssistAccount } from "../usage/assist-free-taste.js";
27
+ import { composeStaffAiAccountAllowance, composeStaffOrgAiAddon, composeStaffOrgAiMargin, composeStaffOrgAiOverage, composeStaffOrgAiPool, composeStaffOrgAiTokens, emptyJobCounts, STAFF_ORG_AI_JOB_STATUSES } from "../usage/staff-org-ai.js";
27
28
  /**
28
29
  * THE STAFF AI CARD'S BACKING READ (AGL-2930).
29
30
  *
@@ -254,7 +255,7 @@ async function handler(request) {
254
255
  status: 400
255
256
  });
256
257
  try {
257
- var _orgSnap_data, _monthSnap_data, _org_name, _rollupDoc_get, _ref, _ref1, _ref2;
258
+ var _orgSnap_data, _monthSnap_data, _org_name, _rollupDoc_get, _ref, _ref1, _ref2, _accountSnap_data;
258
259
  const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
259
260
  if (!decoded.email_verified && !isImpersonationSession(decoded)) {
260
261
  return emailUnverifiedResponse();
@@ -287,10 +288,15 @@ async function handler(request) {
287
288
  // mirror — the same merge the margin route and the org detail read make.
288
289
  const org = _extends({}, (_orgSnap_data = orgSnap.data()) != null ? _orgSnap_data : {}, billingSnap.exists ? billingSnap.data() : {});
289
290
  const monthDoc = monthSnap.exists ? (_monthSnap_data = monthSnap.data()) != null ? _monthSnap_data : {} : null;
290
- const [jobs, users, since] = await Promise.all([
291
+ // A Free workspace also draws on its OWNER's account allowance
292
+ // (AGL-2925), which is what staff give credits back to beside the band
293
+ // (AGL-3595). One more document, read only for a Free workspace.
294
+ const free = freeAssistAccount(org);
295
+ const [jobs, users, since, accountSnap] = await Promise.all([
291
296
  readOrgAiJobsSummary(db, orgId, month),
292
297
  readOrgAiTopUsers(db, orgId, month),
293
- addonSinceFor(orgId)
298
+ addonSinceFor(orgId),
299
+ (free == null ? void 0 : free.accountUid) ? freeAccountUsageRef(db, free.accountUid, month).get() : Promise.resolve(null)
294
300
  ]);
295
301
  const pool = composeStaffOrgAiPool(org, monthDoc, now);
296
302
  // The overage line is an invoice, so it is priced off what the month
@@ -337,7 +343,8 @@ async function handler(request) {
337
343
  jobs,
338
344
  users,
339
345
  margin,
340
- tokens: composeStaffOrgAiTokens(monthDoc)
346
+ tokens: composeStaffOrgAiTokens(monthDoc),
347
+ account: (free == null ? void 0 : free.accountUid) && accountSnap ? composeStaffAiAccountAllowance(free.accountUid, month, accountSnap.exists ? (_accountSnap_data = accountSnap.data()) != null ? _accountSnap_data : null : null) : null
341
348
  };
342
349
  return Response.json(body, {
343
350
  status: 200