@aglyn/plugins-ai 1.0.0-beta.264 → 1.0.0-beta.266
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +17 -17
- package/src/lib/assets/site-styles/beauty.webp +0 -0
- package/src/lib/assets/site-styles/blog.webp +0 -0
- package/src/lib/assets/site-styles/business.webp +0 -0
- package/src/lib/assets/site-styles/coming-soon.webp +0 -0
- package/src/lib/assets/site-styles/education.webp +0 -0
- package/src/lib/assets/site-styles/events.webp +0 -0
- package/src/lib/assets/site-styles/fitness.webp +0 -0
- package/src/lib/assets/site-styles/landing.webp +0 -0
- package/src/lib/assets/site-styles/music.webp +0 -0
- package/src/lib/assets/site-styles/nonprofit.webp +0 -0
- package/src/lib/assets/site-styles/personal.webp +0 -0
- package/src/lib/assets/site-styles/photography.webp +0 -0
- package/src/lib/assets/site-styles/portfolio.webp +0 -0
- package/src/lib/assets/site-styles/professional.webp +0 -0
- package/src/lib/assets/site-styles/realestate.webp +0 -0
- package/src/lib/assets/site-styles/restaurant.webp +0 -0
- package/src/lib/assets/site-styles/store.webp +0 -0
- package/src/lib/assets/site-styles/studio.webp +0 -0
- package/src/lib/assets/site-styles/trades.webp +0 -0
- package/src/lib/assets/site-styles/wellness.webp +0 -0
- package/src/lib/assets/site-styles/yoga.webp +0 -0
- package/src/lib/components/ai-site-start-card.component.d.ts +12 -0
- package/src/lib/components/ai-site-start-card.component.js +256 -236
- package/src/lib/components/ai-site-start-card.component.js.map +1 -1
- package/src/lib/components/ai-site-start-steps.component.d.ts +108 -0
- package/src/lib/components/ai-site-start-steps.component.js +451 -0
- package/src/lib/components/ai-site-start-steps.component.js.map +1 -0
- package/src/lib/components/ai-site-start-tracking.d.ts +114 -0
- package/src/lib/components/ai-site-start-tracking.js +277 -0
- package/src/lib/components/ai-site-start-tracking.js.map +1 -0
- package/src/lib/components/ai-site-style-previews.d.ts +26 -0
- package/src/lib/components/ai-site-style-previews.js +115 -0
- package/src/lib/components/ai-site-style-previews.js.map +1 -0
- package/src/lib/insights/ai-insight-digest.d.ts +9 -0
- package/src/lib/insights/ai-insight-digest.js +35 -11
- package/src/lib/insights/ai-insight-digest.js.map +1 -1
- package/src/lib/jobs/ai-job-page-language.d.ts +9 -0
- package/src/lib/jobs/ai-job-page-language.js +20 -2
- package/src/lib/jobs/ai-job-page-language.js.map +1 -1
- package/src/lib/jobs/ai-job-plan-step.d.ts +43 -8
- package/src/lib/jobs/ai-job-plan-step.js +99 -18
- package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
- package/src/lib/jobs/ai-job-site-datasets.d.ts +82 -1
- package/src/lib/jobs/ai-job-site-datasets.js +276 -14
- package/src/lib/jobs/ai-job-site-datasets.js.map +1 -1
- package/src/lib/jobs/ai-layout-stock-photos.d.ts +108 -10
- package/src/lib/jobs/ai-layout-stock-photos.js +729 -87
- package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
- package/src/lib/jobs/fixtures/ai-site-foreign-datasets-recording.d.ts +58 -0
- package/src/lib/jobs/fixtures/ai-site-foreign-datasets-recording.js +678 -0
- package/src/lib/jobs/fixtures/ai-site-foreign-datasets-recording.js.map +1 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-agl3856-beta265-recordings.d.ts +33 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-agl3856-beta265-recordings.js +270 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-agl3856-beta265-recordings.js.map +1 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-agl3856-people-recordings.d.ts +33 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-agl3856-people-recordings.js +277 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-agl3856-people-recordings.js.map +1 -0
- package/src/lib/layout-language/ai-layout-compiler.js +49 -12
- package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
- package/src/lib/layout-language/ai-layout-design.d.ts +6 -3
- package/src/lib/layout-language/ai-layout-design.js +14 -13
- package/src/lib/layout-language/ai-layout-design.js.map +1 -1
- package/src/lib/layout-language/ai-layout-listings.d.ts +48 -0
- package/src/lib/layout-language/ai-layout-listings.js +91 -5
- package/src/lib/layout-language/ai-layout-listings.js.map +1 -1
- package/src/lib/layout-language/ai-layout-pictures.d.ts +15 -0
- package/src/lib/layout-language/ai-layout-pictures.js +6 -2
- package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
- package/src/lib/model/ai-site-home-arcs.d.ts +75 -0
- package/src/lib/model/ai-site-home-arcs.js +741 -0
- package/src/lib/model/ai-site-home-arcs.js.map +1 -0
- package/src/lib/model/ai-site-inventory.d.ts +18 -0
- package/src/lib/model/ai-site-inventory.js.map +1 -1
- package/src/lib/model/ai-site-job.d.ts +9 -6
- package/src/lib/model/ai-site-job.js +8 -5
- package/src/lib/model/ai-site-job.js.map +1 -1
- package/src/lib/model/ai-site-kinds.js +24 -24
- package/src/lib/model/ai-site-kinds.js.map +1 -1
- package/src/lib/model/ai-site-start-steps.d.ts +67 -0
- package/src/lib/model/ai-site-start-steps.js +127 -0
- package/src/lib/model/ai-site-start-steps.js.map +1 -0
- package/src/lib/model/ai-site-start.js +4 -1
- package/src/lib/model/ai-site-start.js.map +1 -1
- package/src/lib/runtime/ai-doctrine-validators.d.ts +32 -0
- package/src/lib/runtime/ai-doctrine-validators.js +119 -2
- package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
- package/src/lib/runtime/ai-doctrine.d.ts +6 -1
- package/src/lib/runtime/ai-doctrine.js +9 -5
- package/src/lib/runtime/ai-doctrine.js.map +1 -1
- package/src/lib/runtime/ai-palette.generated.js +5 -2
- package/src/lib/runtime/ai-palette.generated.js.map +1 -1
- package/src/lib/runtime/site-inventory.js +6 -0
- package/src/lib/runtime/site-inventory.js.map +1 -1
- package/src/lib/server/ai-insight-digest-route.js +2 -2
- package/src/lib/server/ai-insight-digest-route.js.map +1 -1
- package/src/lib/usage/assist-free-taste.js +3 -0
- package/src/lib/usage/assist-free-taste.js.map +1 -1
|
@@ -20,6 +20,7 @@ import { describeTheme, resolveSiteTheme } from "@aglyn/aglyn/app-utils/site-the
|
|
|
20
20
|
import { REUSABLE_COMPONENT_KIND_EMAIL, reusableComponentKindOf } from "@aglyn/aglyn/app-utils/reusable-component-kind";
|
|
21
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
|
+
import { hostScopeToken } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
23
24
|
import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
|
|
24
25
|
import { resolveOrgIdForHost } from "@aglyn/tenant-data-admin/server/organizations";
|
|
25
26
|
import { AI_INVENTORY_KINDS, AI_SITE_INVENTORY_MAX_PER_KIND, AiInventoryScopeError } from "../model/ai-site-inventory.js";
|
|
@@ -150,6 +151,9 @@ async function readWindow(query, fields, cap, map) {
|
|
|
150
151
|
// address field is the one its pages are reached by.
|
|
151
152
|
const page = aiInventoryRecordPage(record.facts['recordPage'], fields);
|
|
152
153
|
const address = (_ref = page == null ? void 0 : page.addressField) != null ? _ref : (_fields_find = fields.find((field)=>field.address && field.id)) == null ? void 0 : _fields_find.id;
|
|
154
|
+
// Made for this site, where its scope names this site alone (AGL-3912).
|
|
155
|
+
const visibleTo = Array.isArray(record.facts['visibleTo']) ? record.facts['visibleTo'] : [];
|
|
156
|
+
const siteOnly = visibleTo.length > 0 && visibleTo.every((token)=>token === hostScopeToken(hostId));
|
|
153
157
|
return _extends({
|
|
154
158
|
id: record.id,
|
|
155
159
|
name: record.name,
|
|
@@ -161,6 +165,8 @@ async function readWindow(query, fields, cap, map) {
|
|
|
161
165
|
addressField: address
|
|
162
166
|
} : {}, page ? {
|
|
163
167
|
recordBase: page.recordBase
|
|
168
|
+
} : {}, siteOnly ? {
|
|
169
|
+
siteOnly: true
|
|
164
170
|
} : {});
|
|
165
171
|
}),
|
|
166
172
|
truncated
|
|
@@ -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, 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/** A record template's base, as the data plugin keeps it: one to four URL-safe segments. */\nconst RECORD_BASE = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\\/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}$/\n\n/**\n * The record page a site already serves for a dataset (AGL-3747), from the\n * `recordPage` fact the data plugin's index answers for a site: its base and\n * the address field its pages are reached by. `null` where there is none, or\n * where its address field is no longer one of the dataset's fields.\n */\nexport function aiInventoryRecordPage(\n fact: unknown,\n fields: ReadonlyArray<{ id: string }>,\n): { recordBase: string; addressField: string } | null {\n if (!fact || typeof fact !== 'object') return null\n const base = text((fact as Data)['base']).replace(/^\\/+|\\/+$/g, '')\n const slugField = text((fact as Data)['slugField'])\n if (!RECORD_BASE.test(base) || !slugField || !fields.some((field) => field.id === slugField)) return null\n return { recordBase: base, addressField: slugField }\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) => ({\n id: text(field?.['id']),\n name: text(field?.['name']) || text(field?.['id']),\n image: text(field?.['customType']) === 'image',\n // A record page's address, the data plugin's `pageAddress` type (AGL-3727).\n address: text(field?.['customType']) === 'pageAddress',\n }))\n .filter((field) => field.name)\n // A record's photo (AGL-3616), the data plugin's `image` field type.\n const image = fields.find((field) => field.image && field.id)\n // The record page the site already serves for it (AGL-3747), whose\n // address field is the one its pages are reached by.\n const page = aiInventoryRecordPage(record.facts['recordPage'], fields)\n const address = page?.addressField ?? fields.find((field) => field.address && field.id)?.id\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 ...(image ? { imageField: image.id } : {}),\n ...(address ? { addressField: address } : {}),\n ...(page ? { recordBase: page.recordBase } : {}),\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","RECORD_BASE","aiInventoryRecordPage","fact","base","replace","slugField","test","recordBase","addressField","readIndexedDatasets","orgId","hostId","datasets","records","index","list","record","facts","name","image","address","filter","find","page","fieldIds","imageField","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","replaceable","windows"],"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 { hostScopeToken } from '@aglyn/aglyn/app-utils/scope-tokens'\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/** A record template's base, as the data plugin keeps it: one to four URL-safe segments. */\nconst RECORD_BASE = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\\/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}$/\n\n/**\n * The record page a site already serves for a dataset (AGL-3747), from the\n * `recordPage` fact the data plugin's index answers for a site: its base and\n * the address field its pages are reached by. `null` where there is none, or\n * where its address field is no longer one of the dataset's fields.\n */\nexport function aiInventoryRecordPage(\n fact: unknown,\n fields: ReadonlyArray<{ id: string }>,\n): { recordBase: string; addressField: string } | null {\n if (!fact || typeof fact !== 'object') return null\n const base = text((fact as Data)['base']).replace(/^\\/+|\\/+$/g, '')\n const slugField = text((fact as Data)['slugField'])\n if (!RECORD_BASE.test(base) || !slugField || !fields.some((field) => field.id === slugField)) return null\n return { recordBase: base, addressField: slugField }\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) => ({\n id: text(field?.['id']),\n name: text(field?.['name']) || text(field?.['id']),\n image: text(field?.['customType']) === 'image',\n // A record page's address, the data plugin's `pageAddress` type (AGL-3727).\n address: text(field?.['customType']) === 'pageAddress',\n }))\n .filter((field) => field.name)\n // A record's photo (AGL-3616), the data plugin's `image` field type.\n const image = fields.find((field) => field.image && field.id)\n // The record page the site already serves for it (AGL-3747), whose\n // address field is the one its pages are reached by.\n const page = aiInventoryRecordPage(record.facts['recordPage'], fields)\n const address = page?.addressField ?? fields.find((field) => field.address && field.id)?.id\n // Made for this site, where its scope names this site alone (AGL-3912).\n const visibleTo = Array.isArray(record.facts['visibleTo']) ? (record.facts['visibleTo'] as unknown[]) : []\n const siteOnly = visibleTo.length > 0 && visibleTo.every((token) => token === hostScopeToken(hostId))\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 ...(image ? { imageField: image.id } : {}),\n ...(address ? { addressField: address } : {}),\n ...(page ? { recordBase: page.recordBase } : {}),\n ...(siteOnly ? { siteOnly: true as const } : {}),\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","hostScopeToken","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","RECORD_BASE","aiInventoryRecordPage","fact","base","replace","slugField","test","recordBase","addressField","readIndexedDatasets","orgId","hostId","datasets","records","index","list","record","facts","name","image","address","filter","find","page","visibleTo","siteOnly","every","token","fieldIds","imageField","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","replaceable","windows"],"mappings":"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"}
|
|
@@ -122,10 +122,10 @@ import { runAiInsightDigestSweep } from "../insights/ai-insight-digest.js";
|
|
|
122
122
|
rateLimited: false
|
|
123
123
|
};
|
|
124
124
|
// The `ai-insights-digest` system email (AGL-3367), in the org's brand.
|
|
125
|
-
const content = await renderSystemEmailContent('ai-insights-digest', {
|
|
125
|
+
const content = await renderSystemEmailContent('ai-insights-digest', _extends({
|
|
126
126
|
'site.name': email.siteName,
|
|
127
127
|
'digest.body': email.text
|
|
128
|
-
}, systemEmailBrand(email.org), {
|
|
128
|
+
}, email.merge), systemEmailBrand(email.org), {
|
|
129
129
|
subject: email.subject,
|
|
130
130
|
text: email.text
|
|
131
131
|
});
|
|
@@ -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 { 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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"}
|
|
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, ...email.merge },\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","merge","org","subject","result","fromName","context","priority","catch","undefined","consoleOrigin","NEXT_PUBLIC_CONSOLE_URL","trim","replace","report","console"],"mappings":"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"}
|
|
@@ -17,6 +17,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
17
17
|
*/ import { FieldValue } from "firebase-admin/firestore";
|
|
18
18
|
import { assistUsdFromCredits } from "./assist-credits.js";
|
|
19
19
|
import { resolveEffectivePlan } from "@aglyn/aglyn/app-utils/plan-entitlements";
|
|
20
|
+
import { platformConsoleOrigin } from "@aglyn/aglyn/app-utils/platform-brand";
|
|
20
21
|
import { FREE_AI_TASTE_CREDITS_PER_MONTH } from "../plan-entitlements.js";
|
|
21
22
|
import { ASSIST_RETURNED_USD_FIELD, assistSpendAfterReturnsUsd } from "./assist-credit-returns.js";
|
|
22
23
|
import { sendStaffAlertEmail } from "@aglyn/tenant-data-admin/server/staff-alert-email";
|
|
@@ -287,6 +288,8 @@ export function platformFreeSpendRef(firestore, day) {
|
|
|
287
288
|
await sendStaffAlertEmail({
|
|
288
289
|
subject: crossed.paused ? `Free AI generation paused for ${day}: ${figure}` : `Free AI spend at 80% of today's ceiling (${day}): ${figure}`,
|
|
289
290
|
text: crossed.paused ? `Free-tier AI spend for ${day} (UTC) reached ${figure} ` + '(AI_FREE_DAILY_PLATFORM_CEILING_USD). Every Free workspace is ' + 'refused AI generation until the UTC day rolls; paid workspaces are ' + 'unaffected. If this is an abuse wave rather than organic use, the ' + '`ai-generate` feature lock on Staff → Lockdown stops generation ' + 'for everyone, and the assist signals page shows which workspaces ' + 'spent it.' : `Free-tier AI spend for ${day} (UTC) is at ${figure} ` + '(AI_FREE_DAILY_PLATFORM_CEILING_USD). At 100% every Free ' + 'workspace is refused AI generation until the day rolls. The ' + 'assist signals page shows the day so far; the `ai-generate` ' + 'feature lock on Staff → Lockdown is the manual stop.',
|
|
291
|
+
url: `${platformConsoleOrigin()}/admin/lockdown`,
|
|
292
|
+
urlLabel: 'Open Staff → Lockdown',
|
|
290
293
|
context: 'ai-free-spend'
|
|
291
294
|
});
|
|
292
295
|
return crossed;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/assist-free-taste.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 { FieldValue } from 'firebase-admin/firestore'\nimport {\n assistUsdFromCredits,\n type AssistRefusedBy,\n} from './assist-credits'\nimport { resolveEffectivePlan } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { FREE_AI_TASTE_CREDITS_PER_MONTH } from '../plan-entitlements'\nimport {\n ASSIST_RETURNED_USD_FIELD,\n assistSpendAfterReturnsUsd,\n} from './assist-credit-returns'\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation/definitions/org-billing.types'\nimport { sendStaffAlertEmail } from '@aglyn/tenant-data-admin/server/staff-alert-email'\nimport { addAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit-write'\n\n/**\n * The Free AI taste's precautions (AGL-2925) — the half of the meter that\n * exists only because the Free band has no invoice behind it.\n *\n * `FREE_AI_TASTE_CREDITS_PER_MONTH` gives every Free workspace 300 credits a\n * month as a wall. A wall per WORKSPACE is not a bound per PERSON: one\n * account may hold three free workspaces (AGL-2265), a script can mint\n * accounts faster than a person can, and a refused brief costs the same\n * tokens as an answered one. So the taste is metered a second time, per\n * account, and bounded three more ways. Everything below is read and\n * decided INSIDE `reserveAssistMessage`'s transaction, so a burst of\n * concurrent requests cannot each read \"under\" and all proceed — the same\n * property every other rung of that reservation has.\n *\n * ## Documents (both ABSENT from firebase-firestore.rules on purpose)\n *\n * users/{uid}/aiUsage/{YYYY-MM} the ACCOUNT's month:\n * { month, estCostUsd, requests, days: { [YYYY-MM-DD]: { requests,\n * refusals } }, updatedAt }\n * platformAiFreeSpend/{YYYY-MM-DD} the PLATFORM's day of free spend:\n * { day, estCostUsd, requests, refusals, alertedAt?, pausedAt?,\n * updatedAt }\n *\n * The account document sits under `users/{uid}`, whose owner may read and\n * write the user document itself. It may not touch this subcollection: the\n * `users` block matches its subcollections BY NAME and carries no wildcard,\n * so an unmatched name is default-deny for every client, staff included —\n * the same property `assistUsage` relies on one level down. An owner who\n * could write here could reset their own allowance, which is the whole\n * meter. The platform document is a top-level collection alongside\n * `platformCronBeats`, unmatched and therefore closed, and every reader is\n * an Admin-SDK reader behind the staff gate.\n *\n * ## Whose account\n *\n * The WORKSPACE OWNER's. A member invited to someone else's free workspace\n * draws from that workspace's band and from its owner's allowance, never\n * from their own account: the owner is the person the free workspace is\n * attributed to for the ceiling on how many they may hold, and attributing\n * the spend the same way is what keeps the two bounds describing one\n * person. `ownerUid` first, `createdByUid` for an org that predates\n * ownership stamping — the same union the free-workspace count reads.\n */\n\n/** Where an account's month of free AI spend lives, under `users/{uid}`. */\nexport const FREE_AI_ACCOUNT_USAGE_COLLECTION = 'aiUsage'\n\n/** The top-level collection holding one document per UTC day of free spend. */\nexport const PLATFORM_AI_FREE_SPEND_COLLECTION = 'platformAiFreeSpend'\n\n/**\n * Refusals a day before an account's free generation pauses. Three, not\n * one: a single declined brief is a normal outcome of asking an assistant\n * for things, and pausing on it would punish an ordinary afternoon. Three\n * in a day is somebody probing what the model will write.\n */\nexport const AI_FREE_REFUSALS_PER_DAY = 3\n\n/** The share of the platform ceiling at which staff are told, once a day. */\nexport const AI_FREE_PLATFORM_ALERT_SHARE = 0.8\n\n/** The account allowance, in USD of provider spend — the taste, per person. */\nexport const FREE_AI_ACCOUNT_BUDGET_USD = assistUsdFromCredits(\n FREE_AI_TASTE_CREDITS_PER_MONTH,\n)\n\n/**\n * Free requests one account may make a UTC day, across its workspaces\n * (`AI_FREE_DAILY_REQUESTS`, default 30).\n *\n * A request, not a message: this counts every reservation the account's\n * free workspaces take, at either door. Thirty is three times the\n * per-workspace message cap, so one workspace never hits it before its own\n * cap and three workspaces hit it exactly when their caps would have\n * summed — it bounds the multiplier, not the ordinary day. Zero is honored\n * as \"no free requests\", which is a decision an operator may write down;\n * junk and an empty value take the default.\n */\nexport function aiFreeDailyRequests(): number {\n const raw = process.env.AI_FREE_DAILY_REQUESTS\n const parsed = raw ? Number(raw) : Number.NaN\n return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 30\n}\n\n/**\n * The platform-wide ceiling on ONE UTC day of free-tier provider spend\n * (`AI_FREE_DAILY_PLATFORM_CEILING_USD`, default $25).\n *\n * Every other bound here is per something — per workspace, per account,\n * per address — and a wave of new accounts clears all of them one at a\n * time. This is the bound on the sum. At 80% of it staff are mailed; at\n * 100% every free reservation refuses until the day rolls, paid workspaces\n * untouched, and the manual `ai-generate` lockdown key remains the switch\n * for anything the ceiling did not catch.\n *\n * Fails to the DEFAULT, never to \"no ceiling\", for the reason the operator\n * COGS limit does: an empty, negative, zero or unparseable value is not a\n * decision to run unbounded. There is deliberately no `off` word — a\n * deployment that wants no ceiling sets a figure it is content to spend.\n */\nexport function aiFreeDailyPlatformCeilingUsd(): number {\n const raw = String(process.env.AI_FREE_DAILY_PLATFORM_CEILING_USD ?? '').trim()\n const parsed = raw ? Number(raw) : Number.NaN\n return Number.isFinite(parsed) && parsed > 0 ? parsed : 25\n}\n\n/** The account a free workspace's spend is attributed to. */\nexport interface FreeAssistAccount {\n /**\n * The workspace owner's uid, or `null` for an org document that names no\n * owner and no creator — every org created since ownership was stamped\n * names one, so `null` is the shape of a fixture or a very old record.\n * The account rungs are skipped for it; the org's own band still binds.\n */\n accountUid: string | null\n}\n\n/** The org fields the attribution reads, beside the billing ones. */\nexport type AssistMeteredOrg = Partial<AglynOrgBilling> & {\n ownerUid?: string | null\n createdByUid?: string | null\n}\n\n/**\n * The account to meter a workspace's free spend against, or `null` when the\n * workspace is not on the Free plan and none of this applies.\n *\n * `resolveEffectivePlan`, not `org.plan`: a workspace whose subscription\n * died is Free again, and its spend is the taste's spend again.\n */\nexport function freeAssistAccount(\n org: AssistMeteredOrg | null | undefined,\n): FreeAssistAccount | null {\n if (resolveEffectivePlan(org) !== 'free') return null\n const owner = String(org?.ownerUid ?? '').trim()\n const creator = String(org?.createdByUid ?? '').trim()\n return { accountUid: owner || creator || null }\n}\n\nexport function freeAccountUsageRef(\n firestore: FirebaseFirestore.Firestore,\n accountUid: string,\n month: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('users')\n .doc(accountUid)\n .collection(FREE_AI_ACCOUNT_USAGE_COLLECTION)\n .doc(month)\n}\n\nexport function platformFreeSpendRef(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): FirebaseFirestore.DocumentReference {\n return firestore.collection(PLATFORM_AI_FREE_SPEND_COLLECTION).doc(day)\n}\n\n/** What the reservation read about the account and the platform. */\nexport interface FreeTasteReads {\n accountCostUsd: number\n accountRequestsToday: number\n accountRefusalsToday: number\n platformCostUsd: number\n platformPaused: boolean\n}\n\n/** The two snapshots, as the transaction handed them over, into figures. */\nexport function freeTasteReadsFrom(\n account: FirebaseFirestore.DocumentSnapshot | null,\n platform: FirebaseFirestore.DocumentSnapshot | null,\n day: string,\n): FreeTasteReads {\n const days = (account?.get('days') ?? {}) as Record<\n string,\n { requests?: unknown; refusals?: unknown } | undefined\n >\n const today = days[day] ?? {}\n return {\n // Net of credits given back (AGL-3595), the same figure every reader\n // of this meter takes.\n accountCostUsd: assistSpendAfterReturnsUsd(\n account?.get('estCostUsd'),\n account?.get(ASSIST_RETURNED_USD_FIELD),\n ),\n accountRequestsToday: Number(today.requests ?? 0),\n accountRefusalsToday: Number(today.refusals ?? 0),\n platformCostUsd: Number(platform?.get('estCostUsd') ?? 0),\n platformPaused: Boolean(platform?.get('pausedAt')),\n }\n}\n\n/**\n * Which of the taste's own precautions refuses, or `null` when none does —\n * pure, so the spec can force every rung red without a Firestore.\n *\n * Checked in the order the reads are cheapest to explain: the daily\n * request cap and the refusal pause (both reset on a clock the customer\n * can be told about), then the account's month (an upgrade is the way\n * out), then the platform ceiling (nobody's doing; try again tomorrow).\n * The org's own rungs — its daily message cap, its band — run before this\n * in the reservation, so a workspace at its own band hears about its own\n * band rather than about its owner's other workspaces.\n *\n * Every comparison is `>=` against a count the reservation is about to\n * increment, so a cap of N admits exactly N. A paused platform refuses\n * regardless of the running figure: the pause is a decision recorded on\n * the document, and a ceiling raised by an operator mid-day does not undo\n * it — the day rolls, or staff clear the document.\n */\nexport function freeTasteRefusal(\n reads: FreeTasteReads,\n limits: {\n dailyRequests?: number\n refusalsPerDay?: number\n accountBudgetUsd?: number\n platformCeilingUsd?: number\n } = {},\n): Extract<AssistRefusedBy, 'requests' | 'refusals' | 'account' | 'platform'> | null {\n const dailyRequests = limits.dailyRequests ?? aiFreeDailyRequests()\n const refusalsPerDay = limits.refusalsPerDay ?? AI_FREE_REFUSALS_PER_DAY\n const accountBudgetUsd = limits.accountBudgetUsd ?? FREE_AI_ACCOUNT_BUDGET_USD\n const platformCeilingUsd =\n limits.platformCeilingUsd ?? aiFreeDailyPlatformCeilingUsd()\n if (reads.accountRequestsToday >= dailyRequests) return 'requests'\n if (reads.accountRefusalsToday >= refusalsPerDay) return 'refusals'\n if (reads.accountCostUsd >= accountBudgetUsd) return 'account'\n if (reads.platformPaused || reads.platformCostUsd >= platformCeilingUsd) {\n return 'platform'\n }\n return null\n}\n\n/**\n * The account-side write one admitted free reservation owes: a request\n * counted for the month and for the day. `set(…, { merge: true })` with\n * nested increments, so the first request of a month conjures the document\n * and a day's map entry appears the first time that day is counted.\n */\nexport function freeAccountReservationWrite(\n month: string,\n day: string,\n): Record<string, unknown> {\n const increment = FieldValue.increment\n return {\n month,\n requests: increment(1),\n days: { [day]: { requests: increment(1) } },\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n\n/**\n * The two writes one metered free turn owes, beside the org's own rollup.\n *\n * A `refusal` stop is the one case where the meters disagree on purpose. The\n * tokens were spent, so the PLATFORM document — which bounds our money —\n * takes the cost and counts the refusal. The ACCOUNT's credits do not move:\n * a refusal never draws credits, because a customer whose brief was\n * declined has not been given anything, and because a run of declined\n * briefs is answered by the refusal pause rather than by an emptied band.\n * The account's day counts the refusal, which is what the pause reads.\n */\nexport function freeTasteMeterWrites(input: {\n estCostUsd: number\n refused: boolean\n day: string\n month: string\n}): { account: Record<string, unknown>; platform: Record<string, unknown> } {\n const increment = FieldValue.increment\n const serverTimestamp = FieldValue.serverTimestamp\n const { estCostUsd, refused, day, month } = input\n return {\n account: refused\n ? {\n month,\n days: { [day]: { refusals: increment(1) } },\n updatedAt: serverTimestamp(),\n }\n : { month, estCostUsd: increment(estCostUsd), updatedAt: serverTimestamp() },\n platform: {\n day,\n estCostUsd: increment(estCostUsd),\n requests: increment(1),\n refusals: increment(refused ? 1 : 0),\n updatedAt: serverTimestamp(),\n },\n }\n}\n\n/**\n * After a free turn is metered: tell staff at 80% of the day's ceiling,\n * and record the pause at 100% — each ONCE per UTC day, deduplicated on\n * the day document itself inside a transaction, so two turns landing\n * together cannot both cross a threshold and both announce it.\n *\n * The refusal at the ceiling is NOT decided here; `freeTasteRefusal` reads\n * the figure inside every reservation and refuses on it directly. This\n * only says so out loud: the email, and an `adminAudit` row under\n * `platform.aiFreeSpend.paused` so the staff audit trail shows the day the\n * taste switched itself off. Both are best-effort and neither can fail the\n * turn that triggered them — the turn is already paid for.\n *\n * Returns what it announced, for the spec and for the log.\n */\nexport async function announcePlatformFreeSpend(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): Promise<{ alerted: boolean; paused: boolean; estCostUsd: number }> {\n const ceilingUsd = aiFreeDailyPlatformCeilingUsd()\n const ref = platformFreeSpendRef(firestore, day)\n const crossed = await firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(ref)\n const estCostUsd = Number(snapshot.get('estCostUsd') ?? 0)\n const alert =\n !snapshot.get('alertedAt') &&\n estCostUsd >= ceilingUsd * AI_FREE_PLATFORM_ALERT_SHARE\n const pause = !snapshot.get('pausedAt') && estCostUsd >= ceilingUsd\n if (alert || pause) {\n tx.set(\n ref,\n {\n ...(alert ? { alertedAt: FieldValue.serverTimestamp() } : {}),\n ...(pause ? { pausedAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n }\n return { alerted: alert, paused: pause, estCostUsd }\n })\n if (!crossed.alerted && !crossed.paused) return crossed\n\n const figure = `$${crossed.estCostUsd.toFixed(2)} of the $${ceilingUsd.toFixed(2)} ceiling`\n if (crossed.paused) {\n await addAdminAudit(firestore, {\n actorUid: 'system:ai-free-spend',\n action: 'platform.aiFreeSpend.paused',\n target: `${PLATFORM_AI_FREE_SPEND_COLLECTION}/${day}`,\n after: { day, estCostUsd: crossed.estCostUsd, ceilingUsd },\n at: FieldValue.serverTimestamp(),\n })\n .catch((error) =>\n console.error('[ai-free-spend] audit row failed', error),\n )\n }\n await sendStaffAlertEmail({\n subject: crossed.paused\n ? `Free AI generation paused for ${day}: ${figure}`\n : `Free AI spend at 80% of today's ceiling (${day}): ${figure}`,\n text: crossed.paused\n ? `Free-tier AI spend for ${day} (UTC) reached ${figure} ` +\n '(AI_FREE_DAILY_PLATFORM_CEILING_USD). Every Free workspace is ' +\n 'refused AI generation until the UTC day rolls; paid workspaces are ' +\n 'unaffected. If this is an abuse wave rather than organic use, the ' +\n '`ai-generate` feature lock on Staff → Lockdown stops generation ' +\n 'for everyone, and the assist signals page shows which workspaces ' +\n 'spent it.'\n : `Free-tier AI spend for ${day} (UTC) is at ${figure} ` +\n '(AI_FREE_DAILY_PLATFORM_CEILING_USD). At 100% every Free ' +\n 'workspace is refused AI generation until the day rolls. The ' +\n 'assist signals page shows the day so far; the `ai-generate` ' +\n 'feature lock on Staff → Lockdown is the manual stop.',\n context: 'ai-free-spend',\n })\n return crossed\n}\n\n/** The staff readout of today's free spend, for the assist signals page. */\nexport interface PlatformFreeSpendReadout {\n day: string\n estCostUsd: number\n requests: number\n refusals: number\n ceilingUsd: number\n /** True once the 80% mail went out today. */\n alerted: boolean\n /** True while free generation is refused for the day. */\n paused: boolean\n}\n\nexport async function readPlatformFreeSpend(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): Promise<PlatformFreeSpendReadout> {\n const snapshot = await platformFreeSpendRef(firestore, day).get()\n const ceilingUsd = aiFreeDailyPlatformCeilingUsd()\n const estCostUsd = Number(snapshot.get('estCostUsd') ?? 0)\n return {\n day,\n estCostUsd,\n requests: Number(snapshot.get('requests') ?? 0),\n refusals: Number(snapshot.get('refusals') ?? 0),\n ceilingUsd,\n alerted: Boolean(snapshot.get('alertedAt')),\n // The pause is the recorded decision OR the live figure: a day that\n // crossed the ceiling before anything announced it is paused all the\n // same, because the reservation refuses on the figure.\n paused: Boolean(snapshot.get('pausedAt')) || estCostUsd >= ceilingUsd,\n }\n}\n"],"names":["FieldValue","assistUsdFromCredits","resolveEffectivePlan","FREE_AI_TASTE_CREDITS_PER_MONTH","ASSIST_RETURNED_USD_FIELD","assistSpendAfterReturnsUsd","sendStaffAlertEmail","addAdminAudit","FREE_AI_ACCOUNT_USAGE_COLLECTION","PLATFORM_AI_FREE_SPEND_COLLECTION","AI_FREE_REFUSALS_PER_DAY","AI_FREE_PLATFORM_ALERT_SHARE","FREE_AI_ACCOUNT_BUDGET_USD","aiFreeDailyRequests","raw","process","env","AI_FREE_DAILY_REQUESTS","parsed","Number","NaN","isFinite","Math","floor","aiFreeDailyPlatformCeilingUsd","String","AI_FREE_DAILY_PLATFORM_CEILING_USD","trim","freeAssistAccount","org","owner","ownerUid","creator","createdByUid","accountUid","freeAccountUsageRef","firestore","month","collection","doc","platformFreeSpendRef","day","freeTasteReadsFrom","account","platform","days","today","get","accountCostUsd","accountRequestsToday","requests","accountRefusalsToday","refusals","platformCostUsd","platformPaused","Boolean","freeTasteRefusal","reads","limits","dailyRequests","refusalsPerDay","accountBudgetUsd","platformCeilingUsd","freeAccountReservationWrite","increment","updatedAt","serverTimestamp","freeTasteMeterWrites","input","estCostUsd","refused","announcePlatformFreeSpend","ceilingUsd","ref","crossed","runTransaction","tx","snapshot","alert","pause","set","alertedAt","pausedAt","merge","alerted","paused","figure","toFixed","actorUid","action","target","after","at","catch","error","console","subject","text","context","readPlatformFreeSpend"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/assist-free-taste.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 { FieldValue } from 'firebase-admin/firestore'\nimport {\n assistUsdFromCredits,\n type AssistRefusedBy,\n} from './assist-credits'\nimport { resolveEffectivePlan } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { platformConsoleOrigin } from '@aglyn/aglyn/app-utils/platform-brand'\nimport { FREE_AI_TASTE_CREDITS_PER_MONTH } from '../plan-entitlements'\nimport {\n ASSIST_RETURNED_USD_FIELD,\n assistSpendAfterReturnsUsd,\n} from './assist-credit-returns'\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation/definitions/org-billing.types'\nimport { sendStaffAlertEmail } from '@aglyn/tenant-data-admin/server/staff-alert-email'\nimport { addAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit-write'\n\n/**\n * The Free AI taste's precautions (AGL-2925) — the half of the meter that\n * exists only because the Free band has no invoice behind it.\n *\n * `FREE_AI_TASTE_CREDITS_PER_MONTH` gives every Free workspace 300 credits a\n * month as a wall. A wall per WORKSPACE is not a bound per PERSON: one\n * account may hold three free workspaces (AGL-2265), a script can mint\n * accounts faster than a person can, and a refused brief costs the same\n * tokens as an answered one. So the taste is metered a second time, per\n * account, and bounded three more ways. Everything below is read and\n * decided INSIDE `reserveAssistMessage`'s transaction, so a burst of\n * concurrent requests cannot each read \"under\" and all proceed — the same\n * property every other rung of that reservation has.\n *\n * ## Documents (both ABSENT from firebase-firestore.rules on purpose)\n *\n * users/{uid}/aiUsage/{YYYY-MM} the ACCOUNT's month:\n * { month, estCostUsd, requests, days: { [YYYY-MM-DD]: { requests,\n * refusals } }, updatedAt }\n * platformAiFreeSpend/{YYYY-MM-DD} the PLATFORM's day of free spend:\n * { day, estCostUsd, requests, refusals, alertedAt?, pausedAt?,\n * updatedAt }\n *\n * The account document sits under `users/{uid}`, whose owner may read and\n * write the user document itself. It may not touch this subcollection: the\n * `users` block matches its subcollections BY NAME and carries no wildcard,\n * so an unmatched name is default-deny for every client, staff included —\n * the same property `assistUsage` relies on one level down. An owner who\n * could write here could reset their own allowance, which is the whole\n * meter. The platform document is a top-level collection alongside\n * `platformCronBeats`, unmatched and therefore closed, and every reader is\n * an Admin-SDK reader behind the staff gate.\n *\n * ## Whose account\n *\n * The WORKSPACE OWNER's. A member invited to someone else's free workspace\n * draws from that workspace's band and from its owner's allowance, never\n * from their own account: the owner is the person the free workspace is\n * attributed to for the ceiling on how many they may hold, and attributing\n * the spend the same way is what keeps the two bounds describing one\n * person. `ownerUid` first, `createdByUid` for an org that predates\n * ownership stamping — the same union the free-workspace count reads.\n */\n\n/** Where an account's month of free AI spend lives, under `users/{uid}`. */\nexport const FREE_AI_ACCOUNT_USAGE_COLLECTION = 'aiUsage'\n\n/** The top-level collection holding one document per UTC day of free spend. */\nexport const PLATFORM_AI_FREE_SPEND_COLLECTION = 'platformAiFreeSpend'\n\n/**\n * Refusals a day before an account's free generation pauses. Three, not\n * one: a single declined brief is a normal outcome of asking an assistant\n * for things, and pausing on it would punish an ordinary afternoon. Three\n * in a day is somebody probing what the model will write.\n */\nexport const AI_FREE_REFUSALS_PER_DAY = 3\n\n/** The share of the platform ceiling at which staff are told, once a day. */\nexport const AI_FREE_PLATFORM_ALERT_SHARE = 0.8\n\n/** The account allowance, in USD of provider spend — the taste, per person. */\nexport const FREE_AI_ACCOUNT_BUDGET_USD = assistUsdFromCredits(\n FREE_AI_TASTE_CREDITS_PER_MONTH,\n)\n\n/**\n * Free requests one account may make a UTC day, across its workspaces\n * (`AI_FREE_DAILY_REQUESTS`, default 30).\n *\n * A request, not a message: this counts every reservation the account's\n * free workspaces take, at either door. Thirty is three times the\n * per-workspace message cap, so one workspace never hits it before its own\n * cap and three workspaces hit it exactly when their caps would have\n * summed — it bounds the multiplier, not the ordinary day. Zero is honored\n * as \"no free requests\", which is a decision an operator may write down;\n * junk and an empty value take the default.\n */\nexport function aiFreeDailyRequests(): number {\n const raw = process.env.AI_FREE_DAILY_REQUESTS\n const parsed = raw ? Number(raw) : Number.NaN\n return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 30\n}\n\n/**\n * The platform-wide ceiling on ONE UTC day of free-tier provider spend\n * (`AI_FREE_DAILY_PLATFORM_CEILING_USD`, default $25).\n *\n * Every other bound here is per something — per workspace, per account,\n * per address — and a wave of new accounts clears all of them one at a\n * time. This is the bound on the sum. At 80% of it staff are mailed; at\n * 100% every free reservation refuses until the day rolls, paid workspaces\n * untouched, and the manual `ai-generate` lockdown key remains the switch\n * for anything the ceiling did not catch.\n *\n * Fails to the DEFAULT, never to \"no ceiling\", for the reason the operator\n * COGS limit does: an empty, negative, zero or unparseable value is not a\n * decision to run unbounded. There is deliberately no `off` word — a\n * deployment that wants no ceiling sets a figure it is content to spend.\n */\nexport function aiFreeDailyPlatformCeilingUsd(): number {\n const raw = String(process.env.AI_FREE_DAILY_PLATFORM_CEILING_USD ?? '').trim()\n const parsed = raw ? Number(raw) : Number.NaN\n return Number.isFinite(parsed) && parsed > 0 ? parsed : 25\n}\n\n/** The account a free workspace's spend is attributed to. */\nexport interface FreeAssistAccount {\n /**\n * The workspace owner's uid, or `null` for an org document that names no\n * owner and no creator — every org created since ownership was stamped\n * names one, so `null` is the shape of a fixture or a very old record.\n * The account rungs are skipped for it; the org's own band still binds.\n */\n accountUid: string | null\n}\n\n/** The org fields the attribution reads, beside the billing ones. */\nexport type AssistMeteredOrg = Partial<AglynOrgBilling> & {\n ownerUid?: string | null\n createdByUid?: string | null\n}\n\n/**\n * The account to meter a workspace's free spend against, or `null` when the\n * workspace is not on the Free plan and none of this applies.\n *\n * `resolveEffectivePlan`, not `org.plan`: a workspace whose subscription\n * died is Free again, and its spend is the taste's spend again.\n */\nexport function freeAssistAccount(\n org: AssistMeteredOrg | null | undefined,\n): FreeAssistAccount | null {\n if (resolveEffectivePlan(org) !== 'free') return null\n const owner = String(org?.ownerUid ?? '').trim()\n const creator = String(org?.createdByUid ?? '').trim()\n return { accountUid: owner || creator || null }\n}\n\nexport function freeAccountUsageRef(\n firestore: FirebaseFirestore.Firestore,\n accountUid: string,\n month: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('users')\n .doc(accountUid)\n .collection(FREE_AI_ACCOUNT_USAGE_COLLECTION)\n .doc(month)\n}\n\nexport function platformFreeSpendRef(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): FirebaseFirestore.DocumentReference {\n return firestore.collection(PLATFORM_AI_FREE_SPEND_COLLECTION).doc(day)\n}\n\n/** What the reservation read about the account and the platform. */\nexport interface FreeTasteReads {\n accountCostUsd: number\n accountRequestsToday: number\n accountRefusalsToday: number\n platformCostUsd: number\n platformPaused: boolean\n}\n\n/** The two snapshots, as the transaction handed them over, into figures. */\nexport function freeTasteReadsFrom(\n account: FirebaseFirestore.DocumentSnapshot | null,\n platform: FirebaseFirestore.DocumentSnapshot | null,\n day: string,\n): FreeTasteReads {\n const days = (account?.get('days') ?? {}) as Record<\n string,\n { requests?: unknown; refusals?: unknown } | undefined\n >\n const today = days[day] ?? {}\n return {\n // Net of credits given back (AGL-3595), the same figure every reader\n // of this meter takes.\n accountCostUsd: assistSpendAfterReturnsUsd(\n account?.get('estCostUsd'),\n account?.get(ASSIST_RETURNED_USD_FIELD),\n ),\n accountRequestsToday: Number(today.requests ?? 0),\n accountRefusalsToday: Number(today.refusals ?? 0),\n platformCostUsd: Number(platform?.get('estCostUsd') ?? 0),\n platformPaused: Boolean(platform?.get('pausedAt')),\n }\n}\n\n/**\n * Which of the taste's own precautions refuses, or `null` when none does —\n * pure, so the spec can force every rung red without a Firestore.\n *\n * Checked in the order the reads are cheapest to explain: the daily\n * request cap and the refusal pause (both reset on a clock the customer\n * can be told about), then the account's month (an upgrade is the way\n * out), then the platform ceiling (nobody's doing; try again tomorrow).\n * The org's own rungs — its daily message cap, its band — run before this\n * in the reservation, so a workspace at its own band hears about its own\n * band rather than about its owner's other workspaces.\n *\n * Every comparison is `>=` against a count the reservation is about to\n * increment, so a cap of N admits exactly N. A paused platform refuses\n * regardless of the running figure: the pause is a decision recorded on\n * the document, and a ceiling raised by an operator mid-day does not undo\n * it — the day rolls, or staff clear the document.\n */\nexport function freeTasteRefusal(\n reads: FreeTasteReads,\n limits: {\n dailyRequests?: number\n refusalsPerDay?: number\n accountBudgetUsd?: number\n platformCeilingUsd?: number\n } = {},\n): Extract<AssistRefusedBy, 'requests' | 'refusals' | 'account' | 'platform'> | null {\n const dailyRequests = limits.dailyRequests ?? aiFreeDailyRequests()\n const refusalsPerDay = limits.refusalsPerDay ?? AI_FREE_REFUSALS_PER_DAY\n const accountBudgetUsd = limits.accountBudgetUsd ?? FREE_AI_ACCOUNT_BUDGET_USD\n const platformCeilingUsd =\n limits.platformCeilingUsd ?? aiFreeDailyPlatformCeilingUsd()\n if (reads.accountRequestsToday >= dailyRequests) return 'requests'\n if (reads.accountRefusalsToday >= refusalsPerDay) return 'refusals'\n if (reads.accountCostUsd >= accountBudgetUsd) return 'account'\n if (reads.platformPaused || reads.platformCostUsd >= platformCeilingUsd) {\n return 'platform'\n }\n return null\n}\n\n/**\n * The account-side write one admitted free reservation owes: a request\n * counted for the month and for the day. `set(…, { merge: true })` with\n * nested increments, so the first request of a month conjures the document\n * and a day's map entry appears the first time that day is counted.\n */\nexport function freeAccountReservationWrite(\n month: string,\n day: string,\n): Record<string, unknown> {\n const increment = FieldValue.increment\n return {\n month,\n requests: increment(1),\n days: { [day]: { requests: increment(1) } },\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n\n/**\n * The two writes one metered free turn owes, beside the org's own rollup.\n *\n * A `refusal` stop is the one case where the meters disagree on purpose. The\n * tokens were spent, so the PLATFORM document — which bounds our money —\n * takes the cost and counts the refusal. The ACCOUNT's credits do not move:\n * a refusal never draws credits, because a customer whose brief was\n * declined has not been given anything, and because a run of declined\n * briefs is answered by the refusal pause rather than by an emptied band.\n * The account's day counts the refusal, which is what the pause reads.\n */\nexport function freeTasteMeterWrites(input: {\n estCostUsd: number\n refused: boolean\n day: string\n month: string\n}): { account: Record<string, unknown>; platform: Record<string, unknown> } {\n const increment = FieldValue.increment\n const serverTimestamp = FieldValue.serverTimestamp\n const { estCostUsd, refused, day, month } = input\n return {\n account: refused\n ? {\n month,\n days: { [day]: { refusals: increment(1) } },\n updatedAt: serverTimestamp(),\n }\n : { month, estCostUsd: increment(estCostUsd), updatedAt: serverTimestamp() },\n platform: {\n day,\n estCostUsd: increment(estCostUsd),\n requests: increment(1),\n refusals: increment(refused ? 1 : 0),\n updatedAt: serverTimestamp(),\n },\n }\n}\n\n/**\n * After a free turn is metered: tell staff at 80% of the day's ceiling,\n * and record the pause at 100% — each ONCE per UTC day, deduplicated on\n * the day document itself inside a transaction, so two turns landing\n * together cannot both cross a threshold and both announce it.\n *\n * The refusal at the ceiling is NOT decided here; `freeTasteRefusal` reads\n * the figure inside every reservation and refuses on it directly. This\n * only says so out loud: the email, and an `adminAudit` row under\n * `platform.aiFreeSpend.paused` so the staff audit trail shows the day the\n * taste switched itself off. Both are best-effort and neither can fail the\n * turn that triggered them — the turn is already paid for.\n *\n * Returns what it announced, for the spec and for the log.\n */\nexport async function announcePlatformFreeSpend(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): Promise<{ alerted: boolean; paused: boolean; estCostUsd: number }> {\n const ceilingUsd = aiFreeDailyPlatformCeilingUsd()\n const ref = platformFreeSpendRef(firestore, day)\n const crossed = await firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(ref)\n const estCostUsd = Number(snapshot.get('estCostUsd') ?? 0)\n const alert =\n !snapshot.get('alertedAt') &&\n estCostUsd >= ceilingUsd * AI_FREE_PLATFORM_ALERT_SHARE\n const pause = !snapshot.get('pausedAt') && estCostUsd >= ceilingUsd\n if (alert || pause) {\n tx.set(\n ref,\n {\n ...(alert ? { alertedAt: FieldValue.serverTimestamp() } : {}),\n ...(pause ? { pausedAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n }\n return { alerted: alert, paused: pause, estCostUsd }\n })\n if (!crossed.alerted && !crossed.paused) return crossed\n\n const figure = `$${crossed.estCostUsd.toFixed(2)} of the $${ceilingUsd.toFixed(2)} ceiling`\n if (crossed.paused) {\n await addAdminAudit(firestore, {\n actorUid: 'system:ai-free-spend',\n action: 'platform.aiFreeSpend.paused',\n target: `${PLATFORM_AI_FREE_SPEND_COLLECTION}/${day}`,\n after: { day, estCostUsd: crossed.estCostUsd, ceilingUsd },\n at: FieldValue.serverTimestamp(),\n })\n .catch((error) =>\n console.error('[ai-free-spend] audit row failed', error),\n )\n }\n await sendStaffAlertEmail({\n subject: crossed.paused\n ? `Free AI generation paused for ${day}: ${figure}`\n : `Free AI spend at 80% of today's ceiling (${day}): ${figure}`,\n text: crossed.paused\n ? `Free-tier AI spend for ${day} (UTC) reached ${figure} ` +\n '(AI_FREE_DAILY_PLATFORM_CEILING_USD). Every Free workspace is ' +\n 'refused AI generation until the UTC day rolls; paid workspaces are ' +\n 'unaffected. If this is an abuse wave rather than organic use, the ' +\n '`ai-generate` feature lock on Staff → Lockdown stops generation ' +\n 'for everyone, and the assist signals page shows which workspaces ' +\n 'spent it.'\n : `Free-tier AI spend for ${day} (UTC) is at ${figure} ` +\n '(AI_FREE_DAILY_PLATFORM_CEILING_USD). At 100% every Free ' +\n 'workspace is refused AI generation until the day rolls. The ' +\n 'assist signals page shows the day so far; the `ai-generate` ' +\n 'feature lock on Staff → Lockdown is the manual stop.',\n url: `${platformConsoleOrigin()}/admin/lockdown`,\n urlLabel: 'Open Staff → Lockdown',\n context: 'ai-free-spend',\n })\n return crossed\n}\n\n/** The staff readout of today's free spend, for the assist signals page. */\nexport interface PlatformFreeSpendReadout {\n day: string\n estCostUsd: number\n requests: number\n refusals: number\n ceilingUsd: number\n /** True once the 80% mail went out today. */\n alerted: boolean\n /** True while free generation is refused for the day. */\n paused: boolean\n}\n\nexport async function readPlatformFreeSpend(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): Promise<PlatformFreeSpendReadout> {\n const snapshot = await platformFreeSpendRef(firestore, day).get()\n const ceilingUsd = aiFreeDailyPlatformCeilingUsd()\n const estCostUsd = Number(snapshot.get('estCostUsd') ?? 0)\n return {\n day,\n estCostUsd,\n requests: Number(snapshot.get('requests') ?? 0),\n refusals: Number(snapshot.get('refusals') ?? 0),\n ceilingUsd,\n alerted: Boolean(snapshot.get('alertedAt')),\n // The pause is the recorded decision OR the live figure: a day that\n // crossed the ceiling before anything announced it is paused all the\n // same, because the reservation refuses on the figure.\n paused: Boolean(snapshot.get('pausedAt')) || estCostUsd >= ceilingUsd,\n }\n}\n"],"names":["FieldValue","assistUsdFromCredits","resolveEffectivePlan","platformConsoleOrigin","FREE_AI_TASTE_CREDITS_PER_MONTH","ASSIST_RETURNED_USD_FIELD","assistSpendAfterReturnsUsd","sendStaffAlertEmail","addAdminAudit","FREE_AI_ACCOUNT_USAGE_COLLECTION","PLATFORM_AI_FREE_SPEND_COLLECTION","AI_FREE_REFUSALS_PER_DAY","AI_FREE_PLATFORM_ALERT_SHARE","FREE_AI_ACCOUNT_BUDGET_USD","aiFreeDailyRequests","raw","process","env","AI_FREE_DAILY_REQUESTS","parsed","Number","NaN","isFinite","Math","floor","aiFreeDailyPlatformCeilingUsd","String","AI_FREE_DAILY_PLATFORM_CEILING_USD","trim","freeAssistAccount","org","owner","ownerUid","creator","createdByUid","accountUid","freeAccountUsageRef","firestore","month","collection","doc","platformFreeSpendRef","day","freeTasteReadsFrom","account","platform","days","today","get","accountCostUsd","accountRequestsToday","requests","accountRefusalsToday","refusals","platformCostUsd","platformPaused","Boolean","freeTasteRefusal","reads","limits","dailyRequests","refusalsPerDay","accountBudgetUsd","platformCeilingUsd","freeAccountReservationWrite","increment","updatedAt","serverTimestamp","freeTasteMeterWrites","input","estCostUsd","refused","announcePlatformFreeSpend","ceilingUsd","ref","crossed","runTransaction","tx","snapshot","alert","pause","set","alertedAt","pausedAt","merge","alerted","paused","figure","toFixed","actorUid","action","target","after","at","catch","error","console","subject","text","url","urlLabel","context","readPlatformFreeSpend"],"mappings":"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"}
|