@aglyn/plugins-ai 1.0.0-beta.270 → 1.0.0-beta.272
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +17 -17
- package/src/lib/activity/ai-activity-actions.d.ts +4 -3
- package/src/lib/activity/ai-activity-actions.js +4 -0
- package/src/lib/activity/ai-activity-actions.js.map +1 -1
- package/src/lib/assets/site-styles/beauty.webp +0 -0
- package/src/lib/assets/site-styles/blog.webp +0 -0
- package/src/lib/assets/site-styles/business.webp +0 -0
- package/src/lib/assets/site-styles/coming-soon.webp +0 -0
- package/src/lib/assets/site-styles/education.webp +0 -0
- package/src/lib/assets/site-styles/events.webp +0 -0
- package/src/lib/assets/site-styles/fitness.webp +0 -0
- package/src/lib/assets/site-styles/landing.webp +0 -0
- package/src/lib/assets/site-styles/music.webp +0 -0
- package/src/lib/assets/site-styles/nonprofit.webp +0 -0
- package/src/lib/assets/site-styles/personal.webp +0 -0
- package/src/lib/assets/site-styles/photography.webp +0 -0
- package/src/lib/assets/site-styles/portfolio.webp +0 -0
- package/src/lib/assets/site-styles/professional.webp +0 -0
- package/src/lib/assets/site-styles/realestate.webp +0 -0
- package/src/lib/assets/site-styles/restaurant.webp +0 -0
- package/src/lib/assets/site-styles/store.webp +0 -0
- package/src/lib/assets/site-styles/studio.webp +0 -0
- package/src/lib/assets/site-styles/trades.webp +0 -0
- package/src/lib/assets/site-styles/wellness.webp +0 -0
- package/src/lib/assets/site-styles/yoga.webp +0 -0
- package/src/lib/components/ai-credits-card.component.d.ts +4 -3
- package/src/lib/components/ai-credits-card.component.js +15 -15
- package/src/lib/components/ai-credits-card.component.js.map +1 -1
- package/src/lib/components/ai-job-input-form.component.d.ts +36 -0
- package/src/lib/components/ai-job-input-form.component.js +100 -0
- package/src/lib/components/ai-job-input-form.component.js.map +1 -0
- package/src/lib/components/ai-job-requests.d.ts +4 -0
- package/src/lib/components/ai-job-requests.js +4 -0
- package/src/lib/components/ai-job-requests.js.map +1 -1
- package/src/lib/components/ai-site-build-page.component.js +18 -0
- package/src/lib/components/ai-site-build-page.component.js.map +1 -1
- package/src/lib/components/ai-site-import-job.component.d.ts +41 -0
- package/src/lib/components/ai-site-import-job.component.js +707 -0
- package/src/lib/components/ai-site-import-job.component.js.map +1 -0
- package/src/lib/components/ai-site-import-start.component.d.ts +64 -0
- package/src/lib/components/ai-site-import-start.component.js +335 -0
- package/src/lib/components/ai-site-import-start.component.js.map +1 -0
- package/src/lib/components/ai-site-ownership-proof.component.d.ts +42 -0
- package/src/lib/components/ai-site-ownership-proof.component.js +353 -0
- package/src/lib/components/ai-site-ownership-proof.component.js.map +1 -0
- package/src/lib/components/ai-site-start-card.component.d.ts +1 -1
- package/src/lib/components/ai-site-start-card.component.js +261 -119
- package/src/lib/components/ai-site-start-card.component.js.map +1 -1
- package/src/lib/components/ai-site-start-steps.component.d.ts +43 -2
- package/src/lib/components/ai-site-start-steps.component.js +651 -15
- package/src/lib/components/ai-site-start-steps.component.js.map +1 -1
- package/src/lib/components/ai-site-start-tracking.d.ts +10 -0
- package/src/lib/components/ai-site-start-tracking.js +14 -0
- package/src/lib/components/ai-site-start-tracking.js.map +1 -1
- package/src/lib/components/ai-site-style-previews.js +26 -22
- package/src/lib/components/ai-site-style-previews.js.map +1 -1
- package/src/lib/components/ai-upsell-dialog.component.d.ts +7 -5
- package/src/lib/components/ai-upsell-dialog.component.js +8 -6
- package/src/lib/components/ai-upsell-dialog.component.js.map +1 -1
- package/src/lib/components/assist-build-card.component.js +11 -1
- package/src/lib/components/assist-build-card.component.js.map +1 -1
- package/src/lib/components/assist-jobs-drawer.component.js +9 -1
- package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
- package/src/lib/components/assist-panel.component.js +8 -4
- package/src/lib/components/assist-panel.component.js.map +1 -1
- package/src/lib/components/staff-org-ai-card.component.js +4 -7
- package/src/lib/components/staff-org-ai-card.component.js.map +1 -1
- package/src/lib/declarations.d.ts +2 -2
- package/src/lib/declarations.js +9 -5
- package/src/lib/declarations.js.map +1 -1
- package/src/lib/jobs/ai-build-capabilities.js +3 -1
- package/src/lib/jobs/ai-build-capabilities.js.map +1 -1
- package/src/lib/jobs/ai-business-profile-prefill.d.ts +1 -1
- package/src/lib/jobs/ai-business-profile-prefill.js +14 -3
- package/src/lib/jobs/ai-business-profile-prefill.js.map +1 -1
- package/src/lib/jobs/ai-job-build-step.js +5 -3
- package/src/lib/jobs/ai-job-build-step.js.map +1 -1
- package/src/lib/jobs/ai-job-drafts.d.ts +14 -0
- package/src/lib/jobs/ai-job-drafts.js +24 -0
- package/src/lib/jobs/ai-job-drafts.js.map +1 -1
- package/src/lib/jobs/ai-job-form-step.d.ts +15 -1
- package/src/lib/jobs/ai-job-form-step.js +75 -8
- package/src/lib/jobs/ai-job-form-step.js.map +1 -1
- package/src/lib/jobs/ai-job-import-step.d.ts +136 -0
- package/src/lib/jobs/ai-job-import-step.js +809 -0
- package/src/lib/jobs/ai-job-import-step.js.map +1 -0
- package/src/lib/jobs/ai-job-page-language.d.ts +52 -1
- package/src/lib/jobs/ai-job-page-language.js +99 -5
- package/src/lib/jobs/ai-job-page-language.js.map +1 -1
- package/src/lib/jobs/ai-job-page-step.d.ts +19 -2
- package/src/lib/jobs/ai-job-page-step.js +42 -18
- package/src/lib/jobs/ai-job-page-step.js.map +1 -1
- package/src/lib/jobs/ai-job-plan-step.d.ts +25 -2
- package/src/lib/jobs/ai-job-plan-step.js +103 -29
- package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
- package/src/lib/jobs/ai-job-settings-step.js +4 -3
- package/src/lib/jobs/ai-job-settings-step.js.map +1 -1
- package/src/lib/jobs/ai-job-site-datasets.d.ts +27 -0
- package/src/lib/jobs/ai-job-site-datasets.js +140 -14
- package/src/lib/jobs/ai-job-site-datasets.js.map +1 -1
- package/src/lib/jobs/ai-job-site-look.js +18 -5
- package/src/lib/jobs/ai-job-site-look.js.map +1 -1
- package/src/lib/jobs/ai-job-site-step.d.ts +16 -1
- package/src/lib/jobs/ai-job-site-step.js +95 -22
- package/src/lib/jobs/ai-job-site-step.js.map +1 -1
- package/src/lib/jobs/ai-job-text-step.d.ts +15 -1
- package/src/lib/jobs/ai-job-text-step.js.map +1 -1
- package/src/lib/jobs/ai-job-tracking-step.d.ts +14 -3
- package/src/lib/jobs/ai-job-tracking-step.js +28 -5
- package/src/lib/jobs/ai-job-tracking-step.js.map +1 -1
- package/src/lib/jobs/ai-jobs.d.ts +13 -1
- package/src/lib/jobs/ai-jobs.js +77 -10
- package/src/lib/jobs/ai-jobs.js.map +1 -1
- package/src/lib/jobs/ai-layout-owner-photos.d.ts +16 -1
- package/src/lib/jobs/ai-layout-owner-photos.js +47 -0
- package/src/lib/jobs/ai-layout-owner-photos.js.map +1 -1
- package/src/lib/jobs/ai-layout-stock-photos.d.ts +27 -2
- package/src/lib/jobs/ai-layout-stock-photos.js +190 -19
- package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
- package/src/lib/jobs/ai-site-inspiration-signals.d.ts +98 -0
- package/src/lib/jobs/ai-site-inspiration-signals.js +585 -0
- package/src/lib/jobs/ai-site-inspiration-signals.js.map +1 -0
- package/src/lib/jobs/ai-site-inspiration.d.ts +157 -0
- package/src/lib/jobs/ai-site-inspiration.js +346 -0
- package/src/lib/jobs/ai-site-inspiration.js.map +1 -0
- package/src/lib/jobs/ai-site-publish.d.ts +26 -1
- package/src/lib/jobs/ai-site-publish.js +162 -13
- package/src/lib/jobs/ai-site-publish.js.map +1 -1
- package/src/lib/jobs/ai-theme-brand-inputs.d.ts +77 -3
- package/src/lib/jobs/ai-theme-brand-inputs.js +135 -31
- package/src/lib/jobs/ai-theme-brand-inputs.js.map +1 -1
- package/src/lib/jobs/fixtures/ai-candle-shop-free-plan-recording.d.ts +97 -0
- package/src/lib/jobs/fixtures/ai-candle-shop-free-plan-recording.js +137 -0
- package/src/lib/jobs/fixtures/ai-candle-shop-free-plan-recording.js.map +1 -0
- package/src/lib/jobs/fixtures/ai-site-inspiration-pages.d.ts +38 -0
- package/src/lib/jobs/fixtures/ai-site-inspiration-pages.js +103 -0
- package/src/lib/jobs/fixtures/ai-site-inspiration-pages.js.map +1 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-agl3988-recordings.d.ts +25 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-agl3988-recordings.js +174 -0
- package/src/lib/jobs/fixtures/ai-stock-photo-agl3988-recordings.js.map +1 -0
- package/src/lib/layout-language/ai-layout-compiler.d.ts +1 -1
- package/src/lib/layout-language/ai-layout-compiler.js +438 -25
- package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
- package/src/lib/layout-language/ai-layout-contact.d.ts +57 -0
- package/src/lib/layout-language/ai-layout-contact.js +137 -0
- package/src/lib/layout-language/ai-layout-contact.js.map +1 -0
- package/src/lib/layout-language/ai-layout-copy.d.ts +12 -0
- package/src/lib/layout-language/ai-layout-copy.js +34 -2
- package/src/lib/layout-language/ai-layout-copy.js.map +1 -1
- package/src/lib/layout-language/ai-layout-design.d.ts +65 -8
- package/src/lib/layout-language/ai-layout-design.js +411 -97
- package/src/lib/layout-language/ai-layout-design.js.map +1 -1
- package/src/lib/layout-language/ai-layout-frame.js +23 -0
- package/src/lib/layout-language/ai-layout-frame.js.map +1 -1
- package/src/lib/layout-language/ai-layout-labels.d.ts +2 -0
- package/src/lib/layout-language/ai-layout-labels.js +12 -6
- package/src/lib/layout-language/ai-layout-labels.js.map +1 -1
- package/src/lib/layout-language/ai-layout-language.d.ts +3 -2
- package/src/lib/layout-language/ai-layout-language.js +4 -3
- package/src/lib/layout-language/ai-layout-language.js.map +1 -1
- package/src/lib/layout-language/ai-layout-links.d.ts +13 -0
- package/src/lib/layout-language/ai-layout-links.js +30 -0
- package/src/lib/layout-language/ai-layout-links.js.map +1 -1
- package/src/lib/layout-language/ai-layout-pictures.d.ts +49 -6
- package/src/lib/layout-language/ai-layout-pictures.js +145 -22
- package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
- package/src/lib/model/ai-build-job.d.ts +16 -1
- package/src/lib/model/ai-build-job.js +40 -0
- package/src/lib/model/ai-build-job.js.map +1 -1
- package/src/lib/model/ai-credit-estimate.d.ts +20 -0
- package/src/lib/model/ai-credit-estimate.js +13 -0
- package/src/lib/model/ai-credit-estimate.js.map +1 -1
- package/src/lib/model/ai-job-activity.d.ts +2 -2
- package/src/lib/model/ai-job-activity.js +6 -4
- package/src/lib/model/ai-job-activity.js.map +1 -1
- package/src/lib/model/ai-job-input.d.ts +33 -0
- package/src/lib/model/ai-job-input.js +59 -0
- package/src/lib/model/ai-job-input.js.map +1 -0
- package/src/lib/model/ai-job-notice.d.ts +4 -0
- package/src/lib/model/ai-job-notice.js +28 -4
- package/src/lib/model/ai-job-notice.js.map +1 -1
- package/src/lib/model/ai-jobs.types.d.ts +69 -3
- package/src/lib/model/ai-jobs.types.js +2 -1
- package/src/lib/model/ai-jobs.types.js.map +1 -1
- package/src/lib/model/ai-site-details.d.ts +122 -0
- package/src/lib/model/ai-site-details.js +300 -0
- package/src/lib/model/ai-site-details.js.map +1 -0
- package/src/lib/model/ai-site-entity.d.ts +181 -0
- package/src/lib/model/ai-site-entity.js +390 -0
- package/src/lib/model/ai-site-entity.js.map +1 -0
- package/src/lib/model/ai-site-import-view.d.ts +130 -0
- package/src/lib/model/ai-site-import-view.js +182 -0
- package/src/lib/model/ai-site-import-view.js.map +1 -0
- package/src/lib/model/ai-site-import.d.ts +302 -0
- package/src/lib/model/ai-site-import.js +526 -0
- package/src/lib/model/ai-site-import.js.map +1 -0
- package/src/lib/model/ai-site-inspiration.d.ts +72 -0
- package/src/lib/model/ai-site-inspiration.js +109 -0
- package/src/lib/model/ai-site-inspiration.js.map +1 -0
- package/src/lib/model/ai-site-job.d.ts +13 -1
- package/src/lib/model/ai-site-job.js +15 -2
- package/src/lib/model/ai-site-job.js.map +1 -1
- package/src/lib/model/ai-site-kinds.js +109 -21
- package/src/lib/model/ai-site-kinds.js.map +1 -1
- package/src/lib/model/ai-site-look.d.ts +10 -0
- package/src/lib/model/ai-site-look.js +24 -0
- package/src/lib/model/ai-site-look.js.map +1 -1
- package/src/lib/model/ai-site-start-seo.d.ts +19 -5
- package/src/lib/model/ai-site-start-seo.js +25 -7
- package/src/lib/model/ai-site-start-seo.js.map +1 -1
- package/src/lib/model/ai-site-start-steps.d.ts +9 -3
- package/src/lib/model/ai-site-start-steps.js +67 -5
- package/src/lib/model/ai-site-start-steps.js.map +1 -1
- package/src/lib/model/ai-site-start.d.ts +81 -4
- package/src/lib/model/ai-site-start.js +207 -13
- package/src/lib/model/ai-site-start.js.map +1 -1
- package/src/lib/model/ai-upsell-copy.js +4 -1
- package/src/lib/model/ai-upsell-copy.js.map +1 -1
- package/src/lib/plan-entitlements.d.ts +4 -3
- package/src/lib/plan-entitlements.js +3 -8
- package/src/lib/plan-entitlements.js.map +1 -1
- package/src/lib/runtime/ai-dataset-generation.d.ts +8 -1
- package/src/lib/runtime/ai-dataset-generation.js +12 -2
- package/src/lib/runtime/ai-dataset-generation.js.map +1 -1
- package/src/lib/runtime/ai-doctrine-validators.d.ts +26 -33
- package/src/lib/runtime/ai-doctrine-validators.js +54 -44
- 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-node-tree.d.ts +7 -0
- package/src/lib/runtime/ai-node-tree.js +9 -1
- package/src/lib/runtime/ai-node-tree.js.map +1 -1
- package/src/lib/server/ai-create-route.d.ts +1 -1
- package/src/lib/server/ai-create-route.js +3 -4
- package/src/lib/server/ai-create-route.js.map +1 -1
- package/src/lib/server/ai-jobs-resume.js +27 -3
- package/src/lib/server/ai-jobs-resume.js.map +1 -1
- package/src/lib/server/ai-jobs-route.js +18 -0
- package/src/lib/server/ai-jobs-route.js.map +1 -1
- package/src/lib/server/ai-site-import-route.d.ts +89 -0
- package/src/lib/server/ai-site-import-route.js +356 -0
- package/src/lib/server/ai-site-import-route.js.map +1 -0
- package/src/lib/server.js +14 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/site-import/fixtures/site-import-crawl-recordings.d.ts +45 -0
- package/src/lib/site-import/fixtures/site-import-crawl-recordings.js +313 -0
- package/src/lib/site-import/fixtures/site-import-crawl-recordings.js.map +1 -0
- package/src/lib/site-import/fixtures/site-import-wave-recordings.d.ts +50 -0
- package/src/lib/site-import/fixtures/site-import-wave-recordings.js +217 -0
- package/src/lib/site-import/fixtures/site-import-wave-recordings.js.map +1 -0
- package/src/lib/site-import/site-import-crawl.d.ts +163 -0
- package/src/lib/site-import/site-import-crawl.js +467 -0
- package/src/lib/site-import/site-import-crawl.js.map +1 -0
- package/src/lib/site-import/site-import-flag.d.ts +23 -0
- package/src/lib/site-import/site-import-flag.js +23 -0
- package/src/lib/site-import/site-import-flag.js.map +1 -0
- package/src/lib/site-import/site-import-html.d.ts +94 -0
- package/src/lib/site-import/site-import-html.js +462 -0
- package/src/lib/site-import/site-import-html.js.map +1 -0
- package/src/lib/site-import/site-import-images.d.ts +54 -0
- package/src/lib/site-import/site-import-images.js +204 -0
- package/src/lib/site-import/site-import-images.js.map +1 -0
- package/src/lib/site-import/site-import-page.d.ts +48 -0
- package/src/lib/site-import/site-import-page.js +150 -0
- package/src/lib/site-import/site-import-page.js.map +1 -0
- package/src/lib/site-import/site-import-review.d.ts +40 -0
- package/src/lib/site-import/site-import-review.js +149 -0
- package/src/lib/site-import/site-import-review.js.map +1 -0
- package/src/lib/site-import/site-import-robots.d.ts +52 -0
- package/src/lib/site-import/site-import-robots.js +126 -0
- package/src/lib/site-import/site-import-robots.js.map +1 -0
- package/src/lib/usage/assist-credits.d.ts +4 -4
- package/src/lib/usage/assist-credits.js +4 -4
- package/src/lib/usage/assist-credits.js.map +1 -1
- package/src/lib/usage/staff-org-ai-view.js +6 -3
- package/src/lib/usage/staff-org-ai-view.js.map +1 -1
- package/src/lib/usage/staff-org-ai.d.ts +4 -2
- package/src/lib/usage/staff-org-ai.js +7 -7
- package/src/lib/usage/staff-org-ai.js.map +1 -1
- package/src/lib/usage/staff-org-usage-ai.js +2 -2
- package/src/lib/usage/staff-org-usage-ai.js.map +1 -1
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
*/ /*==========================================
|
|
18
|
+
* EVERY SITE IMPORT IS LOGGED FOR TRUST & SAFETY (AGL-3976).
|
|
19
|
+
*
|
|
20
|
+
* A site import is a one-click copy of a website, which is exactly what a
|
|
21
|
+
* phishing clone is. Proving ownership is the control; this row is how a
|
|
22
|
+
* person checks the control held. Every import job files ONE row in the
|
|
23
|
+
* abuse queue (`abuseReports`), in the shape the outbound screen files a
|
|
24
|
+
* hold (`fileOutboundHold`): the same fields the admin page and the daily
|
|
25
|
+
* review already read, `source: 'site-import'`, the proved domain as text
|
|
26
|
+
* (the queue never renders a reported address as a link), and the proof.
|
|
27
|
+
*
|
|
28
|
+
* The row is written when the site is read and rewritten as each wave
|
|
29
|
+
* starts and finishes, its `reportCount` counting the waves, so the queue
|
|
30
|
+
* shows one import as one row however long it runs. It holds nothing: the
|
|
31
|
+
* import goes on while the row is open. Staff look at the old site and the
|
|
32
|
+
* new one; a workspace bringing in a site that is not its own is locked,
|
|
33
|
+
* and the row closed with what was done.
|
|
34
|
+
*
|
|
35
|
+
* Never throws: a log that cannot be written must not stop a person's
|
|
36
|
+
* import, and the failure is a log line staff alerts read.
|
|
37
|
+
*=========================================*/ import { ABUSE_REPORT_COLLECTION, ABUSE_REPORT_MAX_DETAILS } from "@aglyn/aglyn/app-utils/abuse-report";
|
|
38
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
39
|
+
import { AI_SITE_IMPORT_LEFT_OUT_REASONS, aiSiteImportPagesText } from "../model/ai-site-import.js";
|
|
40
|
+
/** The abuse queue's `source` for a site import's row. */ export const SITE_IMPORT_REVIEW_SOURCE = 'site-import';
|
|
41
|
+
/** One row per import job. */ export function siteImportReviewId(jobId) {
|
|
42
|
+
return `site-import-${jobId}`;
|
|
43
|
+
}
|
|
44
|
+
/** The reference staff quote: `SI-` and the job's id. */ export function siteImportReviewReference(jobId) {
|
|
45
|
+
return `SI-${jobId.slice(0, 10).toUpperCase()}`;
|
|
46
|
+
}
|
|
47
|
+
const PROOF_WORDS = {
|
|
48
|
+
dns: 'a DNS TXT record',
|
|
49
|
+
meta: 'a meta tag on its home page',
|
|
50
|
+
email: 'a code emailed to an address at the domain'
|
|
51
|
+
};
|
|
52
|
+
/** The row's details: what was proved, read, kept, left out and brought in, rebuilt whole each time. */ export function siteImportReviewDetails(input) {
|
|
53
|
+
var _PROOF_WORDS_state_ownership_method, _state_leftOutCount_loginform;
|
|
54
|
+
const { state, items } = input;
|
|
55
|
+
const proof = state.ownership.method ? (_PROOF_WORDS_state_ownership_method = PROOF_WORDS[state.ownership.method]) != null ? _PROOF_WORDS_state_ownership_method : state.ownership.method : 'an unknown method';
|
|
56
|
+
const provenOn = state.ownership.provenAt ? new Date(state.ownership.provenAt).toISOString().slice(0, 10) : 'an unknown day';
|
|
57
|
+
const leftOut = AI_SITE_IMPORT_LEFT_OUT_REASONS.filter((reason)=>{
|
|
58
|
+
var _state_leftOutCount_reason;
|
|
59
|
+
return ((_state_leftOutCount_reason = state.leftOutCount[reason]) != null ? _state_leftOutCount_reason : 0) > 0;
|
|
60
|
+
}).map((reason)=>`${reason} ${state.leftOutCount[reason]}`).join(', ');
|
|
61
|
+
const built = items.filter((row)=>row.op === 'page' && (row.status === 'succeeded' || row.status === 'degraded')).length;
|
|
62
|
+
const started = state.waves.filter((wave)=>wave.status !== 'waiting').length;
|
|
63
|
+
const photos = state.pages.reduce((total, page)=>{
|
|
64
|
+
var _ref;
|
|
65
|
+
var _page_photos;
|
|
66
|
+
return total + ((_ref = (_page_photos = page.photos) == null ? void 0 : _page_photos.brought) != null ? _ref : 0);
|
|
67
|
+
}, 0);
|
|
68
|
+
const lines = [
|
|
69
|
+
`Site import: ${state.domain} into site ${input.hostId} (AI job ${input.jobId}).`,
|
|
70
|
+
`Ownership proven by ${proof}${state.ownership.detail ? ` (${state.ownership.detail})` : ''} on ${provenOn}.`,
|
|
71
|
+
`Read ${aiSiteImportPagesText(state.found)}; bringing in ${aiSiteImportPagesText(state.pages.length)} in ${state.waves.length} ${state.waves.length === 1 ? 'wave' : 'waves'}` + `${state.pageLimit === null ? '' : ` (plan page count ${state.pageLimit}, ${state.existingPages} already on the site)`}.`,
|
|
72
|
+
...leftOut ? [
|
|
73
|
+
`Left out: ${leftOut}.`
|
|
74
|
+
] : [],
|
|
75
|
+
...((_state_leftOutCount_loginform = state.leftOutCount['login-form']) != null ? _state_leftOutCount_loginform : 0) > 0 ? [
|
|
76
|
+
`Sign-in pages refused: ${state.leftOutCount['login-form']}. A site with sign-in pages is worth a closer look.`
|
|
77
|
+
] : [],
|
|
78
|
+
`Waves started: ${started} of ${state.waves.length}. Pages brought in so far: ${built}. Photos copied into the media library: ${photos}.`,
|
|
79
|
+
'Nothing is published by the import: its pages are drafts until a member publishes them.',
|
|
80
|
+
'Look at the old site and the new one. If this copies a site the workspace does not own, lock the workspace, then close this row with what you did; otherwise dismiss it.'
|
|
81
|
+
];
|
|
82
|
+
return lines.join('\n').slice(0, ABUSE_REPORT_MAX_DETAILS);
|
|
83
|
+
}
|
|
84
|
+
/** Writes (or rewrites) the import's row in the abuse queue. Never throws. */ export async function fileSiteImportReview(firestore, input) {
|
|
85
|
+
const reviewId = siteImportReviewId(input.jobId);
|
|
86
|
+
try {
|
|
87
|
+
var _input_state_leftOutCount_loginform;
|
|
88
|
+
const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId);
|
|
89
|
+
const first = !(await ref.get()).exists;
|
|
90
|
+
const built = input.items.filter((row)=>row.op === 'page' && (row.status === 'succeeded' || row.status === 'degraded')).length;
|
|
91
|
+
await ref.set(_extends({
|
|
92
|
+
reference: siteImportReviewReference(input.jobId),
|
|
93
|
+
// "Impersonation or trademark": the harm a copied site does is
|
|
94
|
+
// wearing a business it is not. Normal severity: the proof already
|
|
95
|
+
// held, and this row is the check that it did.
|
|
96
|
+
category: 'impersonation',
|
|
97
|
+
severity: 'normal',
|
|
98
|
+
source: SITE_IMPORT_REVIEW_SOURCE,
|
|
99
|
+
// The proved site as TEXT: the queue never renders it as a link.
|
|
100
|
+
url: `https://${input.state.domain}/`,
|
|
101
|
+
reportedHostname: input.state.domain,
|
|
102
|
+
hostId: input.hostId,
|
|
103
|
+
orgId: input.orgId,
|
|
104
|
+
details: siteImportReviewDetails(input),
|
|
105
|
+
reporterEmail: null,
|
|
106
|
+
reporterName: null,
|
|
107
|
+
dmca: null
|
|
108
|
+
}, input.event === 'wave-started' ? {
|
|
109
|
+
reportCount: FieldValue.increment(1)
|
|
110
|
+
} : first ? {
|
|
111
|
+
reportCount: 0
|
|
112
|
+
} : {}, {
|
|
113
|
+
siteImport: _extends({
|
|
114
|
+
jobId: input.jobId,
|
|
115
|
+
domain: input.state.domain,
|
|
116
|
+
method: input.state.ownership.method,
|
|
117
|
+
provenAt: input.state.ownership.provenAt,
|
|
118
|
+
detail: input.state.ownership.detail,
|
|
119
|
+
found: input.state.found,
|
|
120
|
+
kept: input.state.pages.length,
|
|
121
|
+
waves: input.state.waves.length,
|
|
122
|
+
wavesStarted: input.state.waves.filter((wave)=>wave.status !== 'waiting').length,
|
|
123
|
+
pagesBrought: built,
|
|
124
|
+
loginPagesRefused: (_input_state_leftOutCount_loginform = input.state.leftOutCount['login-form']) != null ? _input_state_leftOutCount_loginform : 0,
|
|
125
|
+
lastEvent: input.event
|
|
126
|
+
}, input.wave !== undefined ? {
|
|
127
|
+
lastWave: input.wave
|
|
128
|
+
} : {}, {
|
|
129
|
+
atMs: input.nowMs
|
|
130
|
+
}),
|
|
131
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
132
|
+
}, first ? {
|
|
133
|
+
status: 'open',
|
|
134
|
+
createdAt: FieldValue.serverTimestamp()
|
|
135
|
+
} : {}), {
|
|
136
|
+
merge: true
|
|
137
|
+
});
|
|
138
|
+
return reviewId;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error('[site-import] trust and safety row failed', {
|
|
141
|
+
orgId: input.orgId,
|
|
142
|
+
jobId: input.jobId,
|
|
143
|
+
error
|
|
144
|
+
});
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
//# sourceMappingURL=site-import-review.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/site-import/site-import-review.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * EVERY SITE IMPORT IS LOGGED FOR TRUST & SAFETY (AGL-3976).\n *\n * A site import is a one-click copy of a website, which is exactly what a\n * phishing clone is. Proving ownership is the control; this row is how a\n * person checks the control held. Every import job files ONE row in the\n * abuse queue (`abuseReports`), in the shape the outbound screen files a\n * hold (`fileOutboundHold`): the same fields the admin page and the daily\n * review already read, `source: 'site-import'`, the proved domain as text\n * (the queue never renders a reported address as a link), and the proof.\n *\n * The row is written when the site is read and rewritten as each wave\n * starts and finishes, its `reportCount` counting the waves, so the queue\n * shows one import as one row however long it runs. It holds nothing: the\n * import goes on while the row is open. Staff look at the old site and the\n * new one; a workspace bringing in a site that is not its own is locked,\n * and the row closed with what was done.\n *\n * Never throws: a log that cannot be written must not stop a person's\n * import, and the failure is a log line staff alerts read.\n *=========================================*/\n\nimport { ABUSE_REPORT_COLLECTION, ABUSE_REPORT_MAX_DETAILS } from '@aglyn/aglyn/app-utils/abuse-report'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n AI_SITE_IMPORT_LEFT_OUT_REASONS,\n aiSiteImportPagesText,\n type AiSiteImportState,\n} from '../model/ai-site-import'\nimport type { AiJobItemLedger } from '../model/ai-jobs.types'\n\n/** The abuse queue's `source` for a site import's row. */\nexport const SITE_IMPORT_REVIEW_SOURCE = 'site-import'\n\n/** One row per import job. */\nexport function siteImportReviewId(jobId: string): string {\n return `site-import-${jobId}`\n}\n\n/** The reference staff quote: `SI-` and the job's id. */\nexport function siteImportReviewReference(jobId: string): string {\n return `SI-${jobId.slice(0, 10).toUpperCase()}`\n}\n\nconst PROOF_WORDS: Record<string, string> = {\n dns: 'a DNS TXT record',\n meta: 'a meta tag on its home page',\n email: 'a code emailed to an address at the domain',\n}\n\nexport type SiteImportReviewEvent = 'read' | 'wave-started' | 'wave-finished'\n\nexport interface SiteImportReviewInput {\n orgId: string\n hostId: string\n jobId: string\n state: AiSiteImportState\n items: readonly AiJobItemLedger[]\n event: SiteImportReviewEvent\n /** The wave the event is about; absent on `read`. */\n wave?: number\n nowMs: number\n}\n\n/** The row's details: what was proved, read, kept, left out and brought in, rebuilt whole each time. */\nexport function siteImportReviewDetails(input: SiteImportReviewInput): string {\n const { state, items } = input\n const proof = state.ownership.method ? (PROOF_WORDS[state.ownership.method] ?? state.ownership.method) : 'an unknown method'\n const provenOn = state.ownership.provenAt ? new Date(state.ownership.provenAt).toISOString().slice(0, 10) : 'an unknown day'\n const leftOut = AI_SITE_IMPORT_LEFT_OUT_REASONS.filter((reason) => (state.leftOutCount[reason] ?? 0) > 0)\n .map((reason) => `${reason} ${state.leftOutCount[reason]}`)\n .join(', ')\n const built = items.filter((row) => row.op === 'page' && (row.status === 'succeeded' || row.status === 'degraded')).length\n const started = state.waves.filter((wave) => wave.status !== 'waiting').length\n const photos = state.pages.reduce((total, page) => total + (page.photos?.brought ?? 0), 0)\n const lines = [\n `Site import: ${state.domain} into site ${input.hostId} (AI job ${input.jobId}).`,\n `Ownership proven by ${proof}${state.ownership.detail ? ` (${state.ownership.detail})` : ''} on ${provenOn}.`,\n `Read ${aiSiteImportPagesText(state.found)}; bringing in ${aiSiteImportPagesText(state.pages.length)} in ${state.waves.length} ${state.waves.length === 1 ? 'wave' : 'waves'}` +\n `${state.pageLimit === null ? '' : ` (plan page count ${state.pageLimit}, ${state.existingPages} already on the site)`}.`,\n ...(leftOut ? [`Left out: ${leftOut}.`] : []),\n ...((state.leftOutCount['login-form'] ?? 0) > 0\n ? [`Sign-in pages refused: ${state.leftOutCount['login-form']}. A site with sign-in pages is worth a closer look.`]\n : []),\n `Waves started: ${started} of ${state.waves.length}. Pages brought in so far: ${built}. Photos copied into the media library: ${photos}.`,\n 'Nothing is published by the import: its pages are drafts until a member publishes them.',\n 'Look at the old site and the new one. If this copies a site the workspace does not own, lock the workspace, then close this row with what you did; otherwise dismiss it.',\n ]\n return lines.join('\\n').slice(0, ABUSE_REPORT_MAX_DETAILS)\n}\n\n/** Writes (or rewrites) the import's row in the abuse queue. Never throws. */\nexport async function fileSiteImportReview(\n firestore: FirebaseFirestore.Firestore,\n input: SiteImportReviewInput,\n): Promise<string | null> {\n const reviewId = siteImportReviewId(input.jobId)\n try {\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n const built = input.items.filter((row) => row.op === 'page' && (row.status === 'succeeded' || row.status === 'degraded')).length\n await ref.set(\n {\n reference: siteImportReviewReference(input.jobId),\n // \"Impersonation or trademark\": the harm a copied site does is\n // wearing a business it is not. Normal severity: the proof already\n // held, and this row is the check that it did.\n category: 'impersonation',\n severity: 'normal',\n source: SITE_IMPORT_REVIEW_SOURCE,\n // The proved site as TEXT: the queue never renders it as a link.\n url: `https://${input.state.domain}/`,\n reportedHostname: input.state.domain,\n hostId: input.hostId,\n orgId: input.orgId,\n details: siteImportReviewDetails(input),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n // One a wave started, so a long import reads as the volume it is.\n ...(input.event === 'wave-started' ? { reportCount: FieldValue.increment(1) } : first ? { reportCount: 0 } : {}),\n siteImport: {\n jobId: input.jobId,\n domain: input.state.domain,\n method: input.state.ownership.method,\n provenAt: input.state.ownership.provenAt,\n detail: input.state.ownership.detail,\n found: input.state.found,\n kept: input.state.pages.length,\n waves: input.state.waves.length,\n wavesStarted: input.state.waves.filter((wave) => wave.status !== 'waiting').length,\n pagesBrought: built,\n loginPagesRefused: input.state.leftOutCount['login-form'] ?? 0,\n lastEvent: input.event,\n ...(input.wave !== undefined ? { lastWave: input.wave } : {}),\n atMs: input.nowMs,\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(first ? { status: 'open', createdAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n return reviewId\n } catch (error) {\n console.error('[site-import] trust and safety row failed', { orgId: input.orgId, jobId: input.jobId, error })\n return null\n }\n}\n"],"names":["ABUSE_REPORT_COLLECTION","ABUSE_REPORT_MAX_DETAILS","FieldValue","AI_SITE_IMPORT_LEFT_OUT_REASONS","aiSiteImportPagesText","SITE_IMPORT_REVIEW_SOURCE","siteImportReviewId","jobId","siteImportReviewReference","slice","toUpperCase","PROOF_WORDS","dns","meta","email","siteImportReviewDetails","input","state","items","proof","ownership","method","provenOn","provenAt","Date","toISOString","leftOut","filter","reason","leftOutCount","map","join","built","row","op","status","length","started","waves","wave","photos","pages","reduce","total","page","brought","lines","domain","hostId","detail","found","pageLimit","existingPages","fileSiteImportReview","firestore","reviewId","ref","collection","doc","first","get","exists","set","reference","category","severity","source","url","reportedHostname","orgId","details","reporterEmail","reporterName","dmca","event","reportCount","increment","siteImport","kept","wavesStarted","pagesBrought","loginPagesRefused","lastEvent","undefined","lastWave","atMs","nowMs","updatedAt","serverTimestamp","createdAt","merge","error","console"],"mappings":"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"}
|
|
@@ -0,0 +1,52 @@
|
|
|
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 product token AglynBot matches robots.txt groups by. */
|
|
18
|
+
export declare const AGLYN_BOT_TOKEN = "aglynbot";
|
|
19
|
+
export interface RobotsRule {
|
|
20
|
+
allow: boolean;
|
|
21
|
+
/** The path pattern as written, `*` and `$` included. */
|
|
22
|
+
pattern: string;
|
|
23
|
+
}
|
|
24
|
+
export interface RobotsGroup {
|
|
25
|
+
/** Lowercased product tokens, version suffixes removed. */
|
|
26
|
+
agents: string[];
|
|
27
|
+
rules: RobotsRule[];
|
|
28
|
+
}
|
|
29
|
+
export interface RobotsTxt {
|
|
30
|
+
groups: RobotsGroup[];
|
|
31
|
+
/** Every `Sitemap:` address, as written. */
|
|
32
|
+
sitemaps: string[];
|
|
33
|
+
}
|
|
34
|
+
/** robots.txt as groups and sitemaps; unknown lines are skipped. */
|
|
35
|
+
export declare function parseRobotsTxt(text: string): RobotsTxt;
|
|
36
|
+
/**
|
|
37
|
+
* The rules that apply to `token`: every group naming it, merged; when none
|
|
38
|
+
* does, every `*` group, merged; when neither exists, none (all allowed).
|
|
39
|
+
*/
|
|
40
|
+
export declare function robotsRulesFor(robots: RobotsTxt, token?: string): RobotsRule[];
|
|
41
|
+
/**
|
|
42
|
+
* Whether `pathWithQuery` (`/a/b?c=d`) may be crawled under `rules`.
|
|
43
|
+
* `/robots.txt` itself is always allowed.
|
|
44
|
+
*/
|
|
45
|
+
export declare function robotsAllows(rules: readonly RobotsRule[], pathWithQuery: string): boolean;
|
|
46
|
+
export interface SitemapEntries {
|
|
47
|
+
/** True for a `<sitemapindex>`: `locations` are sitemaps, not pages. */
|
|
48
|
+
index: boolean;
|
|
49
|
+
locations: string[];
|
|
50
|
+
}
|
|
51
|
+
/** The `<loc>` addresses of a sitemap or a sitemap index, entities decoded. */
|
|
52
|
+
export declare function parseSitemap(xml: string, limit?: number): SitemapEntries;
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
* ROBOTS.TXT AND SITEMAPS, FOR AGLYNBOT (AGL-3974).
|
|
18
|
+
*
|
|
19
|
+
* The site-import crawler asks a site's robots.txt before every page, as
|
|
20
|
+
* RFC 9309 describes it: the group naming `AglynBot` if there is one, else
|
|
21
|
+
* the `*` group; the longest matching rule decides, and `Allow` wins a tie;
|
|
22
|
+
* `*` matches any run and a trailing `$` anchors the end. A site that keeps
|
|
23
|
+
* a path from crawlers keeps it from this one, even though the person asking
|
|
24
|
+
* owns the site: the file is the site's own statement of what a robot may
|
|
25
|
+
* read, and the person can change it.
|
|
26
|
+
*
|
|
27
|
+
* Sitemaps are read for their `<loc>` addresses, and a sitemap index for the
|
|
28
|
+
* sitemaps it lists. Gzipped sitemaps are not read.
|
|
29
|
+
*=========================================*/ /** The product token AglynBot matches robots.txt groups by. */ export const AGLYN_BOT_TOKEN = 'aglynbot';
|
|
30
|
+
/** robots.txt as groups and sitemaps; unknown lines are skipped. */ export function parseRobotsTxt(text) {
|
|
31
|
+
const groups = [];
|
|
32
|
+
const sitemaps = [];
|
|
33
|
+
let group = null;
|
|
34
|
+
let lastWasAgent = false;
|
|
35
|
+
for (const rawLine of String(text != null ? text : '').split(/\r\n|\r|\n/)){
|
|
36
|
+
const line = rawLine.replace(/#.*$/, '').trim();
|
|
37
|
+
if (!line) continue;
|
|
38
|
+
const colon = line.indexOf(':');
|
|
39
|
+
if (colon <= 0) continue;
|
|
40
|
+
const key = line.slice(0, colon).trim().toLowerCase();
|
|
41
|
+
const value = line.slice(colon + 1).trim();
|
|
42
|
+
if (key === 'sitemap') {
|
|
43
|
+
if (value) sitemaps.push(value);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (key === 'user-agent') {
|
|
47
|
+
const agent = value.toLowerCase().split('/')[0].trim();
|
|
48
|
+
if (!group || !lastWasAgent) {
|
|
49
|
+
group = {
|
|
50
|
+
agents: [],
|
|
51
|
+
rules: []
|
|
52
|
+
};
|
|
53
|
+
groups.push(group);
|
|
54
|
+
}
|
|
55
|
+
if (agent) group.agents.push(agent);
|
|
56
|
+
lastWasAgent = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
lastWasAgent = false;
|
|
60
|
+
if (!group) continue;
|
|
61
|
+
if (key === 'allow' || key === 'disallow') {
|
|
62
|
+
// An empty Disallow allows everything, which is what no rule does.
|
|
63
|
+
if (!value) continue;
|
|
64
|
+
group.rules.push({
|
|
65
|
+
allow: key === 'allow',
|
|
66
|
+
pattern: value
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
groups,
|
|
72
|
+
sitemaps
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The rules that apply to `token`: every group naming it, merged; when none
|
|
77
|
+
* does, every `*` group, merged; when neither exists, none (all allowed).
|
|
78
|
+
*/ export function robotsRulesFor(robots, token = AGLYN_BOT_TOKEN) {
|
|
79
|
+
const own = robots.groups.filter((group)=>group.agents.includes(token));
|
|
80
|
+
const chosen = own.length ? own : robots.groups.filter((group)=>group.agents.includes('*'));
|
|
81
|
+
return chosen.flatMap((group)=>group.rules);
|
|
82
|
+
}
|
|
83
|
+
function patternMatchLength(pattern, path) {
|
|
84
|
+
const anchored = pattern.endsWith('$');
|
|
85
|
+
const body = anchored ? pattern.slice(0, -1) : pattern;
|
|
86
|
+
const source = body.split('*').map((part)=>part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*');
|
|
87
|
+
const regex = new RegExp(`^${source}${anchored ? '$' : ''}`);
|
|
88
|
+
return regex.test(path) ? body.length : -1;
|
|
89
|
+
}
|
|
90
|
+
/** A path with a percent-encoding case that compares the way robots.txt writes it. */ function comparablePath(path) {
|
|
91
|
+
return path.replace(/%[0-9a-f]{2}/gi, (escape)=>escape.toUpperCase());
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Whether `pathWithQuery` (`/a/b?c=d`) may be crawled under `rules`.
|
|
95
|
+
* `/robots.txt` itself is always allowed.
|
|
96
|
+
*/ export function robotsAllows(rules, pathWithQuery) {
|
|
97
|
+
const path = comparablePath(pathWithQuery || '/');
|
|
98
|
+
if (path === '/robots.txt') return true;
|
|
99
|
+
let best = -1;
|
|
100
|
+
let allowed = true;
|
|
101
|
+
for (const rule of rules){
|
|
102
|
+
const length = patternMatchLength(comparablePath(rule.pattern), path);
|
|
103
|
+
if (length < 0) continue;
|
|
104
|
+
if (length > best || length === best && rule.allow) {
|
|
105
|
+
best = length;
|
|
106
|
+
allowed = rule.allow;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return allowed;
|
|
110
|
+
}
|
|
111
|
+
/** The `<loc>` addresses of a sitemap or a sitemap index, entities decoded. */ export function parseSitemap(xml, limit = 5000) {
|
|
112
|
+
const source = String(xml != null ? xml : '');
|
|
113
|
+
const index = /<sitemapindex\b/i.test(source);
|
|
114
|
+
const locations = [];
|
|
115
|
+
for (const match of source.matchAll(/<loc\b[^>]*>([\s\S]*?)<\/loc\s*>/gi)){
|
|
116
|
+
const value = match[1].replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").trim();
|
|
117
|
+
if (value) locations.push(value);
|
|
118
|
+
if (locations.length >= limit) break;
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
index,
|
|
122
|
+
locations
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
//# sourceMappingURL=site-import-robots.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/site-import/site-import-robots.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * ROBOTS.TXT AND SITEMAPS, FOR AGLYNBOT (AGL-3974).\n *\n * The site-import crawler asks a site's robots.txt before every page, as\n * RFC 9309 describes it: the group naming `AglynBot` if there is one, else\n * the `*` group; the longest matching rule decides, and `Allow` wins a tie;\n * `*` matches any run and a trailing `$` anchors the end. A site that keeps\n * a path from crawlers keeps it from this one, even though the person asking\n * owns the site: the file is the site's own statement of what a robot may\n * read, and the person can change it.\n *\n * Sitemaps are read for their `<loc>` addresses, and a sitemap index for the\n * sitemaps it lists. Gzipped sitemaps are not read.\n *=========================================*/\n\n/** The product token AglynBot matches robots.txt groups by. */\nexport const AGLYN_BOT_TOKEN = 'aglynbot'\n\nexport interface RobotsRule {\n allow: boolean\n /** The path pattern as written, `*` and `$` included. */\n pattern: string\n}\n\nexport interface RobotsGroup {\n /** Lowercased product tokens, version suffixes removed. */\n agents: string[]\n rules: RobotsRule[]\n}\n\nexport interface RobotsTxt {\n groups: RobotsGroup[]\n /** Every `Sitemap:` address, as written. */\n sitemaps: string[]\n}\n\n/** robots.txt as groups and sitemaps; unknown lines are skipped. */\nexport function parseRobotsTxt(text: string): RobotsTxt {\n const groups: RobotsGroup[] = []\n const sitemaps: string[] = []\n let group: RobotsGroup | null = null\n let lastWasAgent = false\n for (const rawLine of String(text ?? '').split(/\\r\\n|\\r|\\n/)) {\n const line = rawLine.replace(/#.*$/, '').trim()\n if (!line) continue\n const colon = line.indexOf(':')\n if (colon <= 0) continue\n const key = line.slice(0, colon).trim().toLowerCase()\n const value = line.slice(colon + 1).trim()\n if (key === 'sitemap') {\n if (value) sitemaps.push(value)\n continue\n }\n if (key === 'user-agent') {\n const agent = value.toLowerCase().split('/')[0].trim()\n if (!group || !lastWasAgent) {\n group = { agents: [], rules: [] }\n groups.push(group)\n }\n if (agent) group.agents.push(agent)\n lastWasAgent = true\n continue\n }\n lastWasAgent = false\n if (!group) continue\n if (key === 'allow' || key === 'disallow') {\n // An empty Disallow allows everything, which is what no rule does.\n if (!value) continue\n group.rules.push({ allow: key === 'allow', pattern: value })\n }\n }\n return { groups, sitemaps }\n}\n\n/**\n * The rules that apply to `token`: every group naming it, merged; when none\n * does, every `*` group, merged; when neither exists, none (all allowed).\n */\nexport function robotsRulesFor(robots: RobotsTxt, token: string = AGLYN_BOT_TOKEN): RobotsRule[] {\n const own = robots.groups.filter((group) => group.agents.includes(token))\n const chosen = own.length ? own : robots.groups.filter((group) => group.agents.includes('*'))\n return chosen.flatMap((group) => group.rules)\n}\n\nfunction patternMatchLength(pattern: string, path: string): number {\n const anchored = pattern.endsWith('$')\n const body = anchored ? pattern.slice(0, -1) : pattern\n const source = body\n .split('*')\n .map((part) => part.replace(/[.+?^${}()|[\\]\\\\]/g, '\\\\$&'))\n .join('.*')\n const regex = new RegExp(`^${source}${anchored ? '$' : ''}`)\n return regex.test(path) ? body.length : -1\n}\n\n/** A path with a percent-encoding case that compares the way robots.txt writes it. */\nfunction comparablePath(path: string): string {\n return path.replace(/%[0-9a-f]{2}/gi, (escape) => escape.toUpperCase())\n}\n\n/**\n * Whether `pathWithQuery` (`/a/b?c=d`) may be crawled under `rules`.\n * `/robots.txt` itself is always allowed.\n */\nexport function robotsAllows(rules: readonly RobotsRule[], pathWithQuery: string): boolean {\n const path = comparablePath(pathWithQuery || '/')\n if (path === '/robots.txt') return true\n let best = -1\n let allowed = true\n for (const rule of rules) {\n const length = patternMatchLength(comparablePath(rule.pattern), path)\n if (length < 0) continue\n if (length > best || (length === best && rule.allow)) {\n best = length\n allowed = rule.allow\n }\n }\n return allowed\n}\n\nexport interface SitemapEntries {\n /** True for a `<sitemapindex>`: `locations` are sitemaps, not pages. */\n index: boolean\n locations: string[]\n}\n\n/** The `<loc>` addresses of a sitemap or a sitemap index, entities decoded. */\nexport function parseSitemap(xml: string, limit = 5_000): SitemapEntries {\n const source = String(xml ?? '')\n const index = /<sitemapindex\\b/i.test(source)\n const locations: string[] = []\n for (const match of source.matchAll(/<loc\\b[^>]*>([\\s\\S]*?)<\\/loc\\s*>/gi)) {\n const value = match[1]\n .replace(/<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>/g, '$1')\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\")\n .trim()\n if (value) locations.push(value)\n if (locations.length >= limit) break\n }\n return { index, locations }\n}\n"],"names":["AGLYN_BOT_TOKEN","parseRobotsTxt","text","groups","sitemaps","group","lastWasAgent","rawLine","String","split","line","replace","trim","colon","indexOf","key","slice","toLowerCase","value","push","agent","agents","rules","allow","pattern","robotsRulesFor","robots","token","own","filter","includes","chosen","length","flatMap","patternMatchLength","path","anchored","endsWith","body","source","map","part","join","regex","RegExp","test","comparablePath","escape","toUpperCase","robotsAllows","pathWithQuery","best","allowed","rule","parseSitemap","xml","limit","index","locations","match","matchAll"],"mappings":"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"}
|
|
@@ -159,10 +159,10 @@ export declare function assistCreditsFromUsd(usd: number): number;
|
|
|
159
159
|
* So the zero case is now reached only by a per-org override that writes
|
|
160
160
|
* one, and the reading above is what keeps that org's assistant running.
|
|
161
161
|
*
|
|
162
|
-
* The Aglyn AI
|
|
163
|
-
* resolver has already added
|
|
164
|
-
* band, because
|
|
165
|
-
* second.
|
|
162
|
+
* The Aglyn AI tier (AGL-2896, AGL-3965) needs no knowledge here either
|
|
163
|
+
* way: the resolver has already added the purchased tier's credits
|
|
164
|
+
* (`AI_TIERS[tier].credits`) to the band, because a tier widens the one pool
|
|
165
|
+
* rather than opening a second.
|
|
166
166
|
*
|
|
167
167
|
* The same reading is what makes the guard survive a stubbed entitlements
|
|
168
168
|
* module. A test double that answers 0 for every quota produces `null` here,
|
|
@@ -166,10 +166,10 @@ import { planQuotaOf } from "@aglyn/aglyn/plugin-manager/plugin-plan-entitlement
|
|
|
166
166
|
* So the zero case is now reached only by a per-org override that writes
|
|
167
167
|
* one, and the reading above is what keeps that org's assistant running.
|
|
168
168
|
*
|
|
169
|
-
* The Aglyn AI
|
|
170
|
-
* resolver has already added
|
|
171
|
-
* band, because
|
|
172
|
-
* second.
|
|
169
|
+
* The Aglyn AI tier (AGL-2896, AGL-3965) needs no knowledge here either
|
|
170
|
+
* way: the resolver has already added the purchased tier's credits
|
|
171
|
+
* (`AI_TIERS[tier].credits`) to the band, because a tier widens the one pool
|
|
172
|
+
* rather than opening a second.
|
|
173
173
|
*
|
|
174
174
|
* The same reading is what makes the guard survive a stubbed entitlements
|
|
175
175
|
* module. A test double that answers 0 for every quota produces `null` here,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/assist-credits.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n isUncappedPlanComp,\n planLabelGrantingFeature,\n resolveOrgEntitlements,\n resolvePlanPricing,\n} from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { planQuotaOf } from '@aglyn/aglyn/plugin-manager/plugin-plan-entitlements'\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation/definitions/org-billing.types'\n\n/**\n * Aglyn Assist credits: the unit assist is sold, metered and refused in.\n *\n * ## Why cost, and not messages\n *\n * Assist is measured per exchange as `estCostUsd` — the exchange priced at\n * the serving model's billed rates, computed where the tokens were counted. A\n * message allowance would throw that measurement away and price every action\n * the same. It cannot: a question is a few thousand tokens, while generating\n * a screen carries the node tree, the component catalog and the theme tokens\n * in, emits structured markup out, and iterates. The two differ by up to two\n * orders of magnitude, so one workspace's ten screen builds can outspend\n * another's thousand questions while both read as \"within allowance\".\n *\n * Metering in cost is the only unit under which those two workspaces draw\n * down their bands differently, which is the entire point.\n *\n * ## Why a credit rather than the dollar figure\n *\n * `estCostUsd` is a dollar figure off our own cost model, not a price.\n * Publishing it would put our model choice, our per-model rates and our\n * margin on a billing page, and it would move under customers every time a\n * model is swapped. A credit is a fixed quantity of that spend with a stable\n * public meaning, so the band on the plan card stays the same number when\n * the model behind it changes.\n *\n * Everything customer-facing counts credits. This module is the ONLY place\n * the two units meet.\n *\n * ## `estCostUsd` is the BILLED figure, not the bill we pay (AGL-3015)\n *\n * It is every exchange priced at the catalog's billed rates, which a model\n * may carry above what its provider charges. Credits are drawn from it, so\n * it is the right figure for a band, a wall, a cap and an invoice line. It\n * is the WRONG figure for a margin or a cost meter, which read the rollup's\n * provider figure through `assistProviderCostUsd` instead.\n */\n\n/**\n * What one credit is BILLED at, in USD.\n *\n * A tenth of a cent, chosen for two properties rather than for roundness:\n *\n * - **1,000 credits cost exactly $1.00**, so the per-1,000 retail rates on\n * `PLAN_PRICING.extraAssistCreditsUsdPer1k` read directly as multiples of\n * cost. A rate of $2.00 per 1,000 is cost x2 with no arithmetic in between,\n * which is what makes the 50% margin floor checkable by eye.\n * - **A single question is tens of credits, a generated screen is hundreds.**\n * At Sonnet list rates a short grounded answer runs around $0.013, so ~13\n * credits; a screen build runs into the hundreds. Both are whole numbers\n * with room to differ, which a cent-sized credit would have flattened to\n * \"1 vs 20\" and a dollar-sized one to \"0 vs 0\".\n *\n * ⚠️ This is a COST-MODEL constant, not a price and not a rate we publish.\n * It never appears on a customer surface; `assistCreditsFromUsd` is how a\n * measured dollar figure becomes something a customer may be shown.\n *\n * ⚠️ AND IT IS NOT WHAT A CREDIT COSTS US (AGL-3015). The dollar figure this\n * divides is priced at the model catalog's BILLED rates, and a model may be\n * billed above what the provider charges. A credit is therefore a tenth of a\n * cent of BILLED spend, and at most a tenth of a cent of provider spend. Read\n * against a retail rate it yields the floor of the line margin, never the\n * middle of it — which is the safe direction, and the direction every margin\n * assertion in this repo depends on. What a period actually cost is measured,\n * not derived: `assistProviderCostUsd` reads it off the rollup.\n */\nexport const ASSIST_CREDIT_COST_USD = 0.001\n\n/**\n * The field a monthly assist rollup, a signal row and a kind bucket keep\n * PROVIDER spend under, beside the `estCostUsd` a customer's credits are\n * drawn from (AGL-3015).\n *\n * Named here rather than in the meter that writes it because the readers are\n * spread across the plugin, the billing routes and the staff surfaces, and a\n * field name spelled out at six call sites is a field name that will be\n * spelled differently at the seventh.\n */\nexport const ASSIST_PROVIDER_COST_FIELD = 'providerCostUsd'\n\n/**\n * What a metered period ACTUALLY COST US, from the two dollar figures a\n * rollup carries.\n *\n * `billedUsd` is `estCostUsd`: every exchange priced at its model's billed\n * rates, and the figure credits are drawn from. `providerUsd` is\n * `ASSIST_PROVIDER_COST_FIELD`: the same exchanges priced at what the\n * provider charged. The second is the answer wherever it exists.\n *\n * ## Why a rollup may not carry one, and which way that errs\n *\n * The provider figure is written from the commit that split the two rates\n * onward. A period closed before it answers with the BILLED figure, which is\n * at or above what we paid — so a margin taken on history reads low and a\n * cost reads high, the direction a cost figure is allowed to be wrong in.\n * The one period that under-reads is the month in flight when the split\n * shipped, whose provider figure covers only the exchanges after it; that\n * month's error is bounded by the markup on a few days of a meter whose\n * largest measured organization is a fraction of a cent.\n */\nexport function assistProviderCostUsd(\n billedUsd: unknown,\n providerUsd: unknown,\n): number {\n const provider = Number(providerUsd)\n if (Number.isFinite(provider) && provider > 0) return provider\n const billed = Number(billedUsd)\n return Number.isFinite(billed) && billed > 0 ? billed : 0\n}\n\n/**\n * The minimum line margin every retail assist rate must clear.\n *\n * The same floor now applied to every retail rate in this codebase, and the\n * reason the assist ladder stops descending at cost x2 on Agency instead of\n * taking one more step the way the contacts ladder once did.\n */\nexport const ASSIST_CREDIT_MIN_MARGIN_PCT = 0.5\n\n/**\n * Credits -> our cost in USD. Non-finite or negative input is 0 credits, so a\n * corrupt band cannot become a budget of `NaN` that every comparison passes.\n */\nexport function assistUsdFromCredits(credits: number): number {\n const value = Number(credits)\n if (!Number.isFinite(value) || value <= 0) return 0\n return Math.round(value * ASSIST_CREDIT_COST_USD * 1_000_000) / 1_000_000\n}\n\n/**\n * Measured USD -> credits, rounded UP.\n *\n * Up, because this converts spend the platform has ALREADY made into the\n * figure a customer is shown against their band. Rounding down would let a\n * long tail of sub-credit exchanges cost real money and draw nothing, which\n * is the one direction a meter must not err in.\n */\nexport function assistCreditsFromUsd(usd: number): number {\n const value = Number(usd)\n if (!Number.isFinite(value) || value <= 0) return 0\n return Math.ceil(value / ASSIST_CREDIT_COST_USD)\n}\n\n/**\n * The org's monthly assist band in credits, or `null` when its plan sells no\n * assist band at all.\n *\n * ## `null` is not zero, and the difference is the whole safety property\n *\n * A band of 0 resolves to `null` — \"this org sells no band\" — and never to\n * a budget of `$0`. An org with no band still reaches the console\n * assistant's docs-grounded rung, which is bounded by the free daily message\n * cap and by the operator's spend backstop. Resolving zero as a budget would\n * refuse that rung outright: a whole assistant turned off by a pricing field\n * that was never about it.\n *\n * Since AGL-3203 no PLAN ROW bands at zero. Free carries the AGL-2925 taste\n * of `FREE_AI_TASTE_CREDITS_PER_MONTH` with no rate beside it, so it\n * resolves to a budget here and `assistBandRefuses` makes that budget a\n * wall; Starter carries 750 WITH a rate, so its budget is a line it meters\n * past rather than a wall. Both draw their docs-grounded chat on the same\n * band, which is fine — each is sized so a month of it costs well under a\n * dollar.\n *\n * So the zero case is now reached only by a per-org override that writes\n * one, and the reading above is what keeps that org's assistant running.\n *\n * The Aglyn AI add-on (AGL-2896) needs no knowledge here either way: the\n * resolver has already added `AI_ADDON_CREDITS_PER_MONTH[plan]` to the\n * band, because the add-on widens the one pool rather than opening a\n * second.\n *\n * The same reading is what makes the guard survive a stubbed entitlements\n * module. A test double that answers 0 for every quota produces `null` here,\n * which falls through to the operator backstop, rather than a budget of zero\n * that refuses everything while the assertions read green. A clamp that goes\n * green having refused every request has proved nothing, and 0 is exactly the\n * value a stub returns.\n *\n * ## Non-finite is also `null`, deliberately\n *\n * `Infinity` must never resolve to a budget. It is unrepresentable off-process\n * — `JSON.stringify(Infinity)` is `null` and reads back as 0 — so a band that\n * reached here as `Infinity` is a band that would read as zero somewhere\n * else. Falling through to the backstop is bounded and consistent; honouring\n * it would be an unbounded budget on one process and a refusal on the next.\n *\n * An UNCAPPED staff comp (AGL-3049) is where that `Infinity` comes from on\n * purpose, and `null` is right for it here too: there is no band to measure\n * against, be over, price or alert on. What `null` must NOT bring with it is\n * the backstop, whose repo default would cap the one workspace staff said\n * has no cap — so `assistBandRefuses` answers false for it, and the\n * reservation's ceiling (`assistMonthlyCeilingUsd` in the AI plugin) drops\n * the default and keeps only an operator's explicit figure.\n */\nexport function resolveAssistCreditBudget(\n org: Partial<AglynOrgBilling> | null | undefined,\n): number | null {\n const credits = planQuotaOf(resolveOrgEntitlements(org), 'assistCreditsPerMonth')\n if (!Number.isFinite(credits) || credits <= 0) return null\n return Math.floor(credits)\n}\n\n/** The org's monthly assist band in USD of provider spend, or `null`. */\nexport function resolveAssistBudgetUsd(\n org: Partial<AglynOrgBilling> | null | undefined,\n): number | null {\n const credits = resolveAssistCreditBudget(org)\n return credits === null ? null : assistUsdFromCredits(credits)\n}\n\n/**\n * Credits spent past the band. A `null` band yields 0 — an org with no band\n * has nothing to be over, and reporting the whole month's spend as overage\n * there would invoice the free tier for its docs answers.\n */\nexport function assistCreditOverage(\n usedCredits: number,\n bandCredits: number | null,\n): number {\n const used = Number(usedCredits)\n if (!Number.isFinite(used) || used <= 0) return 0\n if (bandCredits === null) return 0\n const band = Number(bandCredits)\n if (!Number.isFinite(band) || band <= 0) return 0\n return Math.max(0, used - band)\n}\n\n/**\n * The per-1,000 rate the org's assist credits are sold at past its band, or\n * `null` when nothing is sold past it — the ONE reading of the rate\n * (AGL-2896). `priceAssistCreditOverage`, `assistBandRefuses`,\n * `assistRefusedByHardCap` and `assistHardCapRefusalText` all go through\n * it, so the gate, the invoice and the refusal sentence cannot quote three\n * different rates for one org.\n *\n * The plan's `extraAssistCreditsUsdPer1k` IS the answer — the whole answer,\n * since AGL-3203. Starter used to be the one exception: it banded at 0, so\n * the rate could not sit on its plan row without advertising a fee on a\n * quantity the plan never sold, and the add-on's band was priced from a\n * constant read here instead. Starter now includes 750 credits\n * unconditionally and carries $3.00 on its own row, so the exception and its\n * constant are gone and there is exactly one place the rate is written.\n *\n * Free and Enterprise are null with or without the add-on: Free sells no\n * add-on and its taste band is a wall by decision (AGL-2925), and\n * Enterprise's usage is in the contract.\n *\n * A STAFF COMP is null on every plan and with every add-on (AGL-3034),\n * because `resolvePlanPricing` — never `PLAN_PRICING` indexed directly —\n * sells nothing on a comp. That is what makes a comp's band a wall at the\n * gate and prices its overage to zero at the charge: the AI overage path\n * (AGL-3011) claims only what `assistMonthOverage` prices, so a comp never\n * becomes a Stripe invoice. The function is a single read for that reason;\n * a second source for the rate is how a comp gets quoted one.\n */\nexport function resolveAssistOverageRateUsdPer1k(\n org: Partial<AglynOrgBilling> | null | undefined,\n): number | null {\n return resolvePlanPricing(org).extraAssistCreditsUsdPer1k\n}\n\nexport interface AssistCreditOveragePrice {\n /** Credits past the plan's band, as handed in. */\n overageCredits: number\n /** Estimated charge at the plan's per-1,000 rate. */\n overageMonthlyUsd: number\n /** Per-1,000 rate; null when the plan prices no assist overage. */\n overageRateUsd: number | null\n}\n\n/**\n * Prices assist credits past a plan's band.\n *\n * ## No `allowed`, deliberately — and for the opposite reason to email\n *\n * `priceEmailSendOverage` omits an `allowed` field because email has two\n * gates and a transactional sender consulting the wrong one drops a password\n * reset. Assist omits it because it has exactly ONE gate and this is not it:\n * whether the band refuses is `assistBandRefuses`, applied inside\n * `reserveAssistMessage` before a token is spent. A second answer to \"may\n * this proceed\", computed after the fact from a rounded credit figure, would\n * be a second gate to drift from the first.\n *\n * ## Takes the overage, does not recompute it\n *\n * `assistCreditOverage` derives the excess once, from the measured meter and\n * the resolved band. Re-deriving it here would be a second overage model.\n *\n * A null rate yields zero structurally rather than by a check, which is what\n * keeps Free and Starter at zero on this axis.\n */\nexport function priceAssistCreditOverage(\n org: Partial<AglynOrgBilling> | null | undefined,\n overageCredits: number,\n): AssistCreditOveragePrice {\n const overageRateUsd = resolveAssistOverageRateUsdPer1k(org)\n const credits = Number(overageCredits)\n const over = Number.isFinite(credits) && credits > 0 ? credits : 0\n return {\n overageCredits: over,\n overageMonthlyUsd:\n overageRateUsd === null\n ? 0\n : Math.round((over / 1000) * overageRateUsd * 100) / 100,\n overageRateUsd,\n }\n}\n\n/**\n * The line margin a per-1,000-credit retail rate earns AT LEAST, as a\n * fraction.\n *\n * One expression, so the ladder and its floor cannot be checked against two\n * different definitions of margin. A null or non-positive rate has no margin\n * to report and answers `null` rather than 1 or 0 — \"this plan sells no\n * overage\" is not \"this plan sells overage at 100% margin\".\n *\n * A LOWER BOUND, not the realized figure (AGL-3015). The cost side is\n * `ASSIST_CREDIT_COST_USD`, which is what a credit BILLS at; a model billed\n * above its provider rate costs us less than that per credit, so the real\n * line margin is this or better and never worse. The floor stays checkable\n * by eye — a $2.00 rate is still cost x2 — and a retail rate that clears the\n * floor here clears it on every model mix. What a period realized is the\n * staff margin card's business, and it reads measured provider spend.\n */\nexport function assistCreditRateMarginPct(\n rateUsdPer1k: number | null,\n): number | null {\n if (rateUsdPer1k === null) return null\n const rate = Number(rateUsdPer1k)\n if (!Number.isFinite(rate) || rate <= 0) return null\n const costPer1k = ASSIST_CREDIT_COST_USD * 1000\n return (rate - costPer1k) / rate\n}\n\n/**\n * What a refused or in-progress workspace may be TOLD about its assist band.\n *\n * The reservation carries `costUsd` and `costLimitUsd` because that is what\n * it measured and what it compared against, and both are our provider bill.\n * Shipping the reservation itself to a browser publishes them. This is the\n * projection that crosses that boundary: credits only, no dollars, no model,\n * no rates.\n */\nexport interface PublicAssistCredits {\n /** Credits drawn this month, rounded up. */\n used: number\n /** The band, or `null` when no band applied to this request. */\n limit: number | null\n /** Credits left in the band, or `null` when there is no band. */\n remaining: number | null\n}\n\n/**\n * Projects a measured spend figure and a ceiling into the credit view.\n *\n * Both inputs are nullable because the reservation's are: `costUsd` is `null`\n * when nothing consulted the monthly document, and a ceiling is `null` when\n * none applied. Neither collapses to 0 here — \"nobody looked\" and \"spent\n * nothing\" have to stay distinguishable at the surface, exactly as they do on\n * the reservation.\n */\nexport function publicAssistCredits(\n costUsd: number | null,\n ceilingUsd: number | null,\n): PublicAssistCredits {\n const used = costUsd === null ? 0 : assistCreditsFromUsd(costUsd)\n if (ceilingUsd === null) return { used, limit: null, remaining: null }\n const limit = assistCreditsFromUsd(ceilingUsd)\n return { used, limit, remaining: Math.max(0, limit - used) }\n}\n\n/**\n * The console's name for the org's hard-cap switch (AGL-2653), quoted by the\n * refusal so the words on the 402 and the words beside the switch are one\n * string. Change it here and both move.\n */\nexport const ASSIST_HARD_CAP_CONTROL_LABEL = 'Stop AI assist at the included band'\n\n/** Where the switch lives, in the words the refusal uses. */\nexport const ASSIST_HARD_CAP_CONTROL_LOCATION = 'Billing → Usage'\n\n/**\n * Whether the org asked to be stopped at its assist band (AGL-2653).\n *\n * Strictly `=== true`: the map is absent on every org that has not touched\n * the switch, and absent is the selling default. A truthy string or a `1`\n * written by hand must not switch an assistant off at the band, so nothing\n * short of the boolean counts.\n */\nexport function resolveAssistHardCap(\n org: Partial<AglynOrgBilling> | null | undefined,\n): boolean {\n return org?.assistOverage?.hardCap === true\n}\n\n/**\n * Is the org's assist band a WALL, or a line past which credits are sold?\n *\n * Two things make it a wall, and only two:\n *\n * - **The org asked.** `assistOverage.hardCap` is the customer's own ceiling,\n * the `storageOverage.capUsd` of this meter.\n * - **There is nothing to sell past it at.** An org whose\n * `resolveAssistOverageRateUsdPer1k` is `null` has no rate to bill, so\n * credits past its band would be provider spend with no invoice line — the\n * silent free overage `plan-entitlements.spec.ts` forbids. Enterprise is\n * one case, its band being contractual; FREE is the other, its taste band\n * a wall by the AGL-2925 decision so that nothing about a Free workspace\n * can produce a charge.\n *\n * Everything else sells past the band by default, which is the 2026-09-07\n * decision this function encodes — and since AGL-3203 that includes Starter,\n * which carries 750 credits and a $3.00 rate like every paid tier above it.\n *\n * An org with NO band at all answers the same as Enterprise, harmlessly:\n * `resolveAssistBudgetUsd` is `null` for it and the reservation never\n * measures against a band. No plan ROW is in that state any more — Free and\n * Starter both were, at different times — so it is reached by a per-org\n * override that writes a zero.\n *\n * An UNCAPPED staff comp (AGL-3049) is neither: it has no band to be a wall,\n * and nothing is sold past a band it does not have. It answers false, ahead\n * of the switch — a workspace staff uncapped is not stopped by a band, and\n * the reservation reads that answer as \"no wall, no backstop default\".\n */\nexport function assistBandRefuses(\n org: Partial<AglynOrgBilling> | null | undefined,\n): boolean {\n if (isUncappedPlanComp(org)) return false\n if (resolveAssistHardCap(org)) return true\n return resolveAssistOverageRateUsdPer1k(org) === null\n}\n\n/**\n * Which ceiling refused a reservation, or `null` when it was admitted.\n *\n * - `messages`: the message cap (daily on the free rung, monthly entitled).\n * - `budget`: the operator's spend backstop.\n * - `band`: the plan's own band, as a wall — the switch, or a plan with\n * no rate to sell past it at.\n * - `cap`: the org's own dollar ceiling on overage (AGL-2898).\n *\n * The four that exist only on the Free taste (AGL-2925), each a precaution\n * against the one band that has no invoice behind it:\n *\n * - `account`: the ACCOUNT's 300 credits are spent across every free\n * workspace it owns — the org band alone would multiply by\n * the workspaces one person may hold.\n * - `requests`: the account's daily free request cap.\n * - `refusals`: the account's free generation is paused for the day after\n * too many `refusal` stops — a brief the model declined is a\n * brief that should not be retried thirty times.\n * - `platform`: the platform-wide daily ceiling on free spend; nobody's\n * fault, and \"try again tomorrow\" is the whole answer.\n *\n * Declared here, beside the helpers that read it, so the reservation, its\n * public projection and both doors name one union.\n */\nexport type AssistRefusedBy =\n | 'messages'\n | 'budget'\n | 'band'\n | 'cap'\n | 'account'\n | 'requests'\n | 'refusals'\n | 'platform'\n | null\n\n/**\n * The sentence a Free workspace is told when one of the taste's own\n * precautions refused it, or `null` when the refusal was something else.\n *\n * One string per rung, for both doors and the gate ladder, so a customer\n * reading the console panel and one reading the besigner get the same\n * words. Every sentence is customer-safe: it names what to do (upgrade,\n * wait) and never a figure from our cost model, a counter document, or\n * another workspace.\n */\nexport function assistFreeTasteRefusalText(\n refusedBy: AssistRefusedBy,\n): string | null {\n switch (refusedBy) {\n case 'account':\n return (\n 'Your free AI credits for this month are used across your ' +\n 'workspaces — upgrade any workspace to keep going.'\n )\n case 'requests':\n return (\n 'Free workspaces get a limited number of AI requests a day — ' +\n 'try again tomorrow, or upgrade to keep going.'\n )\n case 'refusals':\n return (\n 'The AI declined several requests today, so free AI generation is ' +\n 'paused until tomorrow. Try a different kind of brief then.'\n )\n case 'platform':\n return (\n 'Free AI generation is paused for the rest of today — try again ' +\n 'tomorrow. Paid workspaces are not affected.'\n )\n default:\n return null\n }\n}\n\n/**\n * The sentence a workspace whose plan does not carry `aiAssist` is told, at\n * the route and at every console door that checks the flag first.\n *\n * The plan it names is read from the plan table rather than written here.\n * Which tier first carries the flag is a pricing decision, and a name typed\n * into the sentence keeps naming the old tier after the table moves — telling\n * the reader to buy a plan above the one that would let them in. `undefined`\n * from the lookup means no tier carries the flag on its base row, and the\n * sentence then names no plan rather than a wrong one.\n */\nexport function assistNotOnPlanText(): string {\n const plan = planLabelGrantingFeature('aiAssist')\n return plan\n ? `AI assist starts on the ${plan} plan — see Billing to upgrade`\n : 'AI assist is not on this plan — see Billing to upgrade'\n}\n\n/**\n * Whether a refusal at the band was the org's OWN doing — the switch is on\n * AND turning it off would have let the exchange through. The refusal that\n * names the switch has to be the one the switch caused: on a plan with no\n * rate the band refuses whatever the switch says, and telling that customer\n * to turn it off would send them to a control that does nothing.\n */\nexport function assistRefusedByHardCap(\n org: Partial<AglynOrgBilling> | null | undefined,\n refusedBy: AssistRefusedBy,\n): boolean {\n return (\n refusedBy === 'band' &&\n resolveAssistHardCap(org) &&\n resolveAssistOverageRateUsdPer1k(org) !== null\n )\n}\n\n/**\n * The sentence a workspace is told when its own hard cap refused it. ONE\n * string for both assist entrypoints, so the console panel and the besigner\n * cannot describe the same control in two ways. It names the switch by its\n * label, says where it lives, and quotes the plan's rate so the reader knows\n * what turning it off costs.\n */\nexport function assistHardCapRefusalText(\n org: Partial<AglynOrgBilling> | null | undefined,\n): string {\n const rate = resolveAssistOverageRateUsdPer1k(org)\n const rateClause =\n rate === null ? '' : ` at $${rate.toFixed(2)} per 1,000 credits`\n return (\n `This workspace used its assistant credits for the month, and ` +\n `\"${ASSIST_HARD_CAP_CONTROL_LABEL}\" is on. Turn it off under ` +\n `${ASSIST_HARD_CAP_CONTROL_LOCATION} to keep going${rateClause}.`\n )\n}\n\nexport interface AssistMonthOverage extends AssistCreditOveragePrice {\n /** Credits drawn this month, rounded up from the measured spend. */\n usedCredits: number\n /** The band those credits were measured against, or `null` for no band. */\n bandCredits: number | null\n}\n\n/**\n * A month's assist overage from its measured spend — the ONE derivation the\n * invoice and every meter read (AGL-2653).\n *\n * Takes `estCostUsd` as the rollup reads it and composes the three steps\n * that already existed — credits from spend, overage past the band, price at\n * the plan's rate — so `report-usage` cannot assemble them in a different\n * order from a console readout and arrive at a different figure. The\n * rounding is the other overage terms' rounding: `priceAssistCreditOverage`\n * rounds to the cent, and the rollup turns dollars into cents with the same\n * `Math.round(usd * 100)` it applies to contacts, API and dataset storage.\n *\n * A non-finite or negative spend is zero credits, for the reason\n * `assistCreditsFromUsd` gives; a plan with no band or no rate prices zero\n * structurally, so Free, Starter and Enterprise bill nothing here without a\n * check that could be forgotten.\n */\nexport function assistMonthOverage(\n org: Partial<AglynOrgBilling> | null | undefined,\n estCostUsd: number,\n): AssistMonthOverage {\n const usedCredits = assistCreditsFromUsd(estCostUsd)\n const bandCredits = resolveAssistCreditBudget(org)\n return {\n usedCredits,\n bandCredits,\n ...priceAssistCreditOverage(\n org,\n assistCreditOverage(usedCredits, bandCredits),\n ),\n }\n}\n\n/** The console's name for the org's overage ceiling (AGL-2898). */\nexport const ASSIST_OVERAGE_CAP_CONTROL_LABEL =\n 'Stop AI when this month’s overage reaches'\n\n/**\n * Bounds on the ceiling a self-serve org may set. The floor is a dollar\n * because a ceiling of cents would refuse on the first exchange past the\n * band while reading as a limit; the roof is the storage cap's, so no\n * self-serve org writes itself an enterprise-sized commitment.\n */\nexport const ASSIST_OVERAGE_CAP_MIN_USD = 1\nexport const ASSIST_OVERAGE_CAP_MAX_USD = 100_000\n\n/**\n * The org's monthly ceiling on AI overage dollars, or `null` for none.\n *\n * Strictly a finite positive number. `null`, absent, a string, `NaN`,\n * `Infinity` and zero all read as no ceiling — the failure mode of the\n * alternative is a wall of `NaN` that every comparison passes, or a ceiling\n * of zero that refuses the first exchange past the band while the card\n * says nothing is set.\n */\nexport function resolveAssistOverageCapUsd(\n org: Partial<AglynOrgBilling> | null | undefined,\n): number | null {\n const raw = org?.assistOverage?.capUsd\n if (typeof raw !== 'number' || !Number.isFinite(raw) || raw <= 0) return null\n return raw\n}\n\n/**\n * Has this month's overage reached the org's own ceiling (AGL-2898)?\n *\n * Composed from `assistMonthOverage`, the ONE derivation the invoice reads,\n * so the figure the ceiling stops at is the figure the invoice would have\n * billed. That composition is also what confines the ceiling to plans that\n * sell past their band: a plan with no rate prices its overage to zero, and\n * zero never reaches a positive ceiling — Free and Enterprise cannot get here\n * without a check that could be forgotten. `>=` rather than `>`, because an\n * org that asked to stop AT a figure is stopped when the figure is met.\n */\nexport function assistOverageCapReached(\n org: Partial<AglynOrgBilling> | null | undefined,\n estCostUsd: number,\n): boolean {\n const cap = resolveAssistOverageCapUsd(org)\n if (cap === null) return false\n return assistMonthOverage(org, estCostUsd).overageMonthlyUsd >= cap\n}\n\n/**\n * Whether a refusal was the org's own ceiling. The reservation answers\n * `'cap'` only when `assistOverageCapReached` did, and that helper only\n * reaches a ceiling on a plan with a rate, so the two conditions restated\n * here are the same guarantee `assistRefusedByHardCap` gives the switch: the\n * control the sentence names is the control that refused.\n */\nexport function assistRefusedByOverageCap(\n org: Partial<AglynOrgBilling> | null | undefined,\n refusedBy: AssistRefusedBy,\n): boolean {\n return (\n refusedBy === 'cap' &&\n resolveAssistOverageCapUsd(org) !== null &&\n resolveAssistOverageRateUsdPer1k(org) !== null\n )\n}\n\n/**\n * The sentence a workspace is told when its own overage ceiling refused it.\n * ONE string for both assist entrypoints, as `assistHardCapRefusalText` is\n * for the switch. It names the control by its label, quotes the figure the\n * org chose, and says where to raise or clear it.\n */\nexport function assistOverageCapRefusalText(\n org: Partial<AglynOrgBilling> | null | undefined,\n): string {\n const cap = resolveAssistOverageCapUsd(org)\n const figure = cap === null ? '' : ` $${cap.toFixed(2)}`\n return (\n `This month’s AI assist overage reached${figure}, the figure you set as ` +\n `\"${ASSIST_OVERAGE_CAP_CONTROL_LABEL}\". Raise or remove it under ` +\n `${ASSIST_HARD_CAP_CONTROL_LOCATION} to keep going.`\n )\n}\n\n/**\n * The refusal sentence for whichever of the org's OWN controls refused, or\n * `null` when the refusal was nobody's control — the message cap, the\n * operator's backstop, or a plan that sells no overage. Both doors answer a\n * 402 with this text when it is a string and their usual 429 otherwise, so\n * the status and the sentence cannot disagree about whose decision it was.\n */\nexport function assistOwnControlRefusalText(\n org: Partial<AglynOrgBilling> | null | undefined,\n refusedBy: AssistRefusedBy,\n): string | null {\n if (assistRefusedByHardCap(org, refusedBy)) return assistHardCapRefusalText(org)\n if (assistRefusedByOverageCap(org, refusedBy)) {\n return assistOverageCapRefusalText(org)\n }\n return null\n}\n"],"names":["isUncappedPlanComp","planLabelGrantingFeature","resolveOrgEntitlements","resolvePlanPricing","planQuotaOf","ASSIST_CREDIT_COST_USD","ASSIST_PROVIDER_COST_FIELD","assistProviderCostUsd","billedUsd","providerUsd","provider","Number","isFinite","billed","ASSIST_CREDIT_MIN_MARGIN_PCT","assistUsdFromCredits","credits","value","Math","round","assistCreditsFromUsd","usd","ceil","resolveAssistCreditBudget","org","floor","resolveAssistBudgetUsd","assistCreditOverage","usedCredits","bandCredits","used","band","max","resolveAssistOverageRateUsdPer1k","extraAssistCreditsUsdPer1k","priceAssistCreditOverage","overageCredits","overageRateUsd","over","overageMonthlyUsd","assistCreditRateMarginPct","rateUsdPer1k","rate","costPer1k","publicAssistCredits","costUsd","ceilingUsd","limit","remaining","ASSIST_HARD_CAP_CONTROL_LABEL","ASSIST_HARD_CAP_CONTROL_LOCATION","resolveAssistHardCap","assistOverage","hardCap","assistBandRefuses","assistFreeTasteRefusalText","refusedBy","assistNotOnPlanText","plan","assistRefusedByHardCap","assistHardCapRefusalText","rateClause","toFixed","assistMonthOverage","estCostUsd","ASSIST_OVERAGE_CAP_CONTROL_LABEL","ASSIST_OVERAGE_CAP_MIN_USD","ASSIST_OVERAGE_CAP_MAX_USD","resolveAssistOverageCapUsd","raw","capUsd","assistOverageCapReached","cap","assistRefusedByOverageCap","assistOverageCapRefusalText","figure","assistOwnControlRefusalText"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/assist-credits.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n isUncappedPlanComp,\n planLabelGrantingFeature,\n resolveOrgEntitlements,\n resolvePlanPricing,\n} from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { planQuotaOf } from '@aglyn/aglyn/plugin-manager/plugin-plan-entitlements'\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation/definitions/org-billing.types'\n\n/**\n * Aglyn Assist credits: the unit assist is sold, metered and refused in.\n *\n * ## Why cost, and not messages\n *\n * Assist is measured per exchange as `estCostUsd` — the exchange priced at\n * the serving model's billed rates, computed where the tokens were counted. A\n * message allowance would throw that measurement away and price every action\n * the same. It cannot: a question is a few thousand tokens, while generating\n * a screen carries the node tree, the component catalog and the theme tokens\n * in, emits structured markup out, and iterates. The two differ by up to two\n * orders of magnitude, so one workspace's ten screen builds can outspend\n * another's thousand questions while both read as \"within allowance\".\n *\n * Metering in cost is the only unit under which those two workspaces draw\n * down their bands differently, which is the entire point.\n *\n * ## Why a credit rather than the dollar figure\n *\n * `estCostUsd` is a dollar figure off our own cost model, not a price.\n * Publishing it would put our model choice, our per-model rates and our\n * margin on a billing page, and it would move under customers every time a\n * model is swapped. A credit is a fixed quantity of that spend with a stable\n * public meaning, so the band on the plan card stays the same number when\n * the model behind it changes.\n *\n * Everything customer-facing counts credits. This module is the ONLY place\n * the two units meet.\n *\n * ## `estCostUsd` is the BILLED figure, not the bill we pay (AGL-3015)\n *\n * It is every exchange priced at the catalog's billed rates, which a model\n * may carry above what its provider charges. Credits are drawn from it, so\n * it is the right figure for a band, a wall, a cap and an invoice line. It\n * is the WRONG figure for a margin or a cost meter, which read the rollup's\n * provider figure through `assistProviderCostUsd` instead.\n */\n\n/**\n * What one credit is BILLED at, in USD.\n *\n * A tenth of a cent, chosen for two properties rather than for roundness:\n *\n * - **1,000 credits cost exactly $1.00**, so the per-1,000 retail rates on\n * `PLAN_PRICING.extraAssistCreditsUsdPer1k` read directly as multiples of\n * cost. A rate of $2.00 per 1,000 is cost x2 with no arithmetic in between,\n * which is what makes the 50% margin floor checkable by eye.\n * - **A single question is tens of credits, a generated screen is hundreds.**\n * At Sonnet list rates a short grounded answer runs around $0.013, so ~13\n * credits; a screen build runs into the hundreds. Both are whole numbers\n * with room to differ, which a cent-sized credit would have flattened to\n * \"1 vs 20\" and a dollar-sized one to \"0 vs 0\".\n *\n * ⚠️ This is a COST-MODEL constant, not a price and not a rate we publish.\n * It never appears on a customer surface; `assistCreditsFromUsd` is how a\n * measured dollar figure becomes something a customer may be shown.\n *\n * ⚠️ AND IT IS NOT WHAT A CREDIT COSTS US (AGL-3015). The dollar figure this\n * divides is priced at the model catalog's BILLED rates, and a model may be\n * billed above what the provider charges. A credit is therefore a tenth of a\n * cent of BILLED spend, and at most a tenth of a cent of provider spend. Read\n * against a retail rate it yields the floor of the line margin, never the\n * middle of it — which is the safe direction, and the direction every margin\n * assertion in this repo depends on. What a period actually cost is measured,\n * not derived: `assistProviderCostUsd` reads it off the rollup.\n */\nexport const ASSIST_CREDIT_COST_USD = 0.001\n\n/**\n * The field a monthly assist rollup, a signal row and a kind bucket keep\n * PROVIDER spend under, beside the `estCostUsd` a customer's credits are\n * drawn from (AGL-3015).\n *\n * Named here rather than in the meter that writes it because the readers are\n * spread across the plugin, the billing routes and the staff surfaces, and a\n * field name spelled out at six call sites is a field name that will be\n * spelled differently at the seventh.\n */\nexport const ASSIST_PROVIDER_COST_FIELD = 'providerCostUsd'\n\n/**\n * What a metered period ACTUALLY COST US, from the two dollar figures a\n * rollup carries.\n *\n * `billedUsd` is `estCostUsd`: every exchange priced at its model's billed\n * rates, and the figure credits are drawn from. `providerUsd` is\n * `ASSIST_PROVIDER_COST_FIELD`: the same exchanges priced at what the\n * provider charged. The second is the answer wherever it exists.\n *\n * ## Why a rollup may not carry one, and which way that errs\n *\n * The provider figure is written from the commit that split the two rates\n * onward. A period closed before it answers with the BILLED figure, which is\n * at or above what we paid — so a margin taken on history reads low and a\n * cost reads high, the direction a cost figure is allowed to be wrong in.\n * The one period that under-reads is the month in flight when the split\n * shipped, whose provider figure covers only the exchanges after it; that\n * month's error is bounded by the markup on a few days of a meter whose\n * largest measured organization is a fraction of a cent.\n */\nexport function assistProviderCostUsd(\n billedUsd: unknown,\n providerUsd: unknown,\n): number {\n const provider = Number(providerUsd)\n if (Number.isFinite(provider) && provider > 0) return provider\n const billed = Number(billedUsd)\n return Number.isFinite(billed) && billed > 0 ? billed : 0\n}\n\n/**\n * The minimum line margin every retail assist rate must clear.\n *\n * The same floor now applied to every retail rate in this codebase, and the\n * reason the assist ladder stops descending at cost x2 on Agency instead of\n * taking one more step the way the contacts ladder once did.\n */\nexport const ASSIST_CREDIT_MIN_MARGIN_PCT = 0.5\n\n/**\n * Credits -> our cost in USD. Non-finite or negative input is 0 credits, so a\n * corrupt band cannot become a budget of `NaN` that every comparison passes.\n */\nexport function assistUsdFromCredits(credits: number): number {\n const value = Number(credits)\n if (!Number.isFinite(value) || value <= 0) return 0\n return Math.round(value * ASSIST_CREDIT_COST_USD * 1_000_000) / 1_000_000\n}\n\n/**\n * Measured USD -> credits, rounded UP.\n *\n * Up, because this converts spend the platform has ALREADY made into the\n * figure a customer is shown against their band. Rounding down would let a\n * long tail of sub-credit exchanges cost real money and draw nothing, which\n * is the one direction a meter must not err in.\n */\nexport function assistCreditsFromUsd(usd: number): number {\n const value = Number(usd)\n if (!Number.isFinite(value) || value <= 0) return 0\n return Math.ceil(value / ASSIST_CREDIT_COST_USD)\n}\n\n/**\n * The org's monthly assist band in credits, or `null` when its plan sells no\n * assist band at all.\n *\n * ## `null` is not zero, and the difference is the whole safety property\n *\n * A band of 0 resolves to `null` — \"this org sells no band\" — and never to\n * a budget of `$0`. An org with no band still reaches the console\n * assistant's docs-grounded rung, which is bounded by the free daily message\n * cap and by the operator's spend backstop. Resolving zero as a budget would\n * refuse that rung outright: a whole assistant turned off by a pricing field\n * that was never about it.\n *\n * Since AGL-3203 no PLAN ROW bands at zero. Free carries the AGL-2925 taste\n * of `FREE_AI_TASTE_CREDITS_PER_MONTH` with no rate beside it, so it\n * resolves to a budget here and `assistBandRefuses` makes that budget a\n * wall; Starter carries 750 WITH a rate, so its budget is a line it meters\n * past rather than a wall. Both draw their docs-grounded chat on the same\n * band, which is fine — each is sized so a month of it costs well under a\n * dollar.\n *\n * So the zero case is now reached only by a per-org override that writes\n * one, and the reading above is what keeps that org's assistant running.\n *\n * The Aglyn AI tier (AGL-2896, AGL-3965) needs no knowledge here either\n * way: the resolver has already added the purchased tier's credits\n * (`AI_TIERS[tier].credits`) to the band, because a tier widens the one pool\n * rather than opening a second.\n *\n * The same reading is what makes the guard survive a stubbed entitlements\n * module. A test double that answers 0 for every quota produces `null` here,\n * which falls through to the operator backstop, rather than a budget of zero\n * that refuses everything while the assertions read green. A clamp that goes\n * green having refused every request has proved nothing, and 0 is exactly the\n * value a stub returns.\n *\n * ## Non-finite is also `null`, deliberately\n *\n * `Infinity` must never resolve to a budget. It is unrepresentable off-process\n * — `JSON.stringify(Infinity)` is `null` and reads back as 0 — so a band that\n * reached here as `Infinity` is a band that would read as zero somewhere\n * else. Falling through to the backstop is bounded and consistent; honouring\n * it would be an unbounded budget on one process and a refusal on the next.\n *\n * An UNCAPPED staff comp (AGL-3049) is where that `Infinity` comes from on\n * purpose, and `null` is right for it here too: there is no band to measure\n * against, be over, price or alert on. What `null` must NOT bring with it is\n * the backstop, whose repo default would cap the one workspace staff said\n * has no cap — so `assistBandRefuses` answers false for it, and the\n * reservation's ceiling (`assistMonthlyCeilingUsd` in the AI plugin) drops\n * the default and keeps only an operator's explicit figure.\n */\nexport function resolveAssistCreditBudget(\n org: Partial<AglynOrgBilling> | null | undefined,\n): number | null {\n const credits = planQuotaOf(resolveOrgEntitlements(org), 'assistCreditsPerMonth')\n if (!Number.isFinite(credits) || credits <= 0) return null\n return Math.floor(credits)\n}\n\n/** The org's monthly assist band in USD of provider spend, or `null`. */\nexport function resolveAssistBudgetUsd(\n org: Partial<AglynOrgBilling> | null | undefined,\n): number | null {\n const credits = resolveAssistCreditBudget(org)\n return credits === null ? null : assistUsdFromCredits(credits)\n}\n\n/**\n * Credits spent past the band. A `null` band yields 0 — an org with no band\n * has nothing to be over, and reporting the whole month's spend as overage\n * there would invoice the free tier for its docs answers.\n */\nexport function assistCreditOverage(\n usedCredits: number,\n bandCredits: number | null,\n): number {\n const used = Number(usedCredits)\n if (!Number.isFinite(used) || used <= 0) return 0\n if (bandCredits === null) return 0\n const band = Number(bandCredits)\n if (!Number.isFinite(band) || band <= 0) return 0\n return Math.max(0, used - band)\n}\n\n/**\n * The per-1,000 rate the org's assist credits are sold at past its band, or\n * `null` when nothing is sold past it — the ONE reading of the rate\n * (AGL-2896). `priceAssistCreditOverage`, `assistBandRefuses`,\n * `assistRefusedByHardCap` and `assistHardCapRefusalText` all go through\n * it, so the gate, the invoice and the refusal sentence cannot quote three\n * different rates for one org.\n *\n * The plan's `extraAssistCreditsUsdPer1k` IS the answer — the whole answer,\n * since AGL-3203. Starter used to be the one exception: it banded at 0, so\n * the rate could not sit on its plan row without advertising a fee on a\n * quantity the plan never sold, and the add-on's band was priced from a\n * constant read here instead. Starter now includes 750 credits\n * unconditionally and carries $3.00 on its own row, so the exception and its\n * constant are gone and there is exactly one place the rate is written.\n *\n * Free and Enterprise are null with or without the add-on: Free sells no\n * add-on and its taste band is a wall by decision (AGL-2925), and\n * Enterprise's usage is in the contract.\n *\n * A STAFF COMP is null on every plan and with every add-on (AGL-3034),\n * because `resolvePlanPricing` — never `PLAN_PRICING` indexed directly —\n * sells nothing on a comp. That is what makes a comp's band a wall at the\n * gate and prices its overage to zero at the charge: the AI overage path\n * (AGL-3011) claims only what `assistMonthOverage` prices, so a comp never\n * becomes a Stripe invoice. The function is a single read for that reason;\n * a second source for the rate is how a comp gets quoted one.\n */\nexport function resolveAssistOverageRateUsdPer1k(\n org: Partial<AglynOrgBilling> | null | undefined,\n): number | null {\n return resolvePlanPricing(org).extraAssistCreditsUsdPer1k\n}\n\nexport interface AssistCreditOveragePrice {\n /** Credits past the plan's band, as handed in. */\n overageCredits: number\n /** Estimated charge at the plan's per-1,000 rate. */\n overageMonthlyUsd: number\n /** Per-1,000 rate; null when the plan prices no assist overage. */\n overageRateUsd: number | null\n}\n\n/**\n * Prices assist credits past a plan's band.\n *\n * ## No `allowed`, deliberately — and for the opposite reason to email\n *\n * `priceEmailSendOverage` omits an `allowed` field because email has two\n * gates and a transactional sender consulting the wrong one drops a password\n * reset. Assist omits it because it has exactly ONE gate and this is not it:\n * whether the band refuses is `assistBandRefuses`, applied inside\n * `reserveAssistMessage` before a token is spent. A second answer to \"may\n * this proceed\", computed after the fact from a rounded credit figure, would\n * be a second gate to drift from the first.\n *\n * ## Takes the overage, does not recompute it\n *\n * `assistCreditOverage` derives the excess once, from the measured meter and\n * the resolved band. Re-deriving it here would be a second overage model.\n *\n * A null rate yields zero structurally rather than by a check, which is what\n * keeps Free and Starter at zero on this axis.\n */\nexport function priceAssistCreditOverage(\n org: Partial<AglynOrgBilling> | null | undefined,\n overageCredits: number,\n): AssistCreditOveragePrice {\n const overageRateUsd = resolveAssistOverageRateUsdPer1k(org)\n const credits = Number(overageCredits)\n const over = Number.isFinite(credits) && credits > 0 ? credits : 0\n return {\n overageCredits: over,\n overageMonthlyUsd:\n overageRateUsd === null\n ? 0\n : Math.round((over / 1000) * overageRateUsd * 100) / 100,\n overageRateUsd,\n }\n}\n\n/**\n * The line margin a per-1,000-credit retail rate earns AT LEAST, as a\n * fraction.\n *\n * One expression, so the ladder and its floor cannot be checked against two\n * different definitions of margin. A null or non-positive rate has no margin\n * to report and answers `null` rather than 1 or 0 — \"this plan sells no\n * overage\" is not \"this plan sells overage at 100% margin\".\n *\n * A LOWER BOUND, not the realized figure (AGL-3015). The cost side is\n * `ASSIST_CREDIT_COST_USD`, which is what a credit BILLS at; a model billed\n * above its provider rate costs us less than that per credit, so the real\n * line margin is this or better and never worse. The floor stays checkable\n * by eye — a $2.00 rate is still cost x2 — and a retail rate that clears the\n * floor here clears it on every model mix. What a period realized is the\n * staff margin card's business, and it reads measured provider spend.\n */\nexport function assistCreditRateMarginPct(\n rateUsdPer1k: number | null,\n): number | null {\n if (rateUsdPer1k === null) return null\n const rate = Number(rateUsdPer1k)\n if (!Number.isFinite(rate) || rate <= 0) return null\n const costPer1k = ASSIST_CREDIT_COST_USD * 1000\n return (rate - costPer1k) / rate\n}\n\n/**\n * What a refused or in-progress workspace may be TOLD about its assist band.\n *\n * The reservation carries `costUsd` and `costLimitUsd` because that is what\n * it measured and what it compared against, and both are our provider bill.\n * Shipping the reservation itself to a browser publishes them. This is the\n * projection that crosses that boundary: credits only, no dollars, no model,\n * no rates.\n */\nexport interface PublicAssistCredits {\n /** Credits drawn this month, rounded up. */\n used: number\n /** The band, or `null` when no band applied to this request. */\n limit: number | null\n /** Credits left in the band, or `null` when there is no band. */\n remaining: number | null\n}\n\n/**\n * Projects a measured spend figure and a ceiling into the credit view.\n *\n * Both inputs are nullable because the reservation's are: `costUsd` is `null`\n * when nothing consulted the monthly document, and a ceiling is `null` when\n * none applied. Neither collapses to 0 here — \"nobody looked\" and \"spent\n * nothing\" have to stay distinguishable at the surface, exactly as they do on\n * the reservation.\n */\nexport function publicAssistCredits(\n costUsd: number | null,\n ceilingUsd: number | null,\n): PublicAssistCredits {\n const used = costUsd === null ? 0 : assistCreditsFromUsd(costUsd)\n if (ceilingUsd === null) return { used, limit: null, remaining: null }\n const limit = assistCreditsFromUsd(ceilingUsd)\n return { used, limit, remaining: Math.max(0, limit - used) }\n}\n\n/**\n * The console's name for the org's hard-cap switch (AGL-2653), quoted by the\n * refusal so the words on the 402 and the words beside the switch are one\n * string. Change it here and both move.\n */\nexport const ASSIST_HARD_CAP_CONTROL_LABEL = 'Stop AI assist at the included band'\n\n/** Where the switch lives, in the words the refusal uses. */\nexport const ASSIST_HARD_CAP_CONTROL_LOCATION = 'Billing → Usage'\n\n/**\n * Whether the org asked to be stopped at its assist band (AGL-2653).\n *\n * Strictly `=== true`: the map is absent on every org that has not touched\n * the switch, and absent is the selling default. A truthy string or a `1`\n * written by hand must not switch an assistant off at the band, so nothing\n * short of the boolean counts.\n */\nexport function resolveAssistHardCap(\n org: Partial<AglynOrgBilling> | null | undefined,\n): boolean {\n return org?.assistOverage?.hardCap === true\n}\n\n/**\n * Is the org's assist band a WALL, or a line past which credits are sold?\n *\n * Two things make it a wall, and only two:\n *\n * - **The org asked.** `assistOverage.hardCap` is the customer's own ceiling,\n * the `storageOverage.capUsd` of this meter.\n * - **There is nothing to sell past it at.** An org whose\n * `resolveAssistOverageRateUsdPer1k` is `null` has no rate to bill, so\n * credits past its band would be provider spend with no invoice line — the\n * silent free overage `plan-entitlements.spec.ts` forbids. Enterprise is\n * one case, its band being contractual; FREE is the other, its taste band\n * a wall by the AGL-2925 decision so that nothing about a Free workspace\n * can produce a charge.\n *\n * Everything else sells past the band by default, which is the 2026-09-07\n * decision this function encodes — and since AGL-3203 that includes Starter,\n * which carries 750 credits and a $3.00 rate like every paid tier above it.\n *\n * An org with NO band at all answers the same as Enterprise, harmlessly:\n * `resolveAssistBudgetUsd` is `null` for it and the reservation never\n * measures against a band. No plan ROW is in that state any more — Free and\n * Starter both were, at different times — so it is reached by a per-org\n * override that writes a zero.\n *\n * An UNCAPPED staff comp (AGL-3049) is neither: it has no band to be a wall,\n * and nothing is sold past a band it does not have. It answers false, ahead\n * of the switch — a workspace staff uncapped is not stopped by a band, and\n * the reservation reads that answer as \"no wall, no backstop default\".\n */\nexport function assistBandRefuses(\n org: Partial<AglynOrgBilling> | null | undefined,\n): boolean {\n if (isUncappedPlanComp(org)) return false\n if (resolveAssistHardCap(org)) return true\n return resolveAssistOverageRateUsdPer1k(org) === null\n}\n\n/**\n * Which ceiling refused a reservation, or `null` when it was admitted.\n *\n * - `messages`: the message cap (daily on the free rung, monthly entitled).\n * - `budget`: the operator's spend backstop.\n * - `band`: the plan's own band, as a wall — the switch, or a plan with\n * no rate to sell past it at.\n * - `cap`: the org's own dollar ceiling on overage (AGL-2898).\n *\n * The four that exist only on the Free taste (AGL-2925), each a precaution\n * against the one band that has no invoice behind it:\n *\n * - `account`: the ACCOUNT's 300 credits are spent across every free\n * workspace it owns — the org band alone would multiply by\n * the workspaces one person may hold.\n * - `requests`: the account's daily free request cap.\n * - `refusals`: the account's free generation is paused for the day after\n * too many `refusal` stops — a brief the model declined is a\n * brief that should not be retried thirty times.\n * - `platform`: the platform-wide daily ceiling on free spend; nobody's\n * fault, and \"try again tomorrow\" is the whole answer.\n *\n * Declared here, beside the helpers that read it, so the reservation, its\n * public projection and both doors name one union.\n */\nexport type AssistRefusedBy =\n | 'messages'\n | 'budget'\n | 'band'\n | 'cap'\n | 'account'\n | 'requests'\n | 'refusals'\n | 'platform'\n | null\n\n/**\n * The sentence a Free workspace is told when one of the taste's own\n * precautions refused it, or `null` when the refusal was something else.\n *\n * One string per rung, for both doors and the gate ladder, so a customer\n * reading the console panel and one reading the besigner get the same\n * words. Every sentence is customer-safe: it names what to do (upgrade,\n * wait) and never a figure from our cost model, a counter document, or\n * another workspace.\n */\nexport function assistFreeTasteRefusalText(\n refusedBy: AssistRefusedBy,\n): string | null {\n switch (refusedBy) {\n case 'account':\n return (\n 'Your free AI credits for this month are used across your ' +\n 'workspaces — upgrade any workspace to keep going.'\n )\n case 'requests':\n return (\n 'Free workspaces get a limited number of AI requests a day — ' +\n 'try again tomorrow, or upgrade to keep going.'\n )\n case 'refusals':\n return (\n 'The AI declined several requests today, so free AI generation is ' +\n 'paused until tomorrow. Try a different kind of brief then.'\n )\n case 'platform':\n return (\n 'Free AI generation is paused for the rest of today — try again ' +\n 'tomorrow. Paid workspaces are not affected.'\n )\n default:\n return null\n }\n}\n\n/**\n * The sentence a workspace whose plan does not carry `aiAssist` is told, at\n * the route and at every console door that checks the flag first.\n *\n * The plan it names is read from the plan table rather than written here.\n * Which tier first carries the flag is a pricing decision, and a name typed\n * into the sentence keeps naming the old tier after the table moves — telling\n * the reader to buy a plan above the one that would let them in. `undefined`\n * from the lookup means no tier carries the flag on its base row, and the\n * sentence then names no plan rather than a wrong one.\n */\nexport function assistNotOnPlanText(): string {\n const plan = planLabelGrantingFeature('aiAssist')\n return plan\n ? `AI assist starts on the ${plan} plan — see Billing to upgrade`\n : 'AI assist is not on this plan — see Billing to upgrade'\n}\n\n/**\n * Whether a refusal at the band was the org's OWN doing — the switch is on\n * AND turning it off would have let the exchange through. The refusal that\n * names the switch has to be the one the switch caused: on a plan with no\n * rate the band refuses whatever the switch says, and telling that customer\n * to turn it off would send them to a control that does nothing.\n */\nexport function assistRefusedByHardCap(\n org: Partial<AglynOrgBilling> | null | undefined,\n refusedBy: AssistRefusedBy,\n): boolean {\n return (\n refusedBy === 'band' &&\n resolveAssistHardCap(org) &&\n resolveAssistOverageRateUsdPer1k(org) !== null\n )\n}\n\n/**\n * The sentence a workspace is told when its own hard cap refused it. ONE\n * string for both assist entrypoints, so the console panel and the besigner\n * cannot describe the same control in two ways. It names the switch by its\n * label, says where it lives, and quotes the plan's rate so the reader knows\n * what turning it off costs.\n */\nexport function assistHardCapRefusalText(\n org: Partial<AglynOrgBilling> | null | undefined,\n): string {\n const rate = resolveAssistOverageRateUsdPer1k(org)\n const rateClause =\n rate === null ? '' : ` at $${rate.toFixed(2)} per 1,000 credits`\n return (\n `This workspace used its assistant credits for the month, and ` +\n `\"${ASSIST_HARD_CAP_CONTROL_LABEL}\" is on. Turn it off under ` +\n `${ASSIST_HARD_CAP_CONTROL_LOCATION} to keep going${rateClause}.`\n )\n}\n\nexport interface AssistMonthOverage extends AssistCreditOveragePrice {\n /** Credits drawn this month, rounded up from the measured spend. */\n usedCredits: number\n /** The band those credits were measured against, or `null` for no band. */\n bandCredits: number | null\n}\n\n/**\n * A month's assist overage from its measured spend — the ONE derivation the\n * invoice and every meter read (AGL-2653).\n *\n * Takes `estCostUsd` as the rollup reads it and composes the three steps\n * that already existed — credits from spend, overage past the band, price at\n * the plan's rate — so `report-usage` cannot assemble them in a different\n * order from a console readout and arrive at a different figure. The\n * rounding is the other overage terms' rounding: `priceAssistCreditOverage`\n * rounds to the cent, and the rollup turns dollars into cents with the same\n * `Math.round(usd * 100)` it applies to contacts, API and dataset storage.\n *\n * A non-finite or negative spend is zero credits, for the reason\n * `assistCreditsFromUsd` gives; a plan with no band or no rate prices zero\n * structurally, so Free, Starter and Enterprise bill nothing here without a\n * check that could be forgotten.\n */\nexport function assistMonthOverage(\n org: Partial<AglynOrgBilling> | null | undefined,\n estCostUsd: number,\n): AssistMonthOverage {\n const usedCredits = assistCreditsFromUsd(estCostUsd)\n const bandCredits = resolveAssistCreditBudget(org)\n return {\n usedCredits,\n bandCredits,\n ...priceAssistCreditOverage(\n org,\n assistCreditOverage(usedCredits, bandCredits),\n ),\n }\n}\n\n/** The console's name for the org's overage ceiling (AGL-2898). */\nexport const ASSIST_OVERAGE_CAP_CONTROL_LABEL =\n 'Stop AI when this month’s overage reaches'\n\n/**\n * Bounds on the ceiling a self-serve org may set. The floor is a dollar\n * because a ceiling of cents would refuse on the first exchange past the\n * band while reading as a limit; the roof is the storage cap's, so no\n * self-serve org writes itself an enterprise-sized commitment.\n */\nexport const ASSIST_OVERAGE_CAP_MIN_USD = 1\nexport const ASSIST_OVERAGE_CAP_MAX_USD = 100_000\n\n/**\n * The org's monthly ceiling on AI overage dollars, or `null` for none.\n *\n * Strictly a finite positive number. `null`, absent, a string, `NaN`,\n * `Infinity` and zero all read as no ceiling — the failure mode of the\n * alternative is a wall of `NaN` that every comparison passes, or a ceiling\n * of zero that refuses the first exchange past the band while the card\n * says nothing is set.\n */\nexport function resolveAssistOverageCapUsd(\n org: Partial<AglynOrgBilling> | null | undefined,\n): number | null {\n const raw = org?.assistOverage?.capUsd\n if (typeof raw !== 'number' || !Number.isFinite(raw) || raw <= 0) return null\n return raw\n}\n\n/**\n * Has this month's overage reached the org's own ceiling (AGL-2898)?\n *\n * Composed from `assistMonthOverage`, the ONE derivation the invoice reads,\n * so the figure the ceiling stops at is the figure the invoice would have\n * billed. That composition is also what confines the ceiling to plans that\n * sell past their band: a plan with no rate prices its overage to zero, and\n * zero never reaches a positive ceiling — Free and Enterprise cannot get here\n * without a check that could be forgotten. `>=` rather than `>`, because an\n * org that asked to stop AT a figure is stopped when the figure is met.\n */\nexport function assistOverageCapReached(\n org: Partial<AglynOrgBilling> | null | undefined,\n estCostUsd: number,\n): boolean {\n const cap = resolveAssistOverageCapUsd(org)\n if (cap === null) return false\n return assistMonthOverage(org, estCostUsd).overageMonthlyUsd >= cap\n}\n\n/**\n * Whether a refusal was the org's own ceiling. The reservation answers\n * `'cap'` only when `assistOverageCapReached` did, and that helper only\n * reaches a ceiling on a plan with a rate, so the two conditions restated\n * here are the same guarantee `assistRefusedByHardCap` gives the switch: the\n * control the sentence names is the control that refused.\n */\nexport function assistRefusedByOverageCap(\n org: Partial<AglynOrgBilling> | null | undefined,\n refusedBy: AssistRefusedBy,\n): boolean {\n return (\n refusedBy === 'cap' &&\n resolveAssistOverageCapUsd(org) !== null &&\n resolveAssistOverageRateUsdPer1k(org) !== null\n )\n}\n\n/**\n * The sentence a workspace is told when its own overage ceiling refused it.\n * ONE string for both assist entrypoints, as `assistHardCapRefusalText` is\n * for the switch. It names the control by its label, quotes the figure the\n * org chose, and says where to raise or clear it.\n */\nexport function assistOverageCapRefusalText(\n org: Partial<AglynOrgBilling> | null | undefined,\n): string {\n const cap = resolveAssistOverageCapUsd(org)\n const figure = cap === null ? '' : ` $${cap.toFixed(2)}`\n return (\n `This month’s AI assist overage reached${figure}, the figure you set as ` +\n `\"${ASSIST_OVERAGE_CAP_CONTROL_LABEL}\". Raise or remove it under ` +\n `${ASSIST_HARD_CAP_CONTROL_LOCATION} to keep going.`\n )\n}\n\n/**\n * The refusal sentence for whichever of the org's OWN controls refused, or\n * `null` when the refusal was nobody's control — the message cap, the\n * operator's backstop, or a plan that sells no overage. Both doors answer a\n * 402 with this text when it is a string and their usual 429 otherwise, so\n * the status and the sentence cannot disagree about whose decision it was.\n */\nexport function assistOwnControlRefusalText(\n org: Partial<AglynOrgBilling> | null | undefined,\n refusedBy: AssistRefusedBy,\n): string | null {\n if (assistRefusedByHardCap(org, refusedBy)) return assistHardCapRefusalText(org)\n if (assistRefusedByOverageCap(org, refusedBy)) {\n return assistOverageCapRefusalText(org)\n }\n return null\n}\n"],"names":["isUncappedPlanComp","planLabelGrantingFeature","resolveOrgEntitlements","resolvePlanPricing","planQuotaOf","ASSIST_CREDIT_COST_USD","ASSIST_PROVIDER_COST_FIELD","assistProviderCostUsd","billedUsd","providerUsd","provider","Number","isFinite","billed","ASSIST_CREDIT_MIN_MARGIN_PCT","assistUsdFromCredits","credits","value","Math","round","assistCreditsFromUsd","usd","ceil","resolveAssistCreditBudget","org","floor","resolveAssistBudgetUsd","assistCreditOverage","usedCredits","bandCredits","used","band","max","resolveAssistOverageRateUsdPer1k","extraAssistCreditsUsdPer1k","priceAssistCreditOverage","overageCredits","overageRateUsd","over","overageMonthlyUsd","assistCreditRateMarginPct","rateUsdPer1k","rate","costPer1k","publicAssistCredits","costUsd","ceilingUsd","limit","remaining","ASSIST_HARD_CAP_CONTROL_LABEL","ASSIST_HARD_CAP_CONTROL_LOCATION","resolveAssistHardCap","assistOverage","hardCap","assistBandRefuses","assistFreeTasteRefusalText","refusedBy","assistNotOnPlanText","plan","assistRefusedByHardCap","assistHardCapRefusalText","rateClause","toFixed","assistMonthOverage","estCostUsd","ASSIST_OVERAGE_CAP_CONTROL_LABEL","ASSIST_OVERAGE_CAP_MIN_USD","ASSIST_OVERAGE_CAP_MAX_USD","resolveAssistOverageCapUsd","raw","capUsd","assistOverageCapReached","cap","assistRefusedByOverageCap","assistOverageCapRefusalText","figure","assistOwnControlRefusalText"],"mappings":"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"}
|