@aglyn/plugins-ai 1.0.0-beta.226 → 1.0.0-beta.228
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/README.md +3 -2
- package/package.json +17 -17
- package/src/lib/billing/ai-month-meter.js +5 -3
- package/src/lib/billing/ai-month-meter.js.map +1 -1
- package/src/lib/billing/ai-overage-ledger.js +3 -1
- package/src/lib/billing/ai-overage-ledger.js.map +1 -1
- package/src/lib/components/ai-brief-dialog.component.d.ts +6 -2
- package/src/lib/components/ai-brief-dialog.component.js +36 -18
- package/src/lib/components/ai-brief-dialog.component.js.map +1 -1
- package/src/lib/components/ai-describe-automation.component.js +24 -4
- package/src/lib/components/ai-describe-automation.component.js.map +1 -1
- package/src/lib/components/ai-describe-button.component.js +6 -1
- package/src/lib/components/ai-describe-button.component.js.map +1 -1
- package/src/lib/components/ai-describe-page.component.d.ts +1 -1
- package/src/lib/components/ai-describe-page.component.js +1 -1
- package/src/lib/components/ai-describe-page.component.js.map +1 -1
- package/src/lib/components/ai-insight-dialog.component.js +24 -2
- package/src/lib/components/ai-insight-dialog.component.js.map +1 -1
- package/src/lib/components/ai-job-events.d.ts +20 -0
- package/src/lib/components/ai-job-events.js +78 -0
- package/src/lib/components/ai-job-events.js.map +1 -0
- package/src/lib/components/ai-job-follow.component.d.ts +49 -0
- package/src/lib/components/ai-job-follow.component.js +210 -0
- package/src/lib/components/ai-job-follow.component.js.map +1 -0
- package/src/lib/components/ai-job-links.d.ts +42 -0
- package/src/lib/components/ai-job-links.js +140 -0
- package/src/lib/components/ai-job-links.js.map +1 -0
- package/src/lib/components/ai-job-plan.component.d.ts +13 -6
- package/src/lib/components/ai-job-plan.component.js +29 -3
- package/src/lib/components/ai-job-plan.component.js.map +1 -1
- package/src/lib/components/ai-job-requests.d.ts +24 -0
- package/src/lib/components/ai-job-requests.js +76 -0
- package/src/lib/components/ai-job-requests.js.map +1 -0
- package/src/lib/components/ai-job-site.d.ts +22 -0
- package/src/lib/components/ai-job-site.js +73 -0
- package/src/lib/components/ai-job-site.js.map +1 -0
- package/src/lib/components/ai-jobs-indicator.component.d.ts +25 -0
- package/src/lib/components/ai-jobs-indicator.component.js +80 -0
- package/src/lib/components/ai-jobs-indicator.component.js.map +1 -0
- package/src/lib/components/ai-jobs-list-page.component.d.ts +23 -0
- package/src/lib/components/ai-jobs-list-page.component.js +303 -0
- package/src/lib/components/ai-jobs-list-page.component.js.map +1 -0
- package/src/lib/components/ai-jobs-page.component.d.ts +7 -0
- package/src/lib/components/ai-jobs-page.component.js +30 -0
- package/src/lib/components/ai-jobs-page.component.js.map +1 -0
- package/src/lib/components/ai-jobs-store.d.ts +81 -0
- package/src/lib/components/ai-jobs-store.js +218 -0
- package/src/lib/components/ai-jobs-store.js.map +1 -0
- package/src/lib/components/ai-page-brief-dialog.component.d.ts +4 -4
- package/src/lib/components/ai-page-brief-dialog.component.js.map +1 -1
- package/src/lib/components/ai-site-build-page.component.d.ts +51 -0
- package/src/lib/components/ai-site-build-page.component.js +495 -0
- package/src/lib/components/ai-site-build-page.component.js.map +1 -0
- package/src/lib/components/ai-site-start-card.component.d.ts +9 -1
- package/src/lib/components/ai-site-start-card.component.js +533 -132
- package/src/lib/components/ai-site-start-card.component.js.map +1 -1
- package/src/lib/components/ai-site-starter-fallback.component.d.ts +24 -0
- package/src/lib/components/ai-site-starter-fallback.component.js +112 -0
- package/src/lib/components/ai-site-starter-fallback.component.js.map +1 -0
- package/src/lib/components/assist-jobs-drawer.component.d.ts +18 -20
- package/src/lib/components/assist-jobs-drawer.component.js +152 -172
- package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
- package/src/lib/components/assist-panel.component.js +69 -8
- package/src/lib/components/assist-panel.component.js.map +1 -1
- package/src/lib/components/staff-ai-credit-return.component.d.ts +55 -0
- package/src/lib/components/staff-ai-credit-return.component.js +346 -0
- package/src/lib/components/staff-ai-credit-return.component.js.map +1 -0
- package/src/lib/components/staff-org-ai-card.component.js +364 -279
- package/src/lib/components/staff-org-ai-card.component.js.map +1 -1
- package/src/lib/components/staff-user-ai-usage-card.component.d.ts +4 -0
- package/src/lib/components/staff-user-ai-usage-card.component.js +83 -28
- package/src/lib/components/staff-user-ai-usage-card.component.js.map +1 -1
- package/src/lib/components/use-ai-job-run.js +5 -18
- package/src/lib/components/use-ai-job-run.js.map +1 -1
- package/src/lib/jobs/ai-job-auto-confirm.d.ts +52 -0
- package/src/lib/jobs/ai-job-auto-confirm.js +62 -0
- package/src/lib/jobs/ai-job-auto-confirm.js.map +1 -0
- package/src/lib/jobs/ai-job-draft-ids.d.ts +6 -0
- package/src/lib/jobs/ai-job-draft-ids.js +9 -0
- package/src/lib/jobs/ai-job-draft-ids.js.map +1 -1
- package/src/lib/jobs/ai-job-drafts.d.ts +7 -0
- package/src/lib/jobs/ai-job-drafts.js +16 -7
- package/src/lib/jobs/ai-job-drafts.js.map +1 -1
- package/src/lib/jobs/ai-job-layout-step.js +2 -1
- package/src/lib/jobs/ai-job-layout-step.js.map +1 -1
- package/src/lib/jobs/ai-job-page-sections.d.ts +6 -5
- package/src/lib/jobs/ai-job-page-sections.js +3 -3
- package/src/lib/jobs/ai-job-page-sections.js.map +1 -1
- package/src/lib/jobs/ai-job-page-step.d.ts +19 -1
- package/src/lib/jobs/ai-job-page-step.js +50 -8
- package/src/lib/jobs/ai-job-page-step.js.map +1 -1
- package/src/lib/jobs/ai-job-plan-step.d.ts +33 -2
- package/src/lib/jobs/ai-job-plan-step.js +167 -18
- package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
- package/src/lib/jobs/ai-job-site-step.d.ts +28 -0
- package/src/lib/jobs/ai-job-site-step.js +130 -21
- package/src/lib/jobs/ai-job-site-step.js.map +1 -1
- package/src/lib/jobs/ai-job-text-step.d.ts +14 -1
- package/src/lib/jobs/ai-job-text-step.js.map +1 -1
- package/src/lib/jobs/ai-jobs-notify.d.ts +37 -0
- package/src/lib/jobs/ai-jobs-notify.js +51 -0
- package/src/lib/jobs/ai-jobs-notify.js.map +1 -0
- package/src/lib/jobs/ai-jobs.d.ts +39 -4
- package/src/lib/jobs/ai-jobs.js +205 -34
- package/src/lib/jobs/ai-jobs.js.map +1 -1
- package/src/lib/jobs/ai-site-publish.d.ts +98 -0
- package/src/lib/jobs/ai-site-publish.js +372 -0
- package/src/lib/jobs/ai-site-publish.js.map +1 -0
- package/src/lib/jobs/fixtures/ai-page-briefs.d.ts +7 -6
- package/src/lib/jobs/fixtures/ai-page-briefs.js +23 -56
- package/src/lib/jobs/fixtures/ai-page-briefs.js.map +1 -1
- package/src/lib/model/ai-job-activity.d.ts +112 -0
- package/src/lib/model/ai-job-activity.js +144 -0
- package/src/lib/model/ai-job-activity.js.map +1 -0
- package/src/lib/model/ai-job-failure-copy.d.ts +69 -0
- package/src/lib/model/ai-job-failure-copy.js +78 -0
- package/src/lib/model/ai-job-failure-copy.js.map +1 -0
- package/src/lib/model/ai-job-notice.d.ts +63 -0
- package/src/lib/model/ai-job-notice.js +129 -0
- package/src/lib/model/ai-job-notice.js.map +1 -0
- package/src/lib/model/ai-jobs.types.d.ts +53 -0
- package/src/lib/model/ai-jobs.types.js +1 -0
- package/src/lib/model/ai-jobs.types.js.map +1 -1
- package/src/lib/model/ai-plan-capabilities.d.ts +32 -10
- package/src/lib/model/ai-plan-capabilities.js +15 -2
- package/src/lib/model/ai-plan-capabilities.js.map +1 -1
- package/src/lib/model/ai-site-build-progress.d.ts +61 -0
- package/src/lib/model/ai-site-build-progress.js +136 -0
- package/src/lib/model/ai-site-build-progress.js.map +1 -0
- package/src/lib/model/ai-site-inventory.d.ts +9 -0
- package/src/lib/model/ai-site-inventory.js +7 -2
- package/src/lib/model/ai-site-inventory.js.map +1 -1
- package/src/lib/model/ai-site-job.d.ts +93 -2
- package/src/lib/model/ai-site-job.js +88 -7
- package/src/lib/model/ai-site-job.js.map +1 -1
- package/src/lib/model/ai-site-start-seo.d.ts +19 -0
- package/src/lib/model/ai-site-start-seo.js +91 -3
- package/src/lib/model/ai-site-start-seo.js.map +1 -1
- package/src/lib/model/ai-site-start.d.ts +3 -1
- package/src/lib/model/ai-site-start.js +6 -4
- package/src/lib/model/ai-site-start.js.map +1 -1
- package/src/lib/plan-entitlements.d.ts +3 -3
- package/src/lib/plan-entitlements.js +3 -3
- package/src/lib/plan-entitlements.js.map +1 -1
- package/src/lib/plugin.js +38 -2
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/runtime/ai-abuse-guards.d.ts +9 -6
- package/src/lib/runtime/ai-abuse-guards.js +10 -7
- package/src/lib/runtime/ai-abuse-guards.js.map +1 -1
- package/src/lib/runtime/ai-doctrine-validators.d.ts +16 -12
- package/src/lib/runtime/ai-doctrine-validators.js +50 -40
- package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
- package/src/lib/runtime/ai-doctrine.js +1 -1
- package/src/lib/runtime/ai-doctrine.js.map +1 -1
- package/src/lib/runtime/ai-eval-live.d.ts +1 -1
- package/src/lib/runtime/ai-eval-live.js +1 -1
- package/src/lib/runtime/ai-eval-live.js.map +1 -1
- package/src/lib/runtime/ai-gate.d.ts +3 -2
- package/src/lib/runtime/ai-gate.js +3 -2
- package/src/lib/runtime/ai-gate.js.map +1 -1
- package/src/lib/runtime/site-inventory.d.ts +16 -1
- package/src/lib/runtime/site-inventory.js +43 -3
- package/src/lib/runtime/site-inventory.js.map +1 -1
- package/src/lib/server/ai-admin-credits.d.ts +21 -0
- package/src/lib/server/ai-admin-credits.js +213 -0
- package/src/lib/server/ai-admin-credits.js.map +1 -0
- package/src/lib/server/ai-admin-org.js +12 -5
- package/src/lib/server/ai-admin-org.js.map +1 -1
- package/src/lib/server/ai-admin-signals.js +3 -1
- package/src/lib/server/ai-admin-signals.js.map +1 -1
- package/src/lib/server/ai-admin-user.js +12 -1
- package/src/lib/server/ai-admin-user.js.map +1 -1
- package/src/lib/server/ai-allotments.js +3 -2
- package/src/lib/server/ai-allotments.js.map +1 -1
- package/src/lib/server/ai-jobs-live-sites.d.ts +26 -0
- package/src/lib/server/ai-jobs-live-sites.js +58 -0
- package/src/lib/server/ai-jobs-live-sites.js.map +1 -0
- package/src/lib/server/ai-jobs-route.js +42 -5
- package/src/lib/server/ai-jobs-route.js.map +1 -1
- package/src/lib/server/assist-chat.js +3 -2
- package/src/lib/server/assist-chat.js.map +1 -1
- package/src/lib/server/billing-credits.js +4 -3
- package/src/lib/server/billing-credits.js.map +1 -1
- package/src/lib/server/billing-overage.js +3 -2
- package/src/lib/server/billing-overage.js.map +1 -1
- package/src/lib/server.js +11 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/usage/assist-credit-returns-write.d.ts +75 -0
- package/src/lib/usage/assist-credit-returns-write.js +150 -0
- package/src/lib/usage/assist-credit-returns-write.js.map +1 -0
- package/src/lib/usage/assist-credit-returns.d.ts +68 -0
- package/src/lib/usage/assist-credit-returns.js +79 -0
- package/src/lib/usage/assist-credit-returns.js.map +1 -0
- package/src/lib/usage/assist-free-taste.js +6 -3
- package/src/lib/usage/assist-free-taste.js.map +1 -1
- package/src/lib/usage/assist-job-refund.d.ts +100 -0
- package/src/lib/usage/assist-job-refund.js +109 -0
- package/src/lib/usage/assist-job-refund.js.map +1 -0
- package/src/lib/usage/assist-usage.js +5 -2
- package/src/lib/usage/assist-usage.js.map +1 -1
- package/src/lib/usage/staff-org-ai.d.ts +31 -1
- package/src/lib/usage/staff-org-ai.js +19 -2
- package/src/lib/usage/staff-org-ai.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/ai-gate.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { DecodedIdToken } from 'firebase-admin/auth'\nimport {\n checkEntitlement,\n type LockdownFeatureKey,\n type ReleaseFlagKey,\n} from '@aglyn/aglyn/server'\nimport {\n assistFreeTasteRefusalText,\n assistHardCapRefusalText,\n assistRefusedByHardCap,\n} from '../usage/assist-credits'\nimport type { AglynOrganization } from '@aglyn/aglyn/foundation/definitions/organization.types'\nimport type { OrgFeatureFlags } from '@aglyn/aglyn/foundation/definitions/org-billing.types'\nimport {\n checkAiClientIpRateLimit,\n freeAccountAgeRefusal,\n} from './ai-abuse-guards'\nimport {\n checkRateLimit,\n rateLimitHeaders,\n type RateLimitResult,\n} from '@aglyn/tenant-data-admin/server/api-http'\nimport { aiOverageReservationRefusal } from '../billing/ai-overage-gate'\nimport { recordUserAiRefusal } from '../usage/ai-usage-by-user'\nimport { authForPool } from '@aglyn/tenant-data-admin/server/auth-pools'\nimport {\n publicAssistQuota,\n reserveAssistMessage,\n type AssistReservation,\n} from '../usage/assist-usage'\nimport { aiAllotmentRefusalText } from '../model/ai-allotments'\nimport { aiUsageMeter } from '../usage/ai-usage-meter'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { featureLockdownRefusal, lockdownRefusal } from '@aglyn/tenant-data-admin/server/lockdown'\nimport {\n permissionRefusal,\n getOrgForUser,\n memberHasPermissionOnHost,\n} from '@aglyn/tenant-data-admin/server/organizations'\nimport { isServerReleaseFlagOnForOrg } from '@aglyn/tenant-data-admin/server/release-flags'\n\n/**\n * The gate ladder every AI door climbs before it spends a token (AGL-2903).\n *\n * The rungs, in order — each one a refusal the door sends as is, and each\n * composed from the helper the existing doors already call rather than a\n * second implementation of it:\n *\n * 405 not POST\n * 401 no bearer token, or a token the verifier refused\n * 403 email unverified (an impersonation session is exempt)\n * 400 the body did not NAME the org it is metered against (AGL-1934)\n * 403 the caller is not a member of that org\n * 403 the member's role lacks the door's AI permission (AGL-2927) — only\n * when the door names one; a verified staff claim passes, as it does\n * at every other org route\n * 404 the release flag is off — a released-off feature does not exist;\n * a verified staff claim previews through it\n * 403 the org's plan lacks the entitlement — a plan-less org resolves as\n * free, which is a real answer, never a loading default\n * 423 lockdown: the scope verdict (platform/org/user) and then the\n * feature kill switch, with its per-feature staff bypass\n * 429 the per-uid rate limit, which fails SOFT: it is a per-instance\n * window that smooths bursts and is not the spend bound\n * 429 the per-address rate limit (AGL-2925), the same window keyed on\n * the trusted-hop client address, so a farm rotating accounts\n * behind one address meets one budget; skipped when no address is\n * readable\n * 403 account age (AGL-2925): a Free workspace's caller must hold an\n * account older than `AI_FREE_MIN_ACCOUNT_AGE_HOURS`; paid\n * workspaces and staff never consult it, and a record that cannot\n * be read is a 503 rather than an admission\n * --- the reservation, which fails CLOSED: it is the only global, atomic\n * bound on provider spend, so a reservation that cannot be taken\n * refuses with 503 rather than calling the provider uncapped. On a\n * Free workspace it also decides the taste's own rungs — the\n * account's daily requests, its refusal pause, its monthly\n * allowance across every workspace it owns, and the platform-wide\n * daily ceiling — inside the same transaction\n * 429 a hard AI allotment (AGL-2942) — the caller's, the caller's on the\n * named site, or the site's — decided inside the same transaction\n * once the workspace's own ceilings admitted the request, so it sits\n * inside the band and never admits what the band refused\n *\n * A door that gets a context back has a reservation in hand and owes the\n * meter a record — or `releaseAssistMessage` if the provider was never\n * reached. What differs between doors is parameters: which entitlement,\n * which flag, which lockdown key, which rate window.\n *\n * The console chat route keeps its own copy of these rungs rather than\n * calling this: its retrieval-first design (AGL-2486) answers from the\n * docs BETWEEN the rate limit and the reservation, so its ladder is not a\n * prefix of this one, and it resolves the entitlement after the rate limit\n * because a free workspace is admitted in limited mode rather than refused.\n * The copy assistant differs the same way at the entitlement and carries no\n * flag by design. Both are documented at the route.\n */\nexport interface AiGateConfig {\n /** The plan entitlement the door sells under (`checkEntitlement`). */\n feature: keyof OrgFeatureFlags\n /** The release flag that makes the door exist. */\n releaseFlag: ReleaseFlagKey\n /** The feature kill switch on the staff lockdown page. */\n lockdownFeature: LockdownFeatureKey\n /**\n * The org permission the door sells under (AGL-2927): `ai.use` for the\n * assistants, `ai.generate` for a generation job. Resolved by\n * `memberHasPermissionOnHost` on the org axis or, for a site collaborator, on\n * the host the body named (`AiGateInput.hostId`). Omitted, the rung is\n * skipped — which is the right reading for a door that has not decided\n * which key it sells under, and the wrong one for any door a customer\n * can reach, so every shipped door names one.\n */\n permission?: 'ai.use' | 'ai.generate'\n rateLimit: {\n /** Prefix of the per-uid key, so two doors do not share one window. */\n key: string\n limit: number\n windowMs: number\n }\n}\n\nexport interface AiGateInput {\n request: Request\n /** The org the parsed body named; empty when it named none. */\n orgId: string\n /**\n * The site the parsed body named, when it named one. A collaborator's\n * permission is decided on this site; an org-wide member's is the same\n * everywhere, so a door on an org-level surface may leave it empty.\n */\n hostId?: string | null\n now?: Date\n}\n\n/** Everything a door needs after the ladder admitted the request. */\nexport interface AiGateContext {\n uid: string\n decoded: DecodedIdToken\n staff: boolean\n orgId: string\n org: Partial<AglynOrganization>\n firestore: FirebaseFirestore.Firestore\n rate: RateLimitResult\n reservation: AssistReservation\n}\n\nfunction bearerToken(request: Request): string | undefined {\n const authorization = request.headers.get('authorization') ?? ''\n return authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n}\n\n/**\n * Climb the ladder. Resolves to the `Response` to send when a rung\n * refuses, or to the resolved context when every rung admitted the request.\n */\nexport async function aiGateLadder(\n input: AiGateInput,\n config: AiGateConfig,\n): Promise<Response | AiGateContext> {\n const { request } = input\n if (request.method !== 'POST') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const idToken = bearerToken(request)\n if (!idToken) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n\n const app = firebaseAdmin.app()\n let decoded: DecodedIdToken\n try {\n decoded = await app.auth().verifyIdToken(idToken)\n } catch (error) {\n // A refused credential is the caller's fault and a 401; anything else\n // — the verifier's own key endpoint unreachable — is ours, and it\n // propagates so the door answers 500 rather than telling every user\n // their credential is bad during an outage (AGL-1993).\n if (isRefusedIdToken(error)) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n throw error\n }\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n const staff = decoded['staff'] === true\n\n const orgId = input.orgId.trim()\n if (!orgId) {\n return Response.json(\n { error: 'Open a workspace before using this feature' },\n { status: 400 },\n )\n }\n\n // Scoped to the NAMED org: membership of some other paid org is not a key\n // to this one, and the org this returns is the one every later rung and\n // the meter read.\n const resolved = await getOrgForUser(decoded.uid, orgId)\n if (!resolved || resolved.orgId !== orgId) {\n return Response.json(\n { error: 'You are not a member of that organization' },\n { status: 403 },\n )\n }\n const org = resolved.org ?? {}\n\n // Permission (AGL-2927), directly after membership: it is a fact about\n // the CALLER, so it is answered before anything about the workspace —\n // the plan, the lockdown state, the quota — is disclosed. Staff pass, as\n // they do at every other org route.\n if (\n config.permission &&\n !staff &&\n !(await memberHasPermissionOnHost(\n orgId,\n input.hostId,\n resolved.member,\n config.permission,\n ))\n ) {\n return permissionRefusal(config.permission)\n }\n\n // The flag closes the ROUTE, not just the UI (AGL-1653): a released-off\n // feature does not exist, so nothing below this line — not the plan, not\n // the lockdown state — is disclosed while it is off.\n if (!staff && !(await isServerReleaseFlagOnForOrg(config.releaseFlag, orgId))) {\n return Response.json({ error: 'Not found' }, { status: 404 })\n }\n\n if (!checkEntitlement(org, config.feature)) {\n return Response.json(\n {\n error: \"This workspace's plan does not include that feature\",\n reason: 'entitlement',\n },\n { status: 403 },\n )\n }\n\n const locked = await lockdownRefusal({\n request,\n staff,\n uid: decoded.uid,\n org: org as Record<string, unknown>,\n })\n if (locked) return locked\n const featureLocked = await featureLockdownRefusal({\n feature: config.lockdownFeature,\n staff,\n // The workspace-scoped pause on the same key (AGL-2927): the staff org\n // page's spend stop, which leaves the entitlement in place.\n orgId,\n })\n if (featureLocked) return featureLocked\n\n const rate = checkRateLimit(`${config.rateLimit.key}:${decoded.uid}`, {\n limit: config.rateLimit.limit,\n windowMs: config.rateLimit.windowMs,\n })\n if (!rate.allowed) {\n return Response.json(\n { error: 'Too many requests — slow down a moment', reason: 'rate' },\n { status: 429, headers: rateLimitHeaders(rate) },\n )\n }\n // The same window keyed on the address (AGL-2925): a farm rotating\n // fresh accounts behind one NAT meets one budget rather than one per\n // account. Answered with the same status and reason so the panel's\n // handling is unchanged.\n const ipRate = checkAiClientIpRateLimit(request.headers)\n if (ipRate && !ipRate.allowed) {\n return Response.json(\n { error: 'Too many requests — slow down a moment', reason: 'rate' },\n { status: 429, headers: rateLimitHeaders(ipRate) },\n )\n }\n\n // A Free workspace's caller must have held an account for a day\n // (AGL-2925). Read from the pool the token was minted in — a tenant\n // user's record is not in the project pool — and after the rate limits,\n // so a burst cannot turn one cached read into many.\n const tooYoung = await freeAccountAgeRefusal({\n uid: decoded.uid,\n org,\n staff,\n getUser: (uid) => authForPool(decoded.firebase?.tenant).getUser(uid),\n now: input.now,\n })\n if (tooYoung) return tooYoung\n\n // RESERVED, not merely checked (AGL-2057/2073): read-and-increment in one\n // transaction BEFORE a token is spent, so N concurrent requests cannot all\n // read \"under the cap\", and a caller that hangs up has been counted. The\n // org document is passed so the plan's own band binds, not only the\n // operator backstop.\n const firestore = app.firestore()\n let reservation: AssistReservation\n try {\n reservation = await reserveAssistMessage(\n firestore,\n orgId,\n true,\n input.now ?? new Date(),\n org,\n // Who is asking and on which site, for the allotments that apply.\n { uid: decoded.uid, hostId: input.hostId ?? null },\n )\n } catch (error) {\n console.error('ai reservation failed', error)\n return Response.json(\n { error: 'This feature is temporarily unavailable' },\n { status: 503 },\n )\n }\n // A refusal is the caller's as well as the workspace's (AGL-2928): the\n // per-person month counts it beside the org counter the reservation moved.\n recordUserAiRefusal(firestore, orgId, decoded.uid, reservation)\n if (!reservation.allowed) {\n const refusedBy = reservation.refusedBy\n // The usage strip's envelope (AGL-2942), on the refusal as on an answer,\n // so the strip shows why the request stopped without a read of its own.\n const meter = aiUsageMeter(reservation)\n // A hard allotment is a monthly line a manager drew inside the band: a\n // 429 like the message cap, because it resets on the calendar, with the\n // sentence naming who can raise it.\n if (refusedBy === 'allotment') {\n return Response.json(\n {\n error: aiAllotmentRefusalText(reservation.allotment?.refusal?.scope),\n reason: 'quota',\n refusedBy,\n quota: publicAssistQuota(reservation),\n meter,\n },\n { status: 429 },\n )\n }\n // AGLYN'S OWN OVERAGE GUARDS (AGL-3011), before the workspace's own\n // controls because they are the narrower answer: a refusal here already\n // knows WHICH limit stopped the request and whether the workspace can do\n // anything about it. Everything else falls through unchanged.\n const overage = aiOverageReservationRefusal(reservation)\n if (overage) {\n return Response.json(\n {\n error: overage.text,\n reason: 'quota',\n refusedBy,\n quota: publicAssistQuota(reservation),\n meter,\n },\n { status: overage.status },\n )\n }\n // The org's own wall is a 402 (AGL-2653): credits past the band are for\n // sale and this workspace switched the sale off, so the sentence names\n // the switch. A spend ceiling and a message cap keep the 429, and are\n // told apart in words because one resets on a clock and one does not.\n const hardCapped = assistRefusedByHardCap(org, refusedBy)\n return Response.json(\n {\n error: hardCapped\n ? assistHardCapRefusalText(org)\n : // The Free taste's own precautions (AGL-2925) have their own\n // sentences: each names a clock or an upgrade, never a figure.\n (assistFreeTasteRefusalText(refusedBy) ??\n (refusedBy === 'budget' || refusedBy === 'band'\n ? reservation.budgetUsd === null\n ? 'This workspace reached its AI spending limit for the month'\n : 'This workspace used its AI credits for the month'\n : 'This workspace reached its AI limit for the month')),\n reason: 'quota',\n // CREDITS, never the reservation itself — its figures are our\n // provider bill and do not leave the server.\n quota: publicAssistQuota(reservation),\n meter,\n },\n { status: hardCapped ? 402 : 429 },\n )\n }\n\n return {\n uid: decoded.uid,\n decoded,\n staff,\n orgId,\n org,\n firestore,\n rate,\n reservation,\n }\n}\n"],"names":["checkEntitlement","assistFreeTasteRefusalText","assistHardCapRefusalText","assistRefusedByHardCap","checkAiClientIpRateLimit","freeAccountAgeRefusal","checkRateLimit","rateLimitHeaders","aiOverageReservationRefusal","recordUserAiRefusal","authForPool","publicAssistQuota","reserveAssistMessage","aiAllotmentRefusalText","aiUsageMeter","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","isRefusedIdToken","featureLockdownRefusal","lockdownRefusal","permissionRefusal","getOrgForUser","memberHasPermissionOnHost","isServerReleaseFlagOnForOrg","bearerToken","request","authorization","headers","get","startsWith","slice","length","undefined","aiGateLadder","input","config","resolved","method","Response","json","error","status","idToken","app","decoded","auth","verifyIdToken","email_verified","staff","orgId","trim","uid","org","permission","hostId","member","releaseFlag","feature","reason","locked","featureLocked","lockdownFeature","rate","rateLimit","key","limit","windowMs","allowed","ipRate","tooYoung","getUser","firebase","tenant","now","firestore","reservation","Date","console","refusedBy","meter","allotment","refusal","scope","quota","overage","text","hardCapped","budgetUsd"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/ai-gate.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { DecodedIdToken } from 'firebase-admin/auth'\nimport {\n checkEntitlement,\n type LockdownFeatureKey,\n type ReleaseFlagKey,\n} from '@aglyn/aglyn/server'\nimport {\n assistFreeTasteRefusalText,\n assistHardCapRefusalText,\n assistRefusedByHardCap,\n} from '../usage/assist-credits'\nimport type { AglynOrganization } from '@aglyn/aglyn/foundation/definitions/organization.types'\nimport type { OrgFeatureFlags } from '@aglyn/aglyn/foundation/definitions/org-billing.types'\nimport {\n checkAiClientIpRateLimit,\n freeAccountAgeRefusal,\n} from './ai-abuse-guards'\nimport {\n checkRateLimit,\n rateLimitHeaders,\n type RateLimitResult,\n} from '@aglyn/tenant-data-admin/server/api-http'\nimport { aiOverageReservationRefusal } from '../billing/ai-overage-gate'\nimport { recordUserAiRefusal } from '../usage/ai-usage-by-user'\nimport { authForPool } from '@aglyn/tenant-data-admin/server/auth-pools'\nimport {\n publicAssistQuota,\n reserveAssistMessage,\n type AssistReservation,\n} from '../usage/assist-usage'\nimport { aiAllotmentRefusalText } from '../model/ai-allotments'\nimport { aiUsageMeter } from '../usage/ai-usage-meter'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { featureLockdownRefusal, lockdownRefusal } from '@aglyn/tenant-data-admin/server/lockdown'\nimport {\n permissionRefusal,\n getOrgForUser,\n memberHasPermissionOnHost,\n} from '@aglyn/tenant-data-admin/server/organizations'\nimport { isServerReleaseFlagOnForOrg } from '@aglyn/tenant-data-admin/server/release-flags'\n\n/**\n * The gate ladder every AI door climbs before it spends a token (AGL-2903).\n *\n * The rungs, in order — each one a refusal the door sends as is, and each\n * composed from the helper the existing doors already call rather than a\n * second implementation of it:\n *\n * 405 not POST\n * 401 no bearer token, or a token the verifier refused\n * 403 email unverified (an impersonation session is exempt)\n * 400 the body did not NAME the org it is metered against (AGL-1934)\n * 403 the caller is not a member of that org\n * 403 the member's role lacks the door's AI permission (AGL-2927) — only\n * when the door names one; a verified staff claim passes, as it does\n * at every other org route\n * 404 the release flag is off — a released-off feature does not exist;\n * a verified staff claim previews through it\n * 403 the org's plan lacks the entitlement — a plan-less org resolves as\n * free, which is a real answer, never a loading default\n * 423 lockdown: the scope verdict (platform/org/user) and then the\n * feature kill switch, with its per-feature staff bypass\n * 429 the per-uid rate limit, which fails SOFT: it is a per-instance\n * window that smooths bursts and is not the spend bound\n * 429 the per-address rate limit (AGL-2925), the same window keyed on\n * the trusted-hop client address, so a farm rotating accounts\n * behind one address meets one budget; skipped when no address is\n * readable\n * 403 account age (AGL-2925), off by default (AGL-3591): when\n * `AI_FREE_MIN_ACCOUNT_AGE_HOURS` is set, a Free workspace's caller\n * must hold an account at least that old; paid\n * workspaces and staff never consult it, and a record that cannot\n * be read is a 503 rather than an admission\n * --- the reservation, which fails CLOSED: it is the only global, atomic\n * bound on provider spend, so a reservation that cannot be taken\n * refuses with 503 rather than calling the provider uncapped. On a\n * Free workspace it also decides the taste's own rungs — the\n * account's daily requests, its refusal pause, its monthly\n * allowance across every workspace it owns, and the platform-wide\n * daily ceiling — inside the same transaction\n * 429 a hard AI allotment (AGL-2942) — the caller's, the caller's on the\n * named site, or the site's — decided inside the same transaction\n * once the workspace's own ceilings admitted the request, so it sits\n * inside the band and never admits what the band refused\n *\n * A door that gets a context back has a reservation in hand and owes the\n * meter a record — or `releaseAssistMessage` if the provider was never\n * reached. What differs between doors is parameters: which entitlement,\n * which flag, which lockdown key, which rate window.\n *\n * The console chat route keeps its own copy of these rungs rather than\n * calling this: its retrieval-first design (AGL-2486) answers from the\n * docs BETWEEN the rate limit and the reservation, so its ladder is not a\n * prefix of this one, and it resolves the entitlement after the rate limit\n * because a free workspace is admitted in limited mode rather than refused.\n * The copy assistant differs the same way at the entitlement and carries no\n * flag by design. Both are documented at the route.\n */\nexport interface AiGateConfig {\n /** The plan entitlement the door sells under (`checkEntitlement`). */\n feature: keyof OrgFeatureFlags\n /** The release flag that makes the door exist. */\n releaseFlag: ReleaseFlagKey\n /** The feature kill switch on the staff lockdown page. */\n lockdownFeature: LockdownFeatureKey\n /**\n * The org permission the door sells under (AGL-2927): `ai.use` for the\n * assistants, `ai.generate` for a generation job. Resolved by\n * `memberHasPermissionOnHost` on the org axis or, for a site collaborator, on\n * the host the body named (`AiGateInput.hostId`). Omitted, the rung is\n * skipped — which is the right reading for a door that has not decided\n * which key it sells under, and the wrong one for any door a customer\n * can reach, so every shipped door names one.\n */\n permission?: 'ai.use' | 'ai.generate'\n rateLimit: {\n /** Prefix of the per-uid key, so two doors do not share one window. */\n key: string\n limit: number\n windowMs: number\n }\n}\n\nexport interface AiGateInput {\n request: Request\n /** The org the parsed body named; empty when it named none. */\n orgId: string\n /**\n * The site the parsed body named, when it named one. A collaborator's\n * permission is decided on this site; an org-wide member's is the same\n * everywhere, so a door on an org-level surface may leave it empty.\n */\n hostId?: string | null\n now?: Date\n}\n\n/** Everything a door needs after the ladder admitted the request. */\nexport interface AiGateContext {\n uid: string\n decoded: DecodedIdToken\n staff: boolean\n orgId: string\n org: Partial<AglynOrganization>\n firestore: FirebaseFirestore.Firestore\n rate: RateLimitResult\n reservation: AssistReservation\n}\n\nfunction bearerToken(request: Request): string | undefined {\n const authorization = request.headers.get('authorization') ?? ''\n return authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n}\n\n/**\n * Climb the ladder. Resolves to the `Response` to send when a rung\n * refuses, or to the resolved context when every rung admitted the request.\n */\nexport async function aiGateLadder(\n input: AiGateInput,\n config: AiGateConfig,\n): Promise<Response | AiGateContext> {\n const { request } = input\n if (request.method !== 'POST') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const idToken = bearerToken(request)\n if (!idToken) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n\n const app = firebaseAdmin.app()\n let decoded: DecodedIdToken\n try {\n decoded = await app.auth().verifyIdToken(idToken)\n } catch (error) {\n // A refused credential is the caller's fault and a 401; anything else\n // — the verifier's own key endpoint unreachable — is ours, and it\n // propagates so the door answers 500 rather than telling every user\n // their credential is bad during an outage (AGL-1993).\n if (isRefusedIdToken(error)) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n throw error\n }\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n const staff = decoded['staff'] === true\n\n const orgId = input.orgId.trim()\n if (!orgId) {\n return Response.json(\n { error: 'Open a workspace before using this feature' },\n { status: 400 },\n )\n }\n\n // Scoped to the NAMED org: membership of some other paid org is not a key\n // to this one, and the org this returns is the one every later rung and\n // the meter read.\n const resolved = await getOrgForUser(decoded.uid, orgId)\n if (!resolved || resolved.orgId !== orgId) {\n return Response.json(\n { error: 'You are not a member of that organization' },\n { status: 403 },\n )\n }\n const org = resolved.org ?? {}\n\n // Permission (AGL-2927), directly after membership: it is a fact about\n // the CALLER, so it is answered before anything about the workspace —\n // the plan, the lockdown state, the quota — is disclosed. Staff pass, as\n // they do at every other org route.\n if (\n config.permission &&\n !staff &&\n !(await memberHasPermissionOnHost(\n orgId,\n input.hostId,\n resolved.member,\n config.permission,\n ))\n ) {\n return permissionRefusal(config.permission)\n }\n\n // The flag closes the ROUTE, not just the UI (AGL-1653): a released-off\n // feature does not exist, so nothing below this line — not the plan, not\n // the lockdown state — is disclosed while it is off.\n if (!staff && !(await isServerReleaseFlagOnForOrg(config.releaseFlag, orgId))) {\n return Response.json({ error: 'Not found' }, { status: 404 })\n }\n\n if (!checkEntitlement(org, config.feature)) {\n return Response.json(\n {\n error: \"This workspace's plan does not include that feature\",\n reason: 'entitlement',\n },\n { status: 403 },\n )\n }\n\n const locked = await lockdownRefusal({\n request,\n staff,\n uid: decoded.uid,\n org: org as Record<string, unknown>,\n })\n if (locked) return locked\n const featureLocked = await featureLockdownRefusal({\n feature: config.lockdownFeature,\n staff,\n // The workspace-scoped pause on the same key (AGL-2927): the staff org\n // page's spend stop, which leaves the entitlement in place.\n orgId,\n })\n if (featureLocked) return featureLocked\n\n const rate = checkRateLimit(`${config.rateLimit.key}:${decoded.uid}`, {\n limit: config.rateLimit.limit,\n windowMs: config.rateLimit.windowMs,\n })\n if (!rate.allowed) {\n return Response.json(\n { error: 'Too many requests — slow down a moment', reason: 'rate' },\n { status: 429, headers: rateLimitHeaders(rate) },\n )\n }\n // The same window keyed on the address (AGL-2925): a farm rotating\n // fresh accounts behind one NAT meets one budget rather than one per\n // account. Answered with the same status and reason so the panel's\n // handling is unchanged.\n const ipRate = checkAiClientIpRateLimit(request.headers)\n if (ipRate && !ipRate.allowed) {\n return Response.json(\n { error: 'Too many requests — slow down a moment', reason: 'rate' },\n { status: 429, headers: rateLimitHeaders(ipRate) },\n )\n }\n\n // A Free workspace's caller must have held an account for\n // `AI_FREE_MIN_ACCOUNT_AGE_HOURS` (AGL-2925) — zero, so off, unless a\n // deployment sets it (AGL-3591). Read from the pool the token was minted in — a tenant\n // user's record is not in the project pool — and after the rate limits,\n // so a burst cannot turn one cached read into many.\n const tooYoung = await freeAccountAgeRefusal({\n uid: decoded.uid,\n org,\n staff,\n getUser: (uid) => authForPool(decoded.firebase?.tenant).getUser(uid),\n now: input.now,\n })\n if (tooYoung) return tooYoung\n\n // RESERVED, not merely checked (AGL-2057/2073): read-and-increment in one\n // transaction BEFORE a token is spent, so N concurrent requests cannot all\n // read \"under the cap\", and a caller that hangs up has been counted. The\n // org document is passed so the plan's own band binds, not only the\n // operator backstop.\n const firestore = app.firestore()\n let reservation: AssistReservation\n try {\n reservation = await reserveAssistMessage(\n firestore,\n orgId,\n true,\n input.now ?? new Date(),\n org,\n // Who is asking and on which site, for the allotments that apply.\n { uid: decoded.uid, hostId: input.hostId ?? null },\n )\n } catch (error) {\n console.error('ai reservation failed', error)\n return Response.json(\n { error: 'This feature is temporarily unavailable' },\n { status: 503 },\n )\n }\n // A refusal is the caller's as well as the workspace's (AGL-2928): the\n // per-person month counts it beside the org counter the reservation moved.\n recordUserAiRefusal(firestore, orgId, decoded.uid, reservation)\n if (!reservation.allowed) {\n const refusedBy = reservation.refusedBy\n // The usage strip's envelope (AGL-2942), on the refusal as on an answer,\n // so the strip shows why the request stopped without a read of its own.\n const meter = aiUsageMeter(reservation)\n // A hard allotment is a monthly line a manager drew inside the band: a\n // 429 like the message cap, because it resets on the calendar, with the\n // sentence naming who can raise it.\n if (refusedBy === 'allotment') {\n return Response.json(\n {\n error: aiAllotmentRefusalText(reservation.allotment?.refusal?.scope),\n reason: 'quota',\n refusedBy,\n quota: publicAssistQuota(reservation),\n meter,\n },\n { status: 429 },\n )\n }\n // AGLYN'S OWN OVERAGE GUARDS (AGL-3011), before the workspace's own\n // controls because they are the narrower answer: a refusal here already\n // knows WHICH limit stopped the request and whether the workspace can do\n // anything about it. Everything else falls through unchanged.\n const overage = aiOverageReservationRefusal(reservation)\n if (overage) {\n return Response.json(\n {\n error: overage.text,\n reason: 'quota',\n refusedBy,\n quota: publicAssistQuota(reservation),\n meter,\n },\n { status: overage.status },\n )\n }\n // The org's own wall is a 402 (AGL-2653): credits past the band are for\n // sale and this workspace switched the sale off, so the sentence names\n // the switch. A spend ceiling and a message cap keep the 429, and are\n // told apart in words because one resets on a clock and one does not.\n const hardCapped = assistRefusedByHardCap(org, refusedBy)\n return Response.json(\n {\n error: hardCapped\n ? assistHardCapRefusalText(org)\n : // The Free taste's own precautions (AGL-2925) have their own\n // sentences: each names a clock or an upgrade, never a figure.\n (assistFreeTasteRefusalText(refusedBy) ??\n (refusedBy === 'budget' || refusedBy === 'band'\n ? reservation.budgetUsd === null\n ? 'This workspace reached its AI spending limit for the month'\n : 'This workspace used its AI credits for the month'\n : 'This workspace reached its AI limit for the month')),\n reason: 'quota',\n // CREDITS, never the reservation itself — its figures are our\n // provider bill and do not leave the server.\n quota: publicAssistQuota(reservation),\n meter,\n },\n { status: hardCapped ? 402 : 429 },\n )\n }\n\n return {\n uid: decoded.uid,\n decoded,\n staff,\n orgId,\n org,\n firestore,\n rate,\n reservation,\n }\n}\n"],"names":["checkEntitlement","assistFreeTasteRefusalText","assistHardCapRefusalText","assistRefusedByHardCap","checkAiClientIpRateLimit","freeAccountAgeRefusal","checkRateLimit","rateLimitHeaders","aiOverageReservationRefusal","recordUserAiRefusal","authForPool","publicAssistQuota","reserveAssistMessage","aiAllotmentRefusalText","aiUsageMeter","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","isRefusedIdToken","featureLockdownRefusal","lockdownRefusal","permissionRefusal","getOrgForUser","memberHasPermissionOnHost","isServerReleaseFlagOnForOrg","bearerToken","request","authorization","headers","get","startsWith","slice","length","undefined","aiGateLadder","input","config","resolved","method","Response","json","error","status","idToken","app","decoded","auth","verifyIdToken","email_verified","staff","orgId","trim","uid","org","permission","hostId","member","releaseFlag","feature","reason","locked","featureLocked","lockdownFeature","rate","rateLimit","key","limit","windowMs","allowed","ipRate","tooYoung","getUser","firebase","tenant","now","firestore","reservation","Date","console","refusedBy","meter","allotment","refusal","scope","quota","overage","text","hardCapped","budgetUsd"],"mappings":"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"}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
-
import { type AiInventoryTheme, type AiSiteInventory } from '../model/ai-site-inventory';
|
|
17
|
+
import { type AiInventoryScreen, type AiInventoryTheme, type AiSiteInventory } from '../model/ai-site-inventory';
|
|
18
18
|
/**
|
|
19
19
|
* The reader behind the site inventory (AGL-2935): one site's components,
|
|
20
20
|
* layouts, templates, forms, datasets, content collections, screens and
|
|
@@ -46,6 +46,21 @@ export interface ReadSiteInventoryOptions {
|
|
|
46
46
|
}
|
|
47
47
|
/** The theme a site renders with, reduced to what a plan and an email can act on. */
|
|
48
48
|
export declare function aiInventoryTheme(host: Data | null): AiInventoryTheme | null;
|
|
49
|
+
/**
|
|
50
|
+
* The screen a site's starter home may be (AGL-3594, AGL-3408): the one
|
|
51
|
+
* `host.defaultHomeScreenId` names, while it still answers `/` in the routing
|
|
52
|
+
* map and is listed as a page. `null` on every site whose owner published a
|
|
53
|
+
* home page of their own, which clears the marker (AGL-3478), and on every
|
|
54
|
+
* site created before the marker existed, whose home page is the owner's.
|
|
55
|
+
*/
|
|
56
|
+
export declare function aiStarterHomeCandidate(host: Readonly<Record<string, unknown>> | null, screens: ReadonlyArray<Pick<AiInventoryScreen, 'id' | 'template'>>): string | null;
|
|
57
|
+
/**
|
|
58
|
+
* Whether the starter home is as the platform wrote it: its one version is
|
|
59
|
+
* the one it was provisioned and published with. A version saved in the
|
|
60
|
+
* editor is an edit, published or not, and an edited starter is the owner's
|
|
61
|
+
* page — a plan keeps it.
|
|
62
|
+
*/
|
|
63
|
+
export declare function aiStarterHomeUntouched(publishedVersionId: string, versionIds: readonly string[]): boolean;
|
|
49
64
|
/**
|
|
50
65
|
* Read one site's inventory for a generation.
|
|
51
66
|
*
|
|
@@ -18,7 +18,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
18
18
|
import { isFormArchived } from "@aglyn/aglyn/app-utils/forms";
|
|
19
19
|
import { describeTheme, resolveSiteTheme } from "@aglyn/aglyn/app-utils/site-theme";
|
|
20
20
|
import { REUSABLE_COMPONENT_KIND_EMAIL, reusableComponentKindOf } from "@aglyn/aglyn/app-utils/reusable-component-kind";
|
|
21
|
-
import { SCREEN_KIND_TEMPLATE } from "@aglyn/aglyn/app-utils/screen-route";
|
|
21
|
+
import { SCREEN_KIND_TEMPLATE, SCREEN_ROOT_PATH } from "@aglyn/aglyn/app-utils/screen-route";
|
|
22
22
|
import { pluginRecordIndex } from "@aglyn/aglyn/plugin-manager/plugin-record-index";
|
|
23
23
|
import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
|
|
24
24
|
import { resolveOrgIdForHost } from "@aglyn/tenant-data-admin/server/organizations";
|
|
@@ -79,6 +79,28 @@ async function readWindow(query, fields, cap, map) {
|
|
|
79
79
|
truncated: snapshot.docs.length > cap
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* The screen a site's starter home may be (AGL-3594, AGL-3408): the one
|
|
84
|
+
* `host.defaultHomeScreenId` names, while it still answers `/` in the routing
|
|
85
|
+
* map and is listed as a page. `null` on every site whose owner published a
|
|
86
|
+
* home page of their own, which clears the marker (AGL-3478), and on every
|
|
87
|
+
* site created before the marker existed, whose home page is the owner's.
|
|
88
|
+
*/ export function aiStarterHomeCandidate(host, screens) {
|
|
89
|
+
const id = text(host == null ? void 0 : host['defaultHomeScreenId']);
|
|
90
|
+
if (!id) return null;
|
|
91
|
+
const routing = host == null ? void 0 : host['screens'];
|
|
92
|
+
const path = routing && typeof routing === 'object' ? routing[id] : undefined;
|
|
93
|
+
if (path !== SCREEN_ROOT_PATH) return null;
|
|
94
|
+
return screens.some((screen)=>screen.id === id && !screen.template) ? id : null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Whether the starter home is as the platform wrote it: its one version is
|
|
98
|
+
* the one it was provisioned and published with. A version saved in the
|
|
99
|
+
* editor is an edit, published or not, and an edited starter is the owner's
|
|
100
|
+
* page — a plan keeps it.
|
|
101
|
+
*/ export function aiStarterHomeUntouched(publishedVersionId, versionIds) {
|
|
102
|
+
return Boolean(publishedVersionId) && versionIds.length === 1 && versionIds[0] === publishedVersionId;
|
|
103
|
+
}
|
|
82
104
|
/**
|
|
83
105
|
* The org's datasets shared with the site, through the `dataset` index the
|
|
84
106
|
* data plugin publishes (AGL-3080) rather than its collection: their names
|
|
@@ -130,6 +152,9 @@ async function readWindow(query, fields, cap, map) {
|
|
|
130
152
|
const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
|
|
131
153
|
const cap = Math.min(AI_SITE_INVENTORY_MAX_PER_KIND, Math.max(1, Math.floor((_options_maxPerKind = options.maxPerKind) != null ? _options_maxPerKind : AI_SITE_INVENTORY_MAX_PER_KIND)));
|
|
132
154
|
const host = firestore.collection('hosts').doc(hostId);
|
|
155
|
+
// Each listed screen's published version, read in the same window, for the
|
|
156
|
+
// starter home's untouched test below.
|
|
157
|
+
const screenVersions = new Map();
|
|
133
158
|
const [components, layouts, templates, forms, datasets, collections, screens, hostDoc] = await Promise.all([
|
|
134
159
|
readWindow(host.collection('components'), [
|
|
135
160
|
'displayName',
|
|
@@ -208,11 +233,13 @@ async function readWindow(query, fields, cap, map) {
|
|
|
208
233
|
'slug',
|
|
209
234
|
'layoutId',
|
|
210
235
|
'kind',
|
|
211
|
-
'deletedAt'
|
|
236
|
+
'deletedAt',
|
|
237
|
+
'versionId'
|
|
212
238
|
], cap, (id, data)=>{
|
|
213
239
|
const kind = text(data['kind']);
|
|
214
240
|
// An email design and an error body are not pages a plan links or duplicates.
|
|
215
241
|
if (data['deletedAt'] || kind && kind !== SCREEN_KIND_TEMPLATE) return null;
|
|
242
|
+
screenVersions.set(id, text(data['versionId']));
|
|
216
243
|
return {
|
|
217
244
|
id,
|
|
218
245
|
name: nameOf(data, id, 'displayName', 'slug'),
|
|
@@ -223,6 +250,19 @@ async function readWindow(query, fields, cap, map) {
|
|
|
223
250
|
}),
|
|
224
251
|
host.get()
|
|
225
252
|
]);
|
|
253
|
+
const hostData = hostDoc.exists ? (_hostDoc_data = hostDoc.data()) != null ? _hostDoc_data : {} : null;
|
|
254
|
+
const starter = aiStarterHomeCandidate(hostData, screens.rows);
|
|
255
|
+
if (starter) {
|
|
256
|
+
var _screenVersions_get;
|
|
257
|
+
// One more read, and only on a site that still carries the marker: the
|
|
258
|
+
// starter's versions, of which an untouched one has exactly the one it
|
|
259
|
+
// was provisioned with.
|
|
260
|
+
const versions = await host.collection('screens').doc(starter).collection('versions').select().limit(2).get();
|
|
261
|
+
if (aiStarterHomeUntouched((_screenVersions_get = screenVersions.get(starter)) != null ? _screenVersions_get : '', versions.docs.map((doc)=>doc.id))) {
|
|
262
|
+
const row = screens.rows.find((screen)=>screen.id === starter);
|
|
263
|
+
if (row) row.replaceable = true;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
226
266
|
const windows = {
|
|
227
267
|
components,
|
|
228
268
|
layouts,
|
|
@@ -241,7 +281,7 @@ async function readWindow(query, fields, cap, map) {
|
|
|
241
281
|
datasets: datasets.rows,
|
|
242
282
|
collections: collections.rows,
|
|
243
283
|
screens: screens.rows,
|
|
244
|
-
theme: aiInventoryTheme(
|
|
284
|
+
theme: aiInventoryTheme(hostData),
|
|
245
285
|
truncated: AI_INVENTORY_KINDS.filter((kind)=>windows[kind].truncated)
|
|
246
286
|
};
|
|
247
287
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/site-inventory.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { hostCollectionKind } from '@aglyn/aglyn/app-utils/collection-kind'\nimport { isFormArchived } from '@aglyn/aglyn/app-utils/forms'\nimport {\n describeTheme,\n resolveSiteTheme,\n type ThemeHostDocument,\n} from '@aglyn/aglyn/app-utils/site-theme'\nimport {\n REUSABLE_COMPONENT_KIND_EMAIL,\n reusableComponentKindOf,\n} from '@aglyn/aglyn/app-utils/reusable-component-kind'\nimport { SCREEN_KIND_TEMPLATE } from '@aglyn/aglyn/app-utils/screen-route'\nimport { pluginRecordIndex } from '@aglyn/aglyn/plugin-manager/plugin-record-index'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { resolveOrgIdForHost } from '@aglyn/tenant-data-admin/server/organizations'\nimport {\n AI_INVENTORY_KINDS,\n AI_SITE_INVENTORY_MAX_PER_KIND,\n AiInventoryScopeError,\n type AiComponentPropTypes,\n type AiInventoryCollection,\n type AiInventoryComponent,\n type AiInventoryDataset,\n type AiInventoryForm,\n type AiInventoryKind,\n type AiInventoryLayout,\n type AiInventoryScreen,\n type AiInventoryTemplate,\n type AiInventoryTheme,\n type AiSiteInventory,\n} from '../model/ai-site-inventory'\nimport { aiBracketedFacts } from './ai-doctrine-validators'\n\n/**\n * The reader behind the site inventory (AGL-2935): one site's components,\n * layouts, templates, forms, datasets, content collections, screens and\n * theme, read as projections through the Admin SDK and reduced to the\n * compact shape `model/ai-site-inventory.ts` describes.\n *\n * Every kind is a projection (`select`) of the fields the inventory names —\n * never a node map, never a record's content — read one row past the cap so\n * a full page is told apart from a cut one. Retired rows (soft-deleted,\n * archived, a component never published) are filtered after the read, so a\n * site with many retired rows can list fewer than the cap; the kind is still\n * marked truncated when the read was cut, which is the honest answer.\n *\n * The window is WIDER than the prompt lists (AGL-2937). The prompt block\n * lists `AI_SITE_INVENTORY_LISTED_PER_KIND` records of a kind because every\n * character of it is billed on every attempt; the rest of the window is what\n * the lookup tool searches, and a record never read cannot be found. The\n * difference is paid in document reads rather than tokens — a projection of\n * a few fields, once per job step — which is the cheaper of the two by some\n * orders of magnitude.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Data = Record<string, unknown>\n\nexport interface ReadSiteInventoryOptions {\n /** The Admin SDK handle; the platform's default app when absent. */\n firestore?: Firestore\n /** Rows read per kind; `AI_SITE_INVENTORY_MAX_PER_KIND` when absent, and never above it. */\n maxPerKind?: number\n}\n\nfunction text(value: unknown): string {\n return typeof value === 'string' ? value.trim() : ''\n}\n\n/** A record's name, or its id when it was saved without one. */\nfunction nameOf(data: Data, id: string, ...fields: string[]): string {\n for (const field of fields) {\n const value = text(data[field])\n if (value) return value\n }\n return id\n}\n\n/** The flat `path → color` leaves of one scheme, e.g. `primary.main`. */\nfunction schemeColors(scheme: unknown): Record<string, string> {\n const colors: Record<string, string> = {}\n const visit = (value: unknown, path: string) => {\n if (typeof value === 'string') {\n if (path) colors[path] = value\n return\n }\n if (!value || typeof value !== 'object' || Array.isArray(value)) return\n for (const [key, inner] of Object.entries(value as Data)) {\n visit(inner, path ? `${path}.${key}` : key)\n }\n }\n visit(scheme, '')\n return colors\n}\n\n/** The theme a site renders with, reduced to what a plan and an email can act on. */\nexport function aiInventoryTheme(host: Data | null): AiInventoryTheme | null {\n const theme = resolveSiteTheme(host as ThemeHostDocument | null)\n if (!theme) return null\n const fonts = new Set<string>()\n for (const font of theme.fonts ?? []) {\n if (text(font?.family)) fonts.add(text(font.family))\n }\n return {\n summary: describeTheme(theme),\n colors: schemeColors(theme.colorSchemes?.light),\n fonts: [...fonts],\n }\n}\n\ninterface Window<T> {\n rows: T[]\n truncated: boolean\n}\n\nasync function readWindow<T>(\n query: FirebaseFirestore.Query,\n fields: string[],\n cap: number,\n map: (id: string, data: Data) => T | null,\n): Promise<Window<T>> {\n const snapshot = await query\n .select(...fields)\n .limit(cap + 1)\n .get()\n const rows: T[] = []\n for (const doc of snapshot.docs.slice(0, cap)) {\n const row = map(doc.id, (doc.data() ?? {}) as Data)\n if (row) rows.push(row)\n }\n return { rows, truncated: snapshot.docs.length > cap }\n}\n\n/**\n * The org's datasets shared with the site, through the `dataset` index the\n * data plugin publishes (AGL-3080) rather than its collection: their names\n * and their fields' names, in the dataset page's order. None where no plugin\n * keeps datasets here.\n */\nasync function readIndexedDatasets(\n orgId: string,\n hostId: string,\n cap: number,\n): Promise<Window<AiInventoryDataset>> {\n const datasets = pluginRecordIndex('dataset')\n if (!datasets) return { rows: [], truncated: false }\n const { records, truncated } = await datasets.index.list({ orgId, hostId, limit: cap })\n return {\n rows: records.map((record) => {\n const fields = (Array.isArray(record.facts['fields']) ? (record.facts['fields'] as Data[]) : [])\n .map((field) => ({ id: text(field?.['id']), name: text(field?.['name']) || text(field?.['id']) }))\n .filter((field) => field.name)\n return {\n id: record.id,\n name: record.name,\n fields: fields.map((field) => field.name),\n fieldIds: fields.map((field) => field.id || field.name),\n }\n }),\n truncated,\n }\n}\n\n/**\n * Read one site's inventory for a generation.\n *\n * SCOPED before anything is read. The job names a site and an org, and\n * nothing the reader is handed proves the site belongs to the org, so it asks\n * the host index first and refuses a pairing it does not record: another\n * workspace's component names are not this workspace's prompt. Datasets are\n * org-owned and narrowed to what this site may see.\n *\n * Throws `AiInventoryScopeError` for a site outside the org, and lets a\n * Firestore failure propagate: a planner handed an empty inventory by\n * mistake would create everything the site already has.\n */\nexport async function readSiteInventory(\n orgId: string,\n hostId: string,\n options: ReadSiteInventoryOptions = {},\n): Promise<AiSiteInventory> {\n const owner = await resolveOrgIdForHost(hostId)\n if (!owner || owner !== orgId) throw new AiInventoryScopeError(hostId)\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const cap = Math.min(\n AI_SITE_INVENTORY_MAX_PER_KIND,\n Math.max(1, Math.floor(options.maxPerKind ?? AI_SITE_INVENTORY_MAX_PER_KIND)),\n )\n const host = firestore.collection('hosts').doc(hostId)\n\n const [components, layouts, templates, forms, datasets, collections, screens, hostDoc] =\n await Promise.all([\n readWindow<AiInventoryComponent>(\n host.collection('components'),\n ['displayName', 'props', 'rootId', 'versionId', 'deletedAt', 'kind'],\n cap,\n (id, data) => {\n // Soft-deleted, or never published: an instance of either renders nothing.\n if (data['deletedAt'] || !(data['rootId'] || data['versionId'])) return null\n // An email block (AGL-3287) is built from email elements and placed\n // in emails alone: no page, layout or component a job builds can\n // render one, and the email a job builds places no instances.\n if (reusableComponentKindOf(data) === REUSABLE_COMPONENT_KIND_EMAIL) return null\n const props: AiComponentPropTypes = {}\n const bracketedDefaults: Record<string, string[]> = {}\n for (const prop of Array.isArray(data['props']) ? (data['props'] as Data[]) : []) {\n const name = text(prop?.['name'])\n if (!name) continue\n props[name] = text(prop['type']) || 'text'\n const facts = aiBracketedFacts([text(prop['defaultValue'])])\n if (facts.length) bracketedDefaults[name] = facts\n }\n return {\n id,\n name: nameOf(data, id, 'displayName'),\n props,\n ...(Object.keys(bracketedDefaults).length ? { bracketedDefaults } : {}),\n }\n },\n ),\n readWindow<AiInventoryLayout>(\n host.collection('layouts'),\n ['displayName', 'layoutId', 'deletedAt'],\n cap,\n (id, data) =>\n data['deletedAt']\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName'),\n parentId: text(data['layoutId']) || null,\n },\n ),\n readWindow<AiInventoryTemplate>(\n host.collection('templates'),\n ['displayName', 'kind', 'deletedAt'],\n cap,\n (id, data) =>\n data['deletedAt']\n ? null\n : { id, name: nameOf(data, id, 'displayName'), kind: text(data['kind']) || 'page' },\n ),\n readWindow<AiInventoryForm>(\n host.collection('forms'),\n ['displayName', 'slug', 'fields', 'archivedAt'],\n cap,\n (id, data) =>\n isFormArchived(data as { archivedAt?: unknown })\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName', 'slug'),\n fields: (Array.isArray(data['fields']) ? (data['fields'] as Data[]) : [])\n .map((field) => text(field?.['fieldName']))\n .filter(Boolean),\n },\n ),\n readIndexedDatasets(orgId, hostId, cap),\n readWindow<AiInventoryCollection>(\n host.collection('collections'),\n ['displayName', 'name', 'slug', 'kind'],\n cap,\n (id, data) =>\n hostCollectionKind(data) !== 'content'\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName', 'name', 'slug'),\n slug: text(data['slug']),\n },\n ),\n readWindow<AiInventoryScreen>(\n host.collection('screens'),\n ['displayName', 'slug', 'layoutId', 'kind', 'deletedAt'],\n cap,\n (id, data) => {\n const kind = text(data['kind'])\n // An email design and an error body are not pages a plan links or duplicates.\n if (data['deletedAt'] || (kind && kind !== SCREEN_KIND_TEMPLATE)) return null\n return {\n id,\n name: nameOf(data, id, 'displayName', 'slug'),\n slug: text(data['slug']),\n layoutId: text(data['layoutId']) || null,\n template: kind === SCREEN_KIND_TEMPLATE,\n }\n },\n ),\n host.get(),\n ])\n\n const windows: Record<AiInventoryKind, Window<unknown>> = {\n components,\n layouts,\n templates,\n forms,\n datasets,\n collections,\n screens,\n }\n return {\n hostId,\n components: components.rows,\n layouts: layouts.rows,\n templates: templates.rows,\n forms: forms.rows,\n datasets: datasets.rows,\n collections: collections.rows,\n screens: screens.rows,\n theme: aiInventoryTheme(hostDoc.exists ? ((hostDoc.data() ?? {}) as Data) : null),\n truncated: AI_INVENTORY_KINDS.filter((kind) => windows[kind].truncated),\n }\n}\n"],"names":["hostCollectionKind","isFormArchived","describeTheme","resolveSiteTheme","REUSABLE_COMPONENT_KIND_EMAIL","reusableComponentKindOf","SCREEN_KIND_TEMPLATE","pluginRecordIndex","firebaseAdmin","resolveOrgIdForHost","AI_INVENTORY_KINDS","AI_SITE_INVENTORY_MAX_PER_KIND","AiInventoryScopeError","aiBracketedFacts","text","value","trim","nameOf","data","id","fields","field","schemeColors","scheme","colors","visit","path","Array","isArray","key","inner","Object","entries","aiInventoryTheme","host","theme","fonts","Set","font","family","add","summary","colorSchemes","light","readWindow","query","cap","map","snapshot","select","limit","get","rows","doc","docs","slice","row","push","truncated","length","readIndexedDatasets","orgId","hostId","datasets","records","index","list","record","facts","name","filter","fieldIds","readSiteInventory","options","hostDoc","owner","firestore","app","Math","min","max","floor","maxPerKind","collection","components","layouts","templates","forms","collections","screens","Promise","all","props","bracketedDefaults","prop","keys","parentId","kind","Boolean","slug","layoutId","template","windows","exists"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/site-inventory.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { hostCollectionKind } from '@aglyn/aglyn/app-utils/collection-kind'\nimport { isFormArchived } from '@aglyn/aglyn/app-utils/forms'\nimport {\n describeTheme,\n resolveSiteTheme,\n type ThemeHostDocument,\n} from '@aglyn/aglyn/app-utils/site-theme'\nimport {\n REUSABLE_COMPONENT_KIND_EMAIL,\n reusableComponentKindOf,\n} from '@aglyn/aglyn/app-utils/reusable-component-kind'\nimport { SCREEN_KIND_TEMPLATE, SCREEN_ROOT_PATH } from '@aglyn/aglyn/app-utils/screen-route'\nimport { pluginRecordIndex } from '@aglyn/aglyn/plugin-manager/plugin-record-index'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { resolveOrgIdForHost } from '@aglyn/tenant-data-admin/server/organizations'\nimport {\n AI_INVENTORY_KINDS,\n AI_SITE_INVENTORY_MAX_PER_KIND,\n AiInventoryScopeError,\n type AiComponentPropTypes,\n type AiInventoryCollection,\n type AiInventoryComponent,\n type AiInventoryDataset,\n type AiInventoryForm,\n type AiInventoryKind,\n type AiInventoryLayout,\n type AiInventoryScreen,\n type AiInventoryTemplate,\n type AiInventoryTheme,\n type AiSiteInventory,\n} from '../model/ai-site-inventory'\nimport { aiBracketedFacts } from './ai-doctrine-validators'\n\n/**\n * The reader behind the site inventory (AGL-2935): one site's components,\n * layouts, templates, forms, datasets, content collections, screens and\n * theme, read as projections through the Admin SDK and reduced to the\n * compact shape `model/ai-site-inventory.ts` describes.\n *\n * Every kind is a projection (`select`) of the fields the inventory names —\n * never a node map, never a record's content — read one row past the cap so\n * a full page is told apart from a cut one. Retired rows (soft-deleted,\n * archived, a component never published) are filtered after the read, so a\n * site with many retired rows can list fewer than the cap; the kind is still\n * marked truncated when the read was cut, which is the honest answer.\n *\n * The window is WIDER than the prompt lists (AGL-2937). The prompt block\n * lists `AI_SITE_INVENTORY_LISTED_PER_KIND` records of a kind because every\n * character of it is billed on every attempt; the rest of the window is what\n * the lookup tool searches, and a record never read cannot be found. The\n * difference is paid in document reads rather than tokens — a projection of\n * a few fields, once per job step — which is the cheaper of the two by some\n * orders of magnitude.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Data = Record<string, unknown>\n\nexport interface ReadSiteInventoryOptions {\n /** The Admin SDK handle; the platform's default app when absent. */\n firestore?: Firestore\n /** Rows read per kind; `AI_SITE_INVENTORY_MAX_PER_KIND` when absent, and never above it. */\n maxPerKind?: number\n}\n\nfunction text(value: unknown): string {\n return typeof value === 'string' ? value.trim() : ''\n}\n\n/** A record's name, or its id when it was saved without one. */\nfunction nameOf(data: Data, id: string, ...fields: string[]): string {\n for (const field of fields) {\n const value = text(data[field])\n if (value) return value\n }\n return id\n}\n\n/** The flat `path → color` leaves of one scheme, e.g. `primary.main`. */\nfunction schemeColors(scheme: unknown): Record<string, string> {\n const colors: Record<string, string> = {}\n const visit = (value: unknown, path: string) => {\n if (typeof value === 'string') {\n if (path) colors[path] = value\n return\n }\n if (!value || typeof value !== 'object' || Array.isArray(value)) return\n for (const [key, inner] of Object.entries(value as Data)) {\n visit(inner, path ? `${path}.${key}` : key)\n }\n }\n visit(scheme, '')\n return colors\n}\n\n/** The theme a site renders with, reduced to what a plan and an email can act on. */\nexport function aiInventoryTheme(host: Data | null): AiInventoryTheme | null {\n const theme = resolveSiteTheme(host as ThemeHostDocument | null)\n if (!theme) return null\n const fonts = new Set<string>()\n for (const font of theme.fonts ?? []) {\n if (text(font?.family)) fonts.add(text(font.family))\n }\n return {\n summary: describeTheme(theme),\n colors: schemeColors(theme.colorSchemes?.light),\n fonts: [...fonts],\n }\n}\n\ninterface Window<T> {\n rows: T[]\n truncated: boolean\n}\n\nasync function readWindow<T>(\n query: FirebaseFirestore.Query,\n fields: string[],\n cap: number,\n map: (id: string, data: Data) => T | null,\n): Promise<Window<T>> {\n const snapshot = await query\n .select(...fields)\n .limit(cap + 1)\n .get()\n const rows: T[] = []\n for (const doc of snapshot.docs.slice(0, cap)) {\n const row = map(doc.id, (doc.data() ?? {}) as Data)\n if (row) rows.push(row)\n }\n return { rows, truncated: snapshot.docs.length > cap }\n}\n\n/**\n * The screen a site's starter home may be (AGL-3594, AGL-3408): the one\n * `host.defaultHomeScreenId` names, while it still answers `/` in the routing\n * map and is listed as a page. `null` on every site whose owner published a\n * home page of their own, which clears the marker (AGL-3478), and on every\n * site created before the marker existed, whose home page is the owner's.\n */\nexport function aiStarterHomeCandidate(\n host: Readonly<Record<string, unknown>> | null,\n screens: ReadonlyArray<Pick<AiInventoryScreen, 'id' | 'template'>>,\n): string | null {\n const id = text(host?.['defaultHomeScreenId'])\n if (!id) return null\n const routing = host?.['screens']\n const path =\n routing && typeof routing === 'object' ? (routing as Record<string, unknown>)[id] : undefined\n if (path !== SCREEN_ROOT_PATH) return null\n return screens.some((screen) => screen.id === id && !screen.template) ? id : null\n}\n\n/**\n * Whether the starter home is as the platform wrote it: its one version is\n * the one it was provisioned and published with. A version saved in the\n * editor is an edit, published or not, and an edited starter is the owner's\n * page — a plan keeps it.\n */\nexport function aiStarterHomeUntouched(\n publishedVersionId: string,\n versionIds: readonly string[],\n): boolean {\n return Boolean(publishedVersionId) && versionIds.length === 1 && versionIds[0] === publishedVersionId\n}\n\n/**\n * The org's datasets shared with the site, through the `dataset` index the\n * data plugin publishes (AGL-3080) rather than its collection: their names\n * and their fields' names, in the dataset page's order. None where no plugin\n * keeps datasets here.\n */\nasync function readIndexedDatasets(\n orgId: string,\n hostId: string,\n cap: number,\n): Promise<Window<AiInventoryDataset>> {\n const datasets = pluginRecordIndex('dataset')\n if (!datasets) return { rows: [], truncated: false }\n const { records, truncated } = await datasets.index.list({ orgId, hostId, limit: cap })\n return {\n rows: records.map((record) => {\n const fields = (Array.isArray(record.facts['fields']) ? (record.facts['fields'] as Data[]) : [])\n .map((field) => ({ id: text(field?.['id']), name: text(field?.['name']) || text(field?.['id']) }))\n .filter((field) => field.name)\n return {\n id: record.id,\n name: record.name,\n fields: fields.map((field) => field.name),\n fieldIds: fields.map((field) => field.id || field.name),\n }\n }),\n truncated,\n }\n}\n\n/**\n * Read one site's inventory for a generation.\n *\n * SCOPED before anything is read. The job names a site and an org, and\n * nothing the reader is handed proves the site belongs to the org, so it asks\n * the host index first and refuses a pairing it does not record: another\n * workspace's component names are not this workspace's prompt. Datasets are\n * org-owned and narrowed to what this site may see.\n *\n * Throws `AiInventoryScopeError` for a site outside the org, and lets a\n * Firestore failure propagate: a planner handed an empty inventory by\n * mistake would create everything the site already has.\n */\nexport async function readSiteInventory(\n orgId: string,\n hostId: string,\n options: ReadSiteInventoryOptions = {},\n): Promise<AiSiteInventory> {\n const owner = await resolveOrgIdForHost(hostId)\n if (!owner || owner !== orgId) throw new AiInventoryScopeError(hostId)\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const cap = Math.min(\n AI_SITE_INVENTORY_MAX_PER_KIND,\n Math.max(1, Math.floor(options.maxPerKind ?? AI_SITE_INVENTORY_MAX_PER_KIND)),\n )\n const host = firestore.collection('hosts').doc(hostId)\n // Each listed screen's published version, read in the same window, for the\n // starter home's untouched test below.\n const screenVersions = new Map<string, string>()\n\n const [components, layouts, templates, forms, datasets, collections, screens, hostDoc] =\n await Promise.all([\n readWindow<AiInventoryComponent>(\n host.collection('components'),\n ['displayName', 'props', 'rootId', 'versionId', 'deletedAt', 'kind'],\n cap,\n (id, data) => {\n // Soft-deleted, or never published: an instance of either renders nothing.\n if (data['deletedAt'] || !(data['rootId'] || data['versionId'])) return null\n // An email block (AGL-3287) is built from email elements and placed\n // in emails alone: no page, layout or component a job builds can\n // render one, and the email a job builds places no instances.\n if (reusableComponentKindOf(data) === REUSABLE_COMPONENT_KIND_EMAIL) return null\n const props: AiComponentPropTypes = {}\n const bracketedDefaults: Record<string, string[]> = {}\n for (const prop of Array.isArray(data['props']) ? (data['props'] as Data[]) : []) {\n const name = text(prop?.['name'])\n if (!name) continue\n props[name] = text(prop['type']) || 'text'\n const facts = aiBracketedFacts([text(prop['defaultValue'])])\n if (facts.length) bracketedDefaults[name] = facts\n }\n return {\n id,\n name: nameOf(data, id, 'displayName'),\n props,\n ...(Object.keys(bracketedDefaults).length ? { bracketedDefaults } : {}),\n }\n },\n ),\n readWindow<AiInventoryLayout>(\n host.collection('layouts'),\n ['displayName', 'layoutId', 'deletedAt'],\n cap,\n (id, data) =>\n data['deletedAt']\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName'),\n parentId: text(data['layoutId']) || null,\n },\n ),\n readWindow<AiInventoryTemplate>(\n host.collection('templates'),\n ['displayName', 'kind', 'deletedAt'],\n cap,\n (id, data) =>\n data['deletedAt']\n ? null\n : { id, name: nameOf(data, id, 'displayName'), kind: text(data['kind']) || 'page' },\n ),\n readWindow<AiInventoryForm>(\n host.collection('forms'),\n ['displayName', 'slug', 'fields', 'archivedAt'],\n cap,\n (id, data) =>\n isFormArchived(data as { archivedAt?: unknown })\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName', 'slug'),\n fields: (Array.isArray(data['fields']) ? (data['fields'] as Data[]) : [])\n .map((field) => text(field?.['fieldName']))\n .filter(Boolean),\n },\n ),\n readIndexedDatasets(orgId, hostId, cap),\n readWindow<AiInventoryCollection>(\n host.collection('collections'),\n ['displayName', 'name', 'slug', 'kind'],\n cap,\n (id, data) =>\n hostCollectionKind(data) !== 'content'\n ? null\n : {\n id,\n name: nameOf(data, id, 'displayName', 'name', 'slug'),\n slug: text(data['slug']),\n },\n ),\n readWindow<AiInventoryScreen>(\n host.collection('screens'),\n ['displayName', 'slug', 'layoutId', 'kind', 'deletedAt', 'versionId'],\n cap,\n (id, data) => {\n const kind = text(data['kind'])\n // An email design and an error body are not pages a plan links or duplicates.\n if (data['deletedAt'] || (kind && kind !== SCREEN_KIND_TEMPLATE)) return null\n screenVersions.set(id, text(data['versionId']))\n return {\n id,\n name: nameOf(data, id, 'displayName', 'slug'),\n slug: text(data['slug']),\n layoutId: text(data['layoutId']) || null,\n template: kind === SCREEN_KIND_TEMPLATE,\n }\n },\n ),\n host.get(),\n ])\n\n const hostData = hostDoc.exists ? ((hostDoc.data() ?? {}) as Data) : null\n const starter = aiStarterHomeCandidate(hostData, screens.rows)\n if (starter) {\n // One more read, and only on a site that still carries the marker: the\n // starter's versions, of which an untouched one has exactly the one it\n // was provisioned with.\n const versions = await host\n .collection('screens')\n .doc(starter)\n .collection('versions')\n .select()\n .limit(2)\n .get()\n if (aiStarterHomeUntouched(screenVersions.get(starter) ?? '', versions.docs.map((doc) => doc.id))) {\n const row = screens.rows.find((screen) => screen.id === starter)\n if (row) row.replaceable = true\n }\n }\n\n const windows: Record<AiInventoryKind, Window<unknown>> = {\n components,\n layouts,\n templates,\n forms,\n datasets,\n collections,\n screens,\n }\n return {\n hostId,\n components: components.rows,\n layouts: layouts.rows,\n templates: templates.rows,\n forms: forms.rows,\n datasets: datasets.rows,\n collections: collections.rows,\n screens: screens.rows,\n theme: aiInventoryTheme(hostData),\n truncated: AI_INVENTORY_KINDS.filter((kind) => windows[kind].truncated),\n }\n}\n"],"names":["hostCollectionKind","isFormArchived","describeTheme","resolveSiteTheme","REUSABLE_COMPONENT_KIND_EMAIL","reusableComponentKindOf","SCREEN_KIND_TEMPLATE","SCREEN_ROOT_PATH","pluginRecordIndex","firebaseAdmin","resolveOrgIdForHost","AI_INVENTORY_KINDS","AI_SITE_INVENTORY_MAX_PER_KIND","AiInventoryScopeError","aiBracketedFacts","text","value","trim","nameOf","data","id","fields","field","schemeColors","scheme","colors","visit","path","Array","isArray","key","inner","Object","entries","aiInventoryTheme","host","theme","fonts","Set","font","family","add","summary","colorSchemes","light","readWindow","query","cap","map","snapshot","select","limit","get","rows","doc","docs","slice","row","push","truncated","length","aiStarterHomeCandidate","screens","routing","undefined","some","screen","template","aiStarterHomeUntouched","publishedVersionId","versionIds","Boolean","readIndexedDatasets","orgId","hostId","datasets","records","index","list","record","facts","name","filter","fieldIds","readSiteInventory","options","hostDoc","owner","firestore","app","Math","min","max","floor","maxPerKind","collection","screenVersions","Map","components","layouts","templates","forms","collections","Promise","all","props","bracketedDefaults","prop","keys","parentId","kind","slug","set","layoutId","hostData","exists","starter","versions","find","replaceable","windows"],"mappings":"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"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/** The staff roles that may give credits back. */
|
|
18
|
+
export declare const AI_CREDIT_RETURN_ROLES: readonly ["super", "billing"];
|
|
19
|
+
declare function handler(request: Request): Promise<Response>;
|
|
20
|
+
export declare const dynamic = "force-dynamic";
|
|
21
|
+
export { handler as POST };
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { ORG_BILLING_DOC_ID, ORG_BILLING_SUBCOLLECTION, pluginRequestFromWeb } from "@aglyn/aglyn/server";
|
|
18
|
+
import { emailUnverifiedResponse, firebaseAdmin, isImpersonationSession } from "@aglyn/tenant-data-admin";
|
|
19
|
+
import { setAdminAudit } from "@aglyn/tenant-data-admin/server/admin-audit-write";
|
|
20
|
+
import { invalidIdTokenResponse } from "@aglyn/tenant-data-admin/server/id-token-refusal";
|
|
21
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
22
|
+
import { freeAssistAccount } from "../usage/assist-free-taste.js";
|
|
23
|
+
import { assistUsageMonth } from "../usage/assist-usage.js";
|
|
24
|
+
import { accountCreditMeter, isAssistCreditReturnKey, returnAssistCredits, workspaceCreditMeter } from "../usage/assist-credit-returns-write.js";
|
|
25
|
+
/**
|
|
26
|
+
* STAFF GIVE AI CREDITS BACK (AGL-3595) — compensation for a fault of ours.
|
|
27
|
+
*
|
|
28
|
+
* A planner bug refused a Free workspace's plan after it had spent 227 of
|
|
29
|
+
* its 300 monthly credits, and the person could not try again until the
|
|
30
|
+
* month rolled. This is the control that undoes that: staff return credits
|
|
31
|
+
* to the meter they were drawn from, with a reason, and the person's next
|
|
32
|
+
* request is admitted against the reduced figure.
|
|
33
|
+
*
|
|
34
|
+
* POST `{ orgId | uid, action, meter, credits?, reason, jobId?, idempotencyKey }`
|
|
35
|
+
*
|
|
36
|
+
* action `give` return `credits` to each named meter
|
|
37
|
+
* `reset` return everything each meter used this month
|
|
38
|
+
* meter `workspace` the workspace's band (needs `orgId`)
|
|
39
|
+
* `account` a Free owner's allowance (`orgId` of a Free
|
|
40
|
+
* workspace, or the account's `uid`)
|
|
41
|
+
* `both` the two together, for a Free workspace
|
|
42
|
+
*
|
|
43
|
+
* ## The gate
|
|
44
|
+
*
|
|
45
|
+
* The staff claim, and then the `super` or `billing` staff role — the bar
|
|
46
|
+
* `/api/admin/org-override` holds for a quota write, because a give-back is
|
|
47
|
+
* one: it hands out credits that are worth money. A missing role is
|
|
48
|
+
* `support`, and refused (AGL-495).
|
|
49
|
+
*
|
|
50
|
+
* ## What it writes
|
|
51
|
+
*
|
|
52
|
+
* An adjustment, never an edit: `returnAssistCredits` adds to the month's
|
|
53
|
+
* `returnedUsd` and keeps the act under `creditReturns.{key}`, and one
|
|
54
|
+
* `adminAudit` row commits in the same transaction. A repeated key writes
|
|
55
|
+
* nothing and answers `duplicate`, so a double-click returns once.
|
|
56
|
+
*/ /** The longest a staff reason may be, so a paste cannot fill the document. */ const REASON_MAX = 500;
|
|
57
|
+
/** The staff roles that may give credits back. */ export const AI_CREDIT_RETURN_ROLES = [
|
|
58
|
+
'super',
|
|
59
|
+
'billing'
|
|
60
|
+
];
|
|
61
|
+
const refuse = (error, status, code, extra = {})=>Response.json(_extends({
|
|
62
|
+
error
|
|
63
|
+
}, code ? {
|
|
64
|
+
code
|
|
65
|
+
} : {}, extra), {
|
|
66
|
+
status
|
|
67
|
+
});
|
|
68
|
+
async function handler(request) {
|
|
69
|
+
var _headers_authorization;
|
|
70
|
+
const { method, body, headers: rawHeaders } = await pluginRequestFromWeb(request);
|
|
71
|
+
const headers = rawHeaders;
|
|
72
|
+
if (method !== 'POST') return refuse('Method not allowed', 405);
|
|
73
|
+
const authorization = (_headers_authorization = headers.authorization) != null ? _headers_authorization : '';
|
|
74
|
+
const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
|
|
75
|
+
if (!idToken) return refuse('Unauthenticated', 401);
|
|
76
|
+
try {
|
|
77
|
+
var _decoded_staffRole, _ref, _ref1, _ref2, _ref3, _ref4, _ref5;
|
|
78
|
+
const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
|
|
79
|
+
if (!decoded.email_verified && !isImpersonationSession(decoded)) {
|
|
80
|
+
return emailUnverifiedResponse();
|
|
81
|
+
}
|
|
82
|
+
if (!decoded['staff']) return refuse('Staff only', 403);
|
|
83
|
+
const role = String((_decoded_staffRole = decoded['staffRole']) != null ? _decoded_staffRole : 'support');
|
|
84
|
+
if (!AI_CREDIT_RETURN_ROLES.includes(role)) {
|
|
85
|
+
return refuse('Requires the billing or super staff role', 403, 'role');
|
|
86
|
+
}
|
|
87
|
+
const orgId = String((_ref = body == null ? void 0 : body.orgId) != null ? _ref : '').trim();
|
|
88
|
+
const uid = String((_ref1 = body == null ? void 0 : body.uid) != null ? _ref1 : '').trim();
|
|
89
|
+
const action = String((_ref2 = body == null ? void 0 : body.action) != null ? _ref2 : '');
|
|
90
|
+
const meter = String((_ref3 = body == null ? void 0 : body.meter) != null ? _ref3 : '');
|
|
91
|
+
if (!orgId && !uid) return refuse('Name an organization or an account', 400);
|
|
92
|
+
if (action !== 'give' && action !== 'reset') return refuse('Bad action', 400);
|
|
93
|
+
if (![
|
|
94
|
+
'workspace',
|
|
95
|
+
'account',
|
|
96
|
+
'both'
|
|
97
|
+
].includes(meter)) {
|
|
98
|
+
return refuse('Bad meter', 400);
|
|
99
|
+
}
|
|
100
|
+
// Refused before anything is read: the reason is the audit row's whole
|
|
101
|
+
// value, and a row written without one cannot be corrected later.
|
|
102
|
+
const reason = String((_ref4 = body == null ? void 0 : body.reason) != null ? _ref4 : '').trim().slice(0, REASON_MAX);
|
|
103
|
+
if (!reason) {
|
|
104
|
+
return refuse('Say why the credits are being given back', 400, 'reason_required');
|
|
105
|
+
}
|
|
106
|
+
const key = body == null ? void 0 : body.idempotencyKey;
|
|
107
|
+
if (!isAssistCreditReturnKey(key)) {
|
|
108
|
+
return refuse('Missing idempotency key', 400, 'idempotency_key');
|
|
109
|
+
}
|
|
110
|
+
const credits = Number(body == null ? void 0 : body.credits);
|
|
111
|
+
if (action === 'give' && !(Number.isInteger(credits) && credits > 0)) {
|
|
112
|
+
return refuse('Give back a positive whole number of credits', 400, 'invalid_credits');
|
|
113
|
+
}
|
|
114
|
+
const jobId = String((_ref5 = body == null ? void 0 : body.jobId) != null ? _ref5 : '').trim().slice(0, 200) || null;
|
|
115
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
116
|
+
const month = assistUsageMonth(new Date());
|
|
117
|
+
const targets = [];
|
|
118
|
+
let accountUid = null;
|
|
119
|
+
if (orgId) {
|
|
120
|
+
var _orgSnap_data;
|
|
121
|
+
const orgRef = firestore.collection('orgs').doc(orgId);
|
|
122
|
+
const [orgSnap, billingSnap] = await Promise.all([
|
|
123
|
+
orgRef.get(),
|
|
124
|
+
orgRef.collection(ORG_BILLING_SUBCOLLECTION).doc(ORG_BILLING_DOC_ID).get()
|
|
125
|
+
]);
|
|
126
|
+
if (!orgSnap.exists) return refuse('No such organization', 404);
|
|
127
|
+
// Org doc first, billing mirror over it — the merge the staff card
|
|
128
|
+
// reads, so a workspace whose subscription died resolves as Free here
|
|
129
|
+
// exactly as it does at the gate.
|
|
130
|
+
const org = _extends({}, (_orgSnap_data = orgSnap.data()) != null ? _orgSnap_data : {}, billingSnap.exists ? billingSnap.data() : {});
|
|
131
|
+
if (meter !== 'account') targets.push(workspaceCreditMeter(firestore, orgId, month));
|
|
132
|
+
if (meter !== 'workspace') {
|
|
133
|
+
var _ref6;
|
|
134
|
+
var _freeAssistAccount;
|
|
135
|
+
accountUid = (_ref6 = (_freeAssistAccount = freeAssistAccount(org)) == null ? void 0 : _freeAssistAccount.accountUid) != null ? _ref6 : null;
|
|
136
|
+
if (!accountUid) {
|
|
137
|
+
return refuse('Only a Free workspace with an owner draws on an account allowance', 409, 'no_account_allowance');
|
|
138
|
+
}
|
|
139
|
+
targets.push(accountCreditMeter(firestore, accountUid, month));
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
// From the staff user page: the account's own allowance, nothing else.
|
|
143
|
+
if (meter !== 'account') {
|
|
144
|
+
return refuse('An account has only its allowance to give back to', 400);
|
|
145
|
+
}
|
|
146
|
+
accountUid = uid;
|
|
147
|
+
targets.push(accountCreditMeter(firestore, uid, month));
|
|
148
|
+
}
|
|
149
|
+
const outcome = await returnAssistCredits(firestore, {
|
|
150
|
+
month,
|
|
151
|
+
meters: targets,
|
|
152
|
+
credits: action === 'reset' ? 'all' : credits,
|
|
153
|
+
key,
|
|
154
|
+
reason,
|
|
155
|
+
actorUid: decoded.uid,
|
|
156
|
+
source: 'staff',
|
|
157
|
+
jobId
|
|
158
|
+
}, (tx, lines)=>{
|
|
159
|
+
var _ref;
|
|
160
|
+
var _returned_;
|
|
161
|
+
const returned = lines.filter((line)=>line.credits > 0);
|
|
162
|
+
setAdminAudit(tx, firestore, _extends({
|
|
163
|
+
actorUid: decoded.uid,
|
|
164
|
+
action: action === 'reset' ? 'ai.credits.reset' : 'ai.credits.giveBack',
|
|
165
|
+
target: (_ref = (_returned_ = returned[0]) == null ? void 0 : _returned_.path) != null ? _ref : targets[0].ref.path
|
|
166
|
+
}, accountUid ? {
|
|
167
|
+
subjectUid: accountUid
|
|
168
|
+
} : {}, {
|
|
169
|
+
note: `${reason} — ` + returned.map((line)=>`${line.credits} credits to the ${line.meter}`).join(', ') + (jobId ? ` (job ${jobId})` : ''),
|
|
170
|
+
after: {
|
|
171
|
+
orgId: orgId || null,
|
|
172
|
+
accountUid,
|
|
173
|
+
month,
|
|
174
|
+
jobId,
|
|
175
|
+
idempotencyKey: key,
|
|
176
|
+
meters: returned.map((line)=>({
|
|
177
|
+
meter: line.meter,
|
|
178
|
+
path: line.path,
|
|
179
|
+
credits: line.credits,
|
|
180
|
+
usedBefore: line.usedBefore
|
|
181
|
+
}))
|
|
182
|
+
},
|
|
183
|
+
at: FieldValue.serverTimestamp()
|
|
184
|
+
}));
|
|
185
|
+
});
|
|
186
|
+
if (outcome.status === 'over') {
|
|
187
|
+
return refuse('That is more than this month used: ' + outcome.lines.map((line)=>`${line.usedBefore} on the ${line.meter}`).join(', '), 409, 'over_return', {
|
|
188
|
+
lines: outcome.lines
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
if (outcome.status === 'nothing') {
|
|
192
|
+
return refuse('Nothing used this month to give back', 409, 'nothing_used', {
|
|
193
|
+
lines: outcome.lines
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return Response.json({
|
|
197
|
+
month,
|
|
198
|
+
duplicate: outcome.status === 'duplicate',
|
|
199
|
+
lines: outcome.lines
|
|
200
|
+
}, {
|
|
201
|
+
status: 200
|
|
202
|
+
});
|
|
203
|
+
} catch (error) {
|
|
204
|
+
const unauthenticated = invalidIdTokenResponse(error);
|
|
205
|
+
if (unauthenticated) return unauthenticated;
|
|
206
|
+
console.error('[ai/admin/credits]', error);
|
|
207
|
+
return refuse('Giving credits back failed', 500);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
export const dynamic = 'force-dynamic';
|
|
211
|
+
export { handler as POST };
|
|
212
|
+
|
|
213
|
+
//# sourceMappingURL=ai-admin-credits.js.map
|