@aglyn/plugins-ai 1.0.0-beta.237 → 1.0.0-beta.238

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/package.json +17 -17
  2. package/src/lib/components/ai-credits-prompt.component.d.ts +11 -4
  3. package/src/lib/components/ai-credits-prompt.component.js +84 -34
  4. package/src/lib/components/ai-credits-prompt.component.js.map +1 -1
  5. package/src/lib/components/ai-job-cancel.component.d.ts +38 -0
  6. package/src/lib/components/ai-job-cancel.component.js +156 -0
  7. package/src/lib/components/ai-job-cancel.component.js.map +1 -0
  8. package/src/lib/components/ai-job-plan.component.js +12 -1
  9. package/src/lib/components/ai-job-plan.component.js.map +1 -1
  10. package/src/lib/components/ai-jobs-list-page.component.js +141 -72
  11. package/src/lib/components/ai-jobs-list-page.component.js.map +1 -1
  12. package/src/lib/components/ai-site-build-page.component.js +22 -2
  13. package/src/lib/components/ai-site-build-page.component.js.map +1 -1
  14. package/src/lib/components/ai-usage-strip.component.d.ts +19 -2
  15. package/src/lib/components/ai-usage-strip.component.js +40 -9
  16. package/src/lib/components/ai-usage-strip.component.js.map +1 -1
  17. package/src/lib/components/assist-jobs-drawer.component.js +12 -16
  18. package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
  19. package/src/lib/components/assist-panel.component.js +87 -51
  20. package/src/lib/components/assist-panel.component.js.map +1 -1
  21. package/src/lib/jobs/ai-job-build-step.js +4 -1
  22. package/src/lib/jobs/ai-job-build-step.js.map +1 -1
  23. package/src/lib/jobs/ai-job-cancel.d.ts +71 -0
  24. package/src/lib/jobs/ai-job-cancel.js +102 -0
  25. package/src/lib/jobs/ai-job-cancel.js.map +1 -0
  26. package/src/lib/jobs/ai-job-component-step.js +4 -1
  27. package/src/lib/jobs/ai-job-component-step.js.map +1 -1
  28. package/src/lib/jobs/ai-job-drafts.js +2 -0
  29. package/src/lib/jobs/ai-job-drafts.js.map +1 -1
  30. package/src/lib/jobs/ai-job-form-step.js +4 -1
  31. package/src/lib/jobs/ai-job-form-step.js.map +1 -1
  32. package/src/lib/jobs/ai-job-layout-language.d.ts +2 -0
  33. package/src/lib/jobs/ai-job-layout-language.js +13 -1
  34. package/src/lib/jobs/ai-job-layout-language.js.map +1 -1
  35. package/src/lib/jobs/ai-job-layout-step.js +6 -1
  36. package/src/lib/jobs/ai-job-layout-step.js.map +1 -1
  37. package/src/lib/jobs/ai-job-page-language.js +8 -0
  38. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  39. package/src/lib/jobs/ai-job-page-step.js +12 -1
  40. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  41. package/src/lib/jobs/ai-job-plan-step.d.ts +20 -1
  42. package/src/lib/jobs/ai-job-plan-step.js +49 -9
  43. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  44. package/src/lib/jobs/ai-job-site-content.d.ts +15 -0
  45. package/src/lib/jobs/ai-job-site-content.js +66 -7
  46. package/src/lib/jobs/ai-job-site-content.js.map +1 -1
  47. package/src/lib/jobs/ai-job-site-step.js +4 -1
  48. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  49. package/src/lib/jobs/ai-job-template-step.js +4 -1
  50. package/src/lib/jobs/ai-job-template-step.js.map +1 -1
  51. package/src/lib/jobs/ai-jobs.d.ts +37 -12
  52. package/src/lib/jobs/ai-jobs.js +184 -28
  53. package/src/lib/jobs/ai-jobs.js.map +1 -1
  54. package/src/lib/jobs/ai-layout-stock-photos.d.ts +143 -70
  55. package/src/lib/jobs/ai-layout-stock-photos.js +649 -176
  56. package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
  57. package/src/lib/jobs/fixtures/ai-ceramics-portfolio-site-plan.d.ts +30 -0
  58. package/src/lib/jobs/fixtures/ai-ceramics-portfolio-site-plan.js +220 -0
  59. package/src/lib/jobs/fixtures/ai-ceramics-portfolio-site-plan.js.map +1 -0
  60. package/src/lib/jobs/fixtures/ai-stock-photo-pixabay-recordings.d.ts +27 -0
  61. package/src/lib/jobs/fixtures/ai-stock-photo-pixabay-recordings.js +151 -0
  62. package/src/lib/jobs/fixtures/ai-stock-photo-pixabay-recordings.js.map +1 -0
  63. package/src/lib/layout-language/ai-layout-compiler.d.ts +6 -0
  64. package/src/lib/layout-language/ai-layout-compiler.js +304 -8
  65. package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
  66. package/src/lib/layout-language/ai-layout-design.d.ts +6 -3
  67. package/src/lib/layout-language/ai-layout-design.js +5 -3
  68. package/src/lib/layout-language/ai-layout-design.js.map +1 -1
  69. package/src/lib/layout-language/ai-layout-frame.d.ts +12 -0
  70. package/src/lib/layout-language/ai-layout-frame.js +58 -3
  71. package/src/lib/layout-language/ai-layout-frame.js.map +1 -1
  72. package/src/lib/layout-language/ai-layout-listings.d.ts +13 -1
  73. package/src/lib/layout-language/ai-layout-listings.js +61 -46
  74. package/src/lib/layout-language/ai-layout-listings.js.map +1 -1
  75. package/src/lib/layout-language/ai-layout-pictures.d.ts +8 -0
  76. package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
  77. package/src/lib/layout-language/ai-layout-storefront.d.ts +82 -0
  78. package/src/lib/layout-language/ai-layout-storefront.js +223 -0
  79. package/src/lib/layout-language/ai-layout-storefront.js.map +1 -0
  80. package/src/lib/model/ai-build-job.d.ts +9 -1
  81. package/src/lib/model/ai-build-job.js +19 -5
  82. package/src/lib/model/ai-build-job.js.map +1 -1
  83. package/src/lib/model/ai-build-progress.d.ts +2 -2
  84. package/src/lib/model/ai-build-progress.js +6 -4
  85. package/src/lib/model/ai-build-progress.js.map +1 -1
  86. package/src/lib/model/ai-credit-estimate.d.ts +69 -2
  87. package/src/lib/model/ai-credit-estimate.js +91 -3
  88. package/src/lib/model/ai-credit-estimate.js.map +1 -1
  89. package/src/lib/model/ai-job-cancel-copy.d.ts +44 -0
  90. package/src/lib/model/ai-job-cancel-copy.js +53 -0
  91. package/src/lib/model/ai-job-cancel-copy.js.map +1 -0
  92. package/src/lib/model/ai-job-failure-copy.d.ts +4 -1
  93. package/src/lib/model/ai-job-failure-copy.js +8 -3
  94. package/src/lib/model/ai-job-failure-copy.js.map +1 -1
  95. package/src/lib/model/ai-jobs.types.d.ts +20 -0
  96. package/src/lib/model/ai-jobs.types.js.map +1 -1
  97. package/src/lib/model/ai-site-build-progress.js +7 -4
  98. package/src/lib/model/ai-site-build-progress.js.map +1 -1
  99. package/src/lib/model/ai-site-job.d.ts +19 -2
  100. package/src/lib/model/ai-site-job.js +29 -8
  101. package/src/lib/model/ai-site-job.js.map +1 -1
  102. package/src/lib/model/ai-site-kinds.d.ts +4 -1
  103. package/src/lib/model/ai-site-kinds.js +34 -6
  104. package/src/lib/model/ai-site-kinds.js.map +1 -1
  105. package/src/lib/runtime/ai-doctrine-validators.d.ts +37 -1
  106. package/src/lib/runtime/ai-doctrine-validators.js +151 -6
  107. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  108. package/src/lib/runtime/ai-doctrine.js +4 -3
  109. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  110. package/src/lib/runtime/ai-palette.generated.js +53 -7
  111. package/src/lib/runtime/ai-palette.generated.js.map +1 -1
  112. package/src/lib/server/ai-jobs-cancel.d.ts +13 -5
  113. package/src/lib/server/ai-jobs-cancel.js +27 -9
  114. package/src/lib/server/ai-jobs-cancel.js.map +1 -1
  115. package/src/lib/server/ai-jobs-gate.d.ts +12 -2
  116. package/src/lib/server/ai-jobs-gate.js +20 -9
  117. package/src/lib/server/ai-jobs-gate.js.map +1 -1
  118. package/src/lib/usage/ai-usage-meter.d.ts +2 -1
  119. package/src/lib/usage/ai-usage-meter.js +17 -1
  120. package/src/lib/usage/ai-usage-meter.js.map +1 -1
  121. package/src/lib/usage/ai-usage-wire.d.ts +6 -0
  122. package/src/lib/usage/ai-usage-wire.js.map +1 -1
  123. package/src/lib/usage/assist-usage.d.ts +7 -0
  124. package/src/lib/usage/assist-usage.js +14 -4
  125. package/src/lib/usage/assist-usage.js.map +1 -1
  126. package/src/lib/usage/free-ai-credits-left.d.ts +7 -0
  127. package/src/lib/usage/free-ai-credits-left.js +4 -2
  128. package/src/lib/usage/free-ai-credits-left.js.map +1 -1
@@ -15,11 +15,19 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  /**
18
- * Cancel a job (AGL-2904). Idempotent: a job already terminal answers as
19
- * it is, unchanged. A step in flight is not interrupted — the runner that
20
- * holds its lease records what it cost and finds the job canceled when it
21
- * goes to complete it — so a cancel is never a way to spend tokens the
22
- * document does not show. Audited per cancel that changed something.
18
+ * Cancel a job (AGL-2904, AGL-3616). `POST /api/ai/jobs/{jobId}/cancel`
19
+ * `{ orgId }`.
20
+ *
21
+ * Who may: a member who holds `ai.generate` on the job's site (the
22
+ * permission that started it), or staff, member or not. Anyone else is
23
+ * refused 403 and the job is untouched.
24
+ *
25
+ * What it does is `cancelAiJob`'s, in one transaction: a job no step holds
26
+ * ends `canceled` now; a job whose step is in flight records the cancel and
27
+ * ends `canceled` when that step finishes or aborts, which the answer's
28
+ * `job.cancelRequested` says. Idempotent: a job already done, failed or
29
+ * canceled answers as it is, `changed: false`. Audited per cancel that
30
+ * changed something.
23
31
  */
24
32
  export declare function POST(request: Request, context: {
25
33
  params: Promise<{
@@ -1,3 +1,4 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
1
2
  /**
2
3
  * @license
3
4
  * Copyright 2026 Aglyn LLC
@@ -15,13 +16,22 @@
15
16
  * limitations under the License.
16
17
  */ // lockdown-423: via libs/plugins/ai/src/lib/server/ai-jobs-gate.ts
17
18
  import { aiJobSummary, cancelAiJob, getAiJob, writeAiJobAudit } from "../jobs/ai-jobs.js";
19
+ import { memberHasPermissionOnHost, permissionRefusal } from "@aglyn/tenant-data-admin/server/organizations";
18
20
  import { aiJobsGate } from "./ai-jobs-gate.js";
19
21
  /**
20
- * Cancel a job (AGL-2904). Idempotent: a job already terminal answers as
21
- * it is, unchanged. A step in flight is not interrupted — the runner that
22
- * holds its lease records what it cost and finds the job canceled when it
23
- * goes to complete it — so a cancel is never a way to spend tokens the
24
- * document does not show. Audited per cancel that changed something.
22
+ * Cancel a job (AGL-2904, AGL-3616). `POST /api/ai/jobs/{jobId}/cancel`
23
+ * `{ orgId }`.
24
+ *
25
+ * Who may: a member who holds `ai.generate` on the job's site (the
26
+ * permission that started it), or staff, member or not. Anyone else is
27
+ * refused 403 and the job is untouched.
28
+ *
29
+ * What it does is `cancelAiJob`'s, in one transaction: a job no step holds
30
+ * ends `canceled` now; a job whose step is in flight records the cancel and
31
+ * ends `canceled` when that step finishes or aborts, which the answer's
32
+ * `job.cancelRequested` says. Idempotent: a job already done, failed or
33
+ * canceled answers as it is, `changed: false`. Audited per cancel that
34
+ * changed something.
25
35
  */ export async function POST(request, context) {
26
36
  var _ref, _gate_decoded_email;
27
37
  let payload;
@@ -30,7 +40,9 @@ import { aiJobsGate } from "./ai-jobs-gate.js";
30
40
  } catch (unused) {
31
41
  payload = null;
32
42
  }
33
- const gate = await aiJobsGate(request, String((_ref = payload == null ? void 0 : payload.orgId) != null ? _ref : ''));
43
+ const gate = await aiJobsGate(request, String((_ref = payload == null ? void 0 : payload.orgId) != null ? _ref : ''), {
44
+ staffWithoutMembership: true
45
+ });
34
46
  if (gate instanceof Response) return gate;
35
47
  const { jobId } = await context.params;
36
48
  const existing = await getAiJob(gate.firestore, gate.orgId, jobId);
@@ -39,6 +51,10 @@ import { aiJobsGate } from "./ai-jobs-gate.js";
39
51
  }, {
40
52
  status: 404
41
53
  });
54
+ // The job's own permission, on the job's own site (AGL-3616).
55
+ if (!gate.staff && !await memberHasPermissionOnHost(gate.orgId, existing.hostId, gate.member, 'ai.generate')) {
56
+ return permissionRefusal('ai.generate');
57
+ }
42
58
  const now = new Date();
43
59
  // The machine writes the org feed's row with this actor when the cancel
44
60
  // changed something; the staff audit row below is this route's own.
@@ -54,11 +70,13 @@ import { aiJobsGate } from "./ai-jobs-gate.js";
54
70
  actorEmail: (_gate_decoded_email1 = gate.decoded.email) != null ? _gate_decoded_email1 : null,
55
71
  orgId: gate.orgId,
56
72
  jobId,
57
- after: {
58
- status: 'canceled',
73
+ after: _extends({
74
+ status: job.status,
59
75
  wasStatus: existing.status,
60
76
  kind: existing.kind
61
- }
77
+ }, gate.staff ? {
78
+ staff: true
79
+ } : {})
62
80
  });
63
81
  }
64
82
  return Response.json({
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-jobs-cancel.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// lockdown-423: via libs/plugins/ai/src/lib/server/ai-jobs-gate.ts\n\nimport {\n aiJobSummary,\n cancelAiJob,\n getAiJob,\n writeAiJobAudit,\n} from '../jobs/ai-jobs'\nimport { aiJobsGate } from './ai-jobs-gate'\n\n/**\n * Cancel a job (AGL-2904). Idempotent: a job already terminal answers as\n * it is, unchanged. A step in flight is not interrupted — the runner that\n * holds its lease records what it cost and finds the job canceled when it\n * goes to complete it — so a cancel is never a way to spend tokens the\n * document does not show. Audited per cancel that changed something.\n */\nexport async function POST(\n request: Request,\n context: { params: Promise<{ jobId: string }> },\n): Promise<Response> {\n let payload: Record<string, unknown> | null\n try {\n payload = (await request.json()) as Record<string, unknown>\n } catch {\n payload = null\n }\n const gate = await aiJobsGate(request, String(payload?.orgId ?? ''))\n if (gate instanceof Response) return gate\n const { jobId } = await context.params\n const existing = await getAiJob(gate.firestore, gate.orgId, jobId)\n if (!existing) return Response.json({ error: 'Unknown job' }, { status: 404 })\n const now = new Date()\n // The machine writes the org feed's row with this actor when the cancel\n // changed something; the staff audit row below is this route's own.\n const { job, changed } = await cancelAiJob(gate.firestore, gate.orgId, jobId, now, {\n uid: gate.uid,\n email: gate.decoded.email ?? null,\n })\n if (changed) {\n await writeAiJobAudit(gate.firestore, {\n action: 'ai.job.cancel',\n actorUid: gate.uid,\n actorEmail: gate.decoded.email ?? null,\n orgId: gate.orgId,\n jobId,\n after: { status: 'canceled', wasStatus: existing.status, kind: existing.kind },\n })\n }\n return Response.json({ job: aiJobSummary(job, now), changed }, { status: 200 })\n}\n\nexport const dynamic = 'force-dynamic'\nexport const maxDuration = 60\n"],"names":["aiJobSummary","cancelAiJob","getAiJob","writeAiJobAudit","aiJobsGate","POST","request","context","gate","payload","json","String","orgId","Response","jobId","params","existing","firestore","error","status","now","Date","job","changed","uid","email","decoded","action","actorUid","actorEmail","after","wasStatus","kind","dynamic","maxDuration"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,mEAAmE;AAEnE,SACEA,YAAY,EACZC,WAAW,EACXC,QAAQ,EACRC,eAAe,QACV,qBAAiB;AACxB,SAASC,UAAU,QAAQ,oBAAgB;AAE3C;;;;;;CAMC,GACD,OAAO,eAAeC,KACpBC,OAAgB,EAChBC,OAA+C;cAkBtCC;IAhBT,IAAIC;IACJ,IAAI;QACFA,UAAW,MAAMH,QAAQI,IAAI;IAC/B,EAAE,eAAM;QACND,UAAU;IACZ;IACA,MAAMD,OAAO,MAAMJ,WAAWE,SAASK,eAAOF,2BAAAA,QAASG,KAAK,mBAAI;IAChE,IAAIJ,gBAAgBK,UAAU,OAAOL;IACrC,MAAM,EAAEM,KAAK,EAAE,GAAG,MAAMP,QAAQQ,MAAM;IACtC,MAAMC,WAAW,MAAMd,SAASM,KAAKS,SAAS,EAAET,KAAKI,KAAK,EAAEE;IAC5D,IAAI,CAACE,UAAU,OAAOH,SAASH,IAAI,CAAC;QAAEQ,OAAO;IAAc,GAAG;QAAEC,QAAQ;IAAI;IAC5E,MAAMC,MAAM,IAAIC;IAChB,wEAAwE;IACxE,oEAAoE;IACpE,MAAM,EAAEC,GAAG,EAAEC,OAAO,EAAE,GAAG,MAAMtB,YAAYO,KAAKS,SAAS,EAAET,KAAKI,KAAK,EAAEE,OAAOM,KAAK;QACjFI,KAAKhB,KAAKgB,GAAG;QACbC,KAAK,GAAEjB,sBAAAA,KAAKkB,OAAO,CAACD,KAAK,YAAlBjB,sBAAsB;IAC/B;IACA,IAAIe,SAAS;YAIGf;QAHd,MAAML,gBAAgBK,KAAKS,SAAS,EAAE;YACpCU,QAAQ;YACRC,UAAUpB,KAAKgB,GAAG;YAClBK,UAAU,GAAErB,uBAAAA,KAAKkB,OAAO,CAACD,KAAK,YAAlBjB,uBAAsB;YAClCI,OAAOJ,KAAKI,KAAK;YACjBE;YACAgB,OAAO;gBAAEX,QAAQ;gBAAYY,WAAWf,SAASG,MAAM;gBAAEa,MAAMhB,SAASgB,IAAI;YAAC;QAC/E;IACF;IACA,OAAOnB,SAASH,IAAI,CAAC;QAAEY,KAAKtB,aAAasB,KAAKF;QAAMG;IAAQ,GAAG;QAAEJ,QAAQ;IAAI;AAC/E;AAEA,OAAO,MAAMc,UAAU,gBAAe;AACtC,OAAO,MAAMC,cAAc,GAAE"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-jobs-cancel.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// lockdown-423: via libs/plugins/ai/src/lib/server/ai-jobs-gate.ts\n\nimport {\n aiJobSummary,\n cancelAiJob,\n getAiJob,\n writeAiJobAudit,\n} from '../jobs/ai-jobs'\nimport { memberHasPermissionOnHost, permissionRefusal } from '@aglyn/tenant-data-admin/server/organizations'\nimport { aiJobsGate } from './ai-jobs-gate'\n\n/**\n * Cancel a job (AGL-2904, AGL-3616). `POST /api/ai/jobs/{jobId}/cancel`\n * `{ orgId }`.\n *\n * Who may: a member who holds `ai.generate` on the job's site (the\n * permission that started it), or staff, member or not. Anyone else is\n * refused 403 and the job is untouched.\n *\n * What it does is `cancelAiJob`'s, in one transaction: a job no step holds\n * ends `canceled` now; a job whose step is in flight records the cancel and\n * ends `canceled` when that step finishes or aborts, which the answer's\n * `job.cancelRequested` says. Idempotent: a job already done, failed or\n * canceled answers as it is, `changed: false`. Audited per cancel that\n * changed something.\n */\nexport async function POST(\n request: Request,\n context: { params: Promise<{ jobId: string }> },\n): Promise<Response> {\n let payload: Record<string, unknown> | null\n try {\n payload = (await request.json()) as Record<string, unknown>\n } catch {\n payload = null\n }\n const gate = await aiJobsGate(request, String(payload?.orgId ?? ''), { staffWithoutMembership: true })\n if (gate instanceof Response) return gate\n const { jobId } = await context.params\n const existing = await getAiJob(gate.firestore, gate.orgId, jobId)\n if (!existing) return Response.json({ error: 'Unknown job' }, { status: 404 })\n // The job's own permission, on the job's own site (AGL-3616).\n if (!gate.staff && !(await memberHasPermissionOnHost(gate.orgId, existing.hostId, gate.member, 'ai.generate'))) {\n return permissionRefusal('ai.generate')\n }\n const now = new Date()\n // The machine writes the org feed's row with this actor when the cancel\n // changed something; the staff audit row below is this route's own.\n const { job, changed } = await cancelAiJob(gate.firestore, gate.orgId, jobId, now, {\n uid: gate.uid,\n email: gate.decoded.email ?? null,\n })\n if (changed) {\n await writeAiJobAudit(gate.firestore, {\n action: 'ai.job.cancel',\n actorUid: gate.uid,\n actorEmail: gate.decoded.email ?? null,\n orgId: gate.orgId,\n jobId,\n after: {\n status: job.status,\n wasStatus: existing.status,\n kind: existing.kind,\n ...(gate.staff ? { staff: true } : {}),\n },\n })\n }\n return Response.json({ job: aiJobSummary(job, now), changed }, { status: 200 })\n}\n\nexport const dynamic = 'force-dynamic'\nexport const maxDuration = 60\n"],"names":["aiJobSummary","cancelAiJob","getAiJob","writeAiJobAudit","memberHasPermissionOnHost","permissionRefusal","aiJobsGate","POST","request","context","gate","payload","json","String","orgId","staffWithoutMembership","Response","jobId","params","existing","firestore","error","status","staff","hostId","member","now","Date","job","changed","uid","email","decoded","action","actorUid","actorEmail","after","wasStatus","kind","dynamic","maxDuration"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,mEAAmE;AAEnE,SACEA,YAAY,EACZC,WAAW,EACXC,QAAQ,EACRC,eAAe,QACV,qBAAiB;AACxB,SAASC,yBAAyB,EAAEC,iBAAiB,QAAQ,gDAA+C;AAC5G,SAASC,UAAU,QAAQ,oBAAgB;AAE3C;;;;;;;;;;;;;;CAcC,GACD,OAAO,eAAeC,KACpBC,OAAgB,EAChBC,OAA+C;cAsBtCC;IApBT,IAAIC;IACJ,IAAI;QACFA,UAAW,MAAMH,QAAQI,IAAI;IAC/B,EAAE,eAAM;QACND,UAAU;IACZ;IACA,MAAMD,OAAO,MAAMJ,WAAWE,SAASK,eAAOF,2BAAAA,QAASG,KAAK,mBAAI,KAAK;QAAEC,wBAAwB;IAAK;IACpG,IAAIL,gBAAgBM,UAAU,OAAON;IACrC,MAAM,EAAEO,KAAK,EAAE,GAAG,MAAMR,QAAQS,MAAM;IACtC,MAAMC,WAAW,MAAMjB,SAASQ,KAAKU,SAAS,EAAEV,KAAKI,KAAK,EAAEG;IAC5D,IAAI,CAACE,UAAU,OAAOH,SAASJ,IAAI,CAAC;QAAES,OAAO;IAAc,GAAG;QAAEC,QAAQ;IAAI;IAC5E,8DAA8D;IAC9D,IAAI,CAACZ,KAAKa,KAAK,IAAI,CAAE,MAAMnB,0BAA0BM,KAAKI,KAAK,EAAEK,SAASK,MAAM,EAAEd,KAAKe,MAAM,EAAE,gBAAiB;QAC9G,OAAOpB,kBAAkB;IAC3B;IACA,MAAMqB,MAAM,IAAIC;IAChB,wEAAwE;IACxE,oEAAoE;IACpE,MAAM,EAAEC,GAAG,EAAEC,OAAO,EAAE,GAAG,MAAM5B,YAAYS,KAAKU,SAAS,EAAEV,KAAKI,KAAK,EAAEG,OAAOS,KAAK;QACjFI,KAAKpB,KAAKoB,GAAG;QACbC,KAAK,GAAErB,sBAAAA,KAAKsB,OAAO,CAACD,KAAK,YAAlBrB,sBAAsB;IAC/B;IACA,IAAImB,SAAS;YAIGnB;QAHd,MAAMP,gBAAgBO,KAAKU,SAAS,EAAE;YACpCa,QAAQ;YACRC,UAAUxB,KAAKoB,GAAG;YAClBK,UAAU,GAAEzB,uBAAAA,KAAKsB,OAAO,CAACD,KAAK,YAAlBrB,uBAAsB;YAClCI,OAAOJ,KAAKI,KAAK;YACjBG;YACAmB,OAAO;gBACLd,QAAQM,IAAIN,MAAM;gBAClBe,WAAWlB,SAASG,MAAM;gBAC1BgB,MAAMnB,SAASmB,IAAI;eACf5B,KAAKa,KAAK,GAAG;gBAAEA,OAAO;YAAK,IAAI,CAAC;QAExC;IACF;IACA,OAAOP,SAASJ,IAAI,CAAC;QAAEgB,KAAK5B,aAAa4B,KAAKF;QAAMG;IAAQ,GAAG;QAAEP,QAAQ;IAAI;AAC/E;AAEA,OAAO,MAAMiB,UAAU,gBAAe;AACtC,OAAO,MAAMC,cAAc,GAAE"}
@@ -15,7 +15,7 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import type { DecodedIdToken } from 'firebase-admin/auth';
18
- import type { AglynOrganization } from '@aglyn/aglyn/foundation/definitions/organization.types';
18
+ import type { AglynOrganization, AglynOrgMember } from '@aglyn/aglyn/foundation/definitions/organization.types';
19
19
  /**
20
20
  * The gate the READ and CANCEL doors of AI jobs climb (AGL-2904).
21
21
  *
@@ -42,6 +42,16 @@ export interface AiJobsGateContext {
42
42
  staff: boolean;
43
43
  orgId: string;
44
44
  org: Partial<AglynOrganization>;
45
+ /** The caller's membership; `null` only for staff let in by `staffWithoutMembership`. */
46
+ member: AglynOrgMember | null;
45
47
  firestore: FirebaseFirestore.Firestore;
46
48
  }
47
- export declare function aiJobsGate(request: Request, rawOrgId: string): Promise<Response | AiJobsGateContext>;
49
+ export interface AiJobsGateOptions {
50
+ /**
51
+ * A verified staff claim passes the membership rung for an org it is not a
52
+ * member of (AGL-3616): staff stop a customer's job from the job's own
53
+ * page. Every rung after it is climbed as for anyone else.
54
+ */
55
+ staffWithoutMembership?: boolean;
56
+ }
57
+ export declare function aiJobsGate(request: Request, rawOrgId: string, options?: AiJobsGateOptions): Promise<Response | AiJobsGateContext>;
@@ -28,8 +28,7 @@ function bearerToken(request) {
28
28
  const authorization = (_request_headers_get = request.headers.get('authorization')) != null ? _request_headers_get : '';
29
29
  return authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
30
30
  }
31
- export async function aiJobsGate(request, rawOrgId) {
32
- var _resolved_org;
31
+ export async function aiJobsGate(request, rawOrgId, options = {}) {
33
32
  const idToken = bearerToken(request);
34
33
  if (!idToken) {
35
34
  return Response.json({
@@ -63,14 +62,25 @@ export async function aiJobsGate(request, rawOrgId) {
63
62
  });
64
63
  }
65
64
  const resolved = await getOrgForUser(decoded.uid, orgId);
66
- if (!resolved || resolved.orgId !== orgId) {
67
- return Response.json({
68
- error: 'You are not a member of that organization'
69
- }, {
70
- status: 403
71
- });
65
+ let org;
66
+ let member;
67
+ if (resolved && resolved.orgId === orgId) {
68
+ var _resolved_org, _resolved_member;
69
+ org = (_resolved_org = resolved.org) != null ? _resolved_org : {};
70
+ member = (_resolved_member = resolved.member) != null ? _resolved_member : null;
71
+ } else {
72
+ var _staffOrg_data;
73
+ const staffOrg = staff && options.staffWithoutMembership ? await app.firestore().collection('orgs').doc(orgId).get() : null;
74
+ if (!(staffOrg == null ? void 0 : staffOrg.exists)) {
75
+ return Response.json({
76
+ error: 'You are not a member of that organization'
77
+ }, {
78
+ status: 403
79
+ });
80
+ }
81
+ org = (_staffOrg_data = staffOrg.data()) != null ? _staffOrg_data : {};
82
+ member = null;
72
83
  }
73
- const org = (_resolved_org = resolved.org) != null ? _resolved_org : {};
74
84
  if (!staff && !await isServerReleaseFlagOnForOrg('release_ai_generative', orgId)) {
75
85
  return Response.json({
76
86
  error: 'Not found'
@@ -99,6 +109,7 @@ export async function aiJobsGate(request, rawOrgId) {
99
109
  staff,
100
110
  orgId,
101
111
  org,
112
+ member,
102
113
  firestore: app.firestore()
103
114
  };
104
115
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-jobs-gate.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { DecodedIdToken } from 'firebase-admin/auth'\nimport { checkEntitlement } from '@aglyn/aglyn/server'\nimport type { AglynOrganization } from '@aglyn/aglyn/foundation/definitions/organization.types'\n// By their own entry points rather than the barrel, for the reason the chat\n// route gives for the runtime: the route specs replace the barrel with a\n// closed-world factory, and a leaf that is mocked at its own seam is one\n// the spec can drive rung by rung.\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { lockdownRefusal } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { getOrgForUser } from '@aglyn/tenant-data-admin/server/organizations'\nimport { isServerReleaseFlagOnForOrg } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\n\n/**\n * The gate the READ and CANCEL doors of AI jobs climb (AGL-2904).\n *\n * `aiGateLadder` is the ladder a door that SPENDS climbs: it is POST-only\n * and ends by taking a reservation, because its whole purpose is to bound\n * provider spend. Listing a workspace's jobs, watching one, or canceling\n * one spends nothing and answers a GET, so those doors climb the same\n * rungs up to the lockdown verdict and stop there — no rate window and no\n * reservation. The rungs are in the ladder's order, for the ladder's\n * reason: each one refused discloses nothing a lower rung would.\n *\n * 401 no bearer token, or a token the verifier refused\n * 403 email unverified (an impersonation session is exempt)\n * 400 the request did not NAME the org (AGL-1934)\n * 403 the caller is not a member of that org\n * 404 the release flag is off — a released-off feature does not exist;\n * a verified staff claim previews through it\n * 403 the org's plan lacks `aiGenerative`\n * 423 lockdown: the scope verdict (platform/org/user)\n */\nexport interface AiJobsGateContext {\n uid: string\n decoded: DecodedIdToken\n staff: boolean\n orgId: string\n org: Partial<AglynOrganization>\n firestore: FirebaseFirestore.Firestore\n}\n\nfunction bearerToken(request: Request): string | undefined {\n const authorization = request.headers.get('authorization') ?? ''\n return authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n}\n\nexport async function aiJobsGate(\n request: Request,\n rawOrgId: string,\n): Promise<Response | AiJobsGateContext> {\n const idToken = bearerToken(request)\n if (!idToken) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n const app = firebaseAdmin.app()\n let decoded: DecodedIdToken\n try {\n decoded = await app.auth().verifyIdToken(idToken)\n } catch (error) {\n // A refused credential is the caller's fault and a 401; anything else\n // is ours and propagates so the door answers 500 rather than telling\n // every user their credential is bad during an outage (AGL-1993).\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n throw error\n }\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n const staff = decoded['staff'] === true\n\n const orgId = rawOrgId.trim()\n if (!orgId) {\n return Response.json(\n { error: 'Open a workspace before using this feature' },\n { status: 400 },\n )\n }\n const resolved = await getOrgForUser(decoded.uid, orgId)\n if (!resolved || resolved.orgId !== orgId) {\n return Response.json(\n { error: 'You are not a member of that organization' },\n { status: 403 },\n )\n }\n const org = resolved.org ?? {}\n\n if (!staff && !(await isServerReleaseFlagOnForOrg('release_ai_generative', orgId))) {\n return Response.json({ error: 'Not found' }, { status: 404 })\n }\n if (!checkEntitlement(org, 'aiGenerative')) {\n return Response.json(\n {\n error: \"This workspace's plan does not include that feature\",\n reason: 'entitlement',\n },\n { status: 403 },\n )\n }\n const locked = await lockdownRefusal({\n request,\n staff,\n uid: decoded.uid,\n org: org as Record<string, unknown>,\n })\n if (locked) return locked\n\n return {\n uid: decoded.uid,\n decoded,\n staff,\n orgId,\n org,\n firestore: app.firestore(),\n }\n}\n"],"names":["checkEntitlement","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","lockdownRefusal","getOrgForUser","isServerReleaseFlagOnForOrg","invalidIdTokenResponse","bearerToken","request","authorization","headers","get","startsWith","slice","length","undefined","aiJobsGate","rawOrgId","resolved","idToken","Response","json","error","status","app","decoded","auth","verifyIdToken","unauthenticated","email_verified","staff","orgId","trim","uid","org","reason","locked","firestore"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAGD,SAASA,gBAAgB,QAAQ,sBAAqB;AAEtD,4EAA4E;AAC5E,yEAAyE;AACzE,yEAAyE;AACzE,mCAAmC;AACnC,SACEC,uBAAuB,EACvBC,aAAa,EACbC,sBAAsB,QACjB,iDAAgD;AACvD,SAASC,eAAe,QAAQ,2CAA0C;AAC1E,SAASC,aAAa,QAAQ,gDAA+C;AAC7E,SAASC,2BAA2B,QAAQ,gDAA+C;AAC3F,SAASC,sBAAsB,QAAQ,mDAAkD;AA+BzF,SAASC,YAAYC,OAAgB;QACbA;IAAtB,MAAMC,iBAAgBD,uBAAAA,QAAQE,OAAO,CAACC,GAAG,CAAC,4BAApBH,uBAAwC;IAC9D,OAAOC,cAAcG,UAAU,CAAC,aAC5BH,cAAcI,KAAK,CAAC,UAAUC,MAAM,IACpCC;AACN;AAEA,OAAO,eAAeC,WACpBR,OAAgB,EAChBS,QAAgB;QAqCJC;IAnCZ,MAAMC,UAAUZ,YAAYC;IAC5B,IAAI,CAACW,SAAS;QACZ,OAAOC,SAASC,IAAI,CAAC;YAAEC,OAAO;QAAkB,GAAG;YAAEC,QAAQ;QAAI;IACnE;IACA,MAAMC,MAAMvB,cAAcuB,GAAG;IAC7B,IAAIC;IACJ,IAAI;QACFA,UAAU,MAAMD,IAAIE,IAAI,GAAGC,aAAa,CAACR;IAC3C,EAAE,OAAOG,OAAO;QACd,sEAAsE;QACtE,qEAAqE;QACrE,kEAAkE;QAClE,MAAMM,kBAAkBtB,uBAAuBgB;QAC/C,IAAIM,iBAAiB,OAAOA;QAC5B,MAAMN;IACR;IACA,IAAI,CAACG,QAAQI,cAAc,IAAI,CAAC3B,uBAAuBuB,UAAU;QAC/D,OAAOzB;IACT;IACA,MAAM8B,QAAQL,OAAO,CAAC,QAAQ,KAAK;IAEnC,MAAMM,QAAQd,SAASe,IAAI;IAC3B,IAAI,CAACD,OAAO;QACV,OAAOX,SAASC,IAAI,CAClB;YAAEC,OAAO;QAA6C,GACtD;YAAEC,QAAQ;QAAI;IAElB;IACA,MAAML,WAAW,MAAMd,cAAcqB,QAAQQ,GAAG,EAAEF;IAClD,IAAI,CAACb,YAAYA,SAASa,KAAK,KAAKA,OAAO;QACzC,OAAOX,SAASC,IAAI,CAClB;YAAEC,OAAO;QAA4C,GACrD;YAAEC,QAAQ;QAAI;IAElB;IACA,MAAMW,OAAMhB,gBAAAA,SAASgB,GAAG,YAAZhB,gBAAgB,CAAC;IAE7B,IAAI,CAACY,SAAS,CAAE,MAAMzB,4BAA4B,yBAAyB0B,QAAS;QAClF,OAAOX,SAASC,IAAI,CAAC;YAAEC,OAAO;QAAY,GAAG;YAAEC,QAAQ;QAAI;IAC7D;IACA,IAAI,CAACxB,iBAAiBmC,KAAK,iBAAiB;QAC1C,OAAOd,SAASC,IAAI,CAClB;YACEC,OAAO;YACPa,QAAQ;QACV,GACA;YAAEZ,QAAQ;QAAI;IAElB;IACA,MAAMa,SAAS,MAAMjC,gBAAgB;QACnCK;QACAsB;QACAG,KAAKR,QAAQQ,GAAG;QAChBC,KAAKA;IACP;IACA,IAAIE,QAAQ,OAAOA;IAEnB,OAAO;QACLH,KAAKR,QAAQQ,GAAG;QAChBR;QACAK;QACAC;QACAG;QACAG,WAAWb,IAAIa,SAAS;IAC1B;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-jobs-gate.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { DecodedIdToken } from 'firebase-admin/auth'\nimport { checkEntitlement } from '@aglyn/aglyn/server'\nimport type { AglynOrganization, AglynOrgMember } from '@aglyn/aglyn/foundation/definitions/organization.types'\n// By their own entry points rather than the barrel, for the reason the chat\n// route gives for the runtime: the route specs replace the barrel with a\n// closed-world factory, and a leaf that is mocked at its own seam is one\n// the spec can drive rung by rung.\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { lockdownRefusal } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { getOrgForUser } from '@aglyn/tenant-data-admin/server/organizations'\nimport { isServerReleaseFlagOnForOrg } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\n\n/**\n * The gate the READ and CANCEL doors of AI jobs climb (AGL-2904).\n *\n * `aiGateLadder` is the ladder a door that SPENDS climbs: it is POST-only\n * and ends by taking a reservation, because its whole purpose is to bound\n * provider spend. Listing a workspace's jobs, watching one, or canceling\n * one spends nothing and answers a GET, so those doors climb the same\n * rungs up to the lockdown verdict and stop there — no rate window and no\n * reservation. The rungs are in the ladder's order, for the ladder's\n * reason: each one refused discloses nothing a lower rung would.\n *\n * 401 no bearer token, or a token the verifier refused\n * 403 email unverified (an impersonation session is exempt)\n * 400 the request did not NAME the org (AGL-1934)\n * 403 the caller is not a member of that org\n * 404 the release flag is off — a released-off feature does not exist;\n * a verified staff claim previews through it\n * 403 the org's plan lacks `aiGenerative`\n * 423 lockdown: the scope verdict (platform/org/user)\n */\nexport interface AiJobsGateContext {\n uid: string\n decoded: DecodedIdToken\n staff: boolean\n orgId: string\n org: Partial<AglynOrganization>\n /** The caller's membership; `null` only for staff let in by `staffWithoutMembership`. */\n member: AglynOrgMember | null\n firestore: FirebaseFirestore.Firestore\n}\n\nexport interface AiJobsGateOptions {\n /**\n * A verified staff claim passes the membership rung for an org it is not a\n * member of (AGL-3616): staff stop a customer's job from the job's own\n * page. Every rung after it is climbed as for anyone else.\n */\n staffWithoutMembership?: boolean\n}\n\nfunction bearerToken(request: Request): string | undefined {\n const authorization = request.headers.get('authorization') ?? ''\n return authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n}\n\nexport async function aiJobsGate(\n request: Request,\n rawOrgId: string,\n options: AiJobsGateOptions = {},\n): Promise<Response | AiJobsGateContext> {\n const idToken = bearerToken(request)\n if (!idToken) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n const app = firebaseAdmin.app()\n let decoded: DecodedIdToken\n try {\n decoded = await app.auth().verifyIdToken(idToken)\n } catch (error) {\n // A refused credential is the caller's fault and a 401; anything else\n // is ours and propagates so the door answers 500 rather than telling\n // every user their credential is bad during an outage (AGL-1993).\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n throw error\n }\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n const staff = decoded['staff'] === true\n\n const orgId = rawOrgId.trim()\n if (!orgId) {\n return Response.json(\n { error: 'Open a workspace before using this feature' },\n { status: 400 },\n )\n }\n const resolved = await getOrgForUser(decoded.uid, orgId)\n let org: Partial<AglynOrganization>\n let member: AglynOrgMember | null\n if (resolved && resolved.orgId === orgId) {\n org = resolved.org ?? {}\n member = resolved.member ?? null\n } else {\n const staffOrg =\n staff && options.staffWithoutMembership\n ? await app.firestore().collection('orgs').doc(orgId).get()\n : null\n if (!staffOrg?.exists) {\n return Response.json(\n { error: 'You are not a member of that organization' },\n { status: 403 },\n )\n }\n org = (staffOrg.data() ?? {}) as Partial<AglynOrganization>\n member = null\n }\n\n if (!staff && !(await isServerReleaseFlagOnForOrg('release_ai_generative', orgId))) {\n return Response.json({ error: 'Not found' }, { status: 404 })\n }\n if (!checkEntitlement(org, 'aiGenerative')) {\n return Response.json(\n {\n error: \"This workspace's plan does not include that feature\",\n reason: 'entitlement',\n },\n { status: 403 },\n )\n }\n const locked = await lockdownRefusal({\n request,\n staff,\n uid: decoded.uid,\n org: org as Record<string, unknown>,\n })\n if (locked) return locked\n\n return {\n uid: decoded.uid,\n decoded,\n staff,\n orgId,\n org,\n member,\n firestore: app.firestore(),\n }\n}\n"],"names":["checkEntitlement","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","lockdownRefusal","getOrgForUser","isServerReleaseFlagOnForOrg","invalidIdTokenResponse","bearerToken","request","authorization","headers","get","startsWith","slice","length","undefined","aiJobsGate","rawOrgId","options","idToken","Response","json","error","status","app","decoded","auth","verifyIdToken","unauthenticated","email_verified","staff","orgId","trim","resolved","uid","org","member","staffOrg","staffWithoutMembership","firestore","collection","doc","exists","data","reason","locked"],"mappings":"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"}
@@ -25,7 +25,8 @@ import { type AiUsageMeterWire } from './ai-usage-wire';
25
25
  * Every figure is credits. The pool comes through `publicAssistQuota`, the
26
26
  * one projection allowed to turn our provider bill into a customer figure;
27
27
  * the caller's month comes off the allotment gate, which the meter wrote in
28
- * credits in the first place.
28
+ * credits in the first place — or, on the Free plan, off the person's Free
29
+ * allowance the reservation read, net of give-backs (AGL-3722).
29
30
  *
30
31
  * `last` is added to both months, because the reservation measured them
31
32
  * BEFORE this exchange and the strip is read after it.
@@ -16,6 +16,8 @@
16
16
  */ import { aiCatalogEntry } from "../providers/catalog.js";
17
17
  import { publicAssistQuota } from "./assist-usage.js";
18
18
  import { aiUsageMeterState } from "./ai-usage-wire.js";
19
+ import { assistCreditsFromUsd } from "./assist-credits.js";
20
+ import { FREE_AI_TASTE_CREDITS_PER_MONTH } from "../plan-entitlements.js";
19
21
  /**
20
22
  * The usage strip's envelope from what a door already holds (AGL-2942): the
21
23
  * reservation it took and, once the provider answered, what the exchange
@@ -25,7 +27,8 @@ import { aiUsageMeterState } from "./ai-usage-wire.js";
25
27
  * Every figure is credits. The pool comes through `publicAssistQuota`, the
26
28
  * one projection allowed to turn our provider bill into a customer figure;
27
29
  * the caller's month comes off the allotment gate, which the meter wrote in
28
- * credits in the first place.
30
+ * credits in the first place — or, on the Free plan, off the person's Free
31
+ * allowance the reservation read, net of give-backs (AGL-3722).
29
32
  *
30
33
  * `last` is added to both months, because the reservation measured them
31
34
  * BEFORE this exchange and the strip is read after it.
@@ -50,11 +53,24 @@ import { aiUsageMeterState } from "./ai-usage-wire.js";
50
53
  }
51
54
  const gate = (_reservation_allotment = reservation.allotment) != null ? _reservation_allotment : null;
52
55
  const binding = (_ref = gate == null ? void 0 : gate.binding) != null ? _ref : null;
56
+ // On the Free plan the person's own line is their Free allowance (AGL-3722):
57
+ // 300 credits a month across every Free workspace they hold, net of
58
+ // give-backs — the wall the reservation refuses at. The workspace's
59
+ // per-member row it used to read is gross (a refund or a staff reset never
60
+ // reaches it) and this workspace's alone, so it said "You: 401 credits this
61
+ // month" to a person with 48 of their 300 left (2026-10-10).
62
+ const freeAccountUsd = reservation.free ? reservation.freeAccountCostUsd : null;
53
63
  const mine = binding ? {
54
64
  used: binding.used + spent,
55
65
  limit: binding.credits,
56
66
  mode: binding.mode,
57
67
  scope: binding.scope
68
+ } : typeof freeAccountUsd === 'number' && Number.isFinite(freeAccountUsd) ? {
69
+ used: assistCreditsFromUsd(freeAccountUsd) + spent,
70
+ limit: FREE_AI_TASTE_CREDITS_PER_MONTH,
71
+ mode: 'hard',
72
+ scope: null,
73
+ free: true
58
74
  } : {
59
75
  used: ((_ref1 = gate == null ? void 0 : gate.personalCredits) != null ? _ref1 : 0) + spent,
60
76
  limit: null,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/ai-usage-meter.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 { aiCatalogEntry } from '../providers/catalog'\nimport { publicAssistQuota, type AssistReservation } from './assist-usage'\nimport { aiUsageMeterState, type AiUsageMeterWire } from './ai-usage-wire'\n\n/**\n * The usage strip's envelope from what a door already holds (AGL-2942): the\n * reservation it took and, once the provider answered, what the exchange\n * cost. No read — the reservation measured the pool and the caller's month\n * inside the transaction it already runs.\n *\n * Every figure is credits. The pool comes through `publicAssistQuota`, the\n * one projection allowed to turn our provider bill into a customer figure;\n * the caller's month comes off the allotment gate, which the meter wrote in\n * credits in the first place.\n *\n * `last` is added to both months, because the reservation measured them\n * BEFORE this exchange and the strip is read after it.\n */\nexport function aiUsageMeter(\n reservation: Pick<AssistReservation, 'monthKey' | 'allowed'> &\n Partial<AssistReservation>,\n extra: {\n lastCredits?: number | null\n model?: { id: string; auto: boolean } | null\n } = {},\n): AiUsageMeterWire {\n const last =\n typeof extra.lastCredits === 'number' && Number.isFinite(extra.lastCredits)\n ? Math.max(0, Math.ceil(extra.lastCredits))\n : null\n const spent = last ?? 0\n let pool: AiUsageMeterWire['pool'] = { used: spent, limit: null }\n try {\n const credits = publicAssistQuota(reservation as AssistReservation)?.credits\n if (credits) pool = { used: credits.used + spent, limit: credits.limit }\n } catch {\n // A reservation without its dollar figures (a double, a refusal taken\n // before the month was read) reports the exchange alone.\n }\n const gate = reservation.allotment ?? null\n const binding = gate?.binding ?? null\n const mine: AiUsageMeterWire['mine'] = binding\n ? {\n used: binding.used + spent,\n limit: binding.credits,\n mode: binding.mode,\n scope: binding.scope,\n }\n : {\n used: (gate?.personalCredits ?? 0) + spent,\n limit: null,\n mode: null,\n scope: null,\n }\n const refused = !reservation.allowed\n const entry = extra.model ? aiCatalogEntry(extra.model.id) : undefined\n return {\n month: reservation.monthKey,\n pool,\n mine,\n last,\n refused,\n state: aiUsageMeterState({ pool, mine, refused }),\n model:\n extra.model && entry\n ? { id: entry.id, label: entry.label, auto: extra.model.auto }\n : null,\n }\n}\n"],"names":["aiCatalogEntry","publicAssistQuota","aiUsageMeterState","aiUsageMeter","reservation","extra","last","lastCredits","Number","isFinite","Math","max","ceil","spent","pool","used","limit","credits","gate","allotment","binding","mine","mode","scope","personalCredits","refused","allowed","entry","model","id","undefined","month","monthKey","state","label","auto"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,cAAc,QAAQ,0BAAsB;AACrD,SAASC,iBAAiB,QAAgC,oBAAgB;AAC1E,SAASC,iBAAiB,QAA+B,qBAAiB;AAE1E;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASC,aACdC,WAC4B,EAC5BC,QAGI,CAAC,CAAC;QAeOD;IAbb,MAAME,OACJ,OAAOD,MAAME,WAAW,KAAK,YAAYC,OAAOC,QAAQ,CAACJ,MAAME,WAAW,IACtEG,KAAKC,GAAG,CAAC,GAAGD,KAAKE,IAAI,CAACP,MAAME,WAAW,KACvC;IACN,MAAMM,QAAQP,eAAAA,OAAQ;IACtB,IAAIQ,OAAiC;QAAEC,MAAMF;QAAOG,OAAO;IAAK;IAChE,IAAI;YACcf;QAAhB,MAAMgB,WAAUhB,qBAAAA,kBAAkBG,iCAAlBH,mBAAqDgB,OAAO;QAC5E,IAAIA,SAASH,OAAO;YAAEC,MAAME,QAAQF,IAAI,GAAGF;YAAOG,OAAOC,QAAQD,KAAK;QAAC;IACzE,EAAE,eAAM;IACN,sEAAsE;IACtE,yDAAyD;IAC3D;IACA,MAAME,QAAOd,yBAAAA,YAAYe,SAAS,YAArBf,yBAAyB;IACtC,MAAMgB,kBAAUF,wBAAAA,KAAME,OAAO,mBAAI;IACjC,MAAMC,OAAiCD,UACnC;QACEL,MAAMK,QAAQL,IAAI,GAAGF;QACrBG,OAAOI,QAAQH,OAAO;QACtBK,MAAMF,QAAQE,IAAI;QAClBC,OAAOH,QAAQG,KAAK;IACtB,IACA;QACER,MAAM,UAACG,wBAAAA,KAAMM,eAAe,oBAAI,KAAKX;QACrCG,OAAO;QACPM,MAAM;QACNC,OAAO;IACT;IACJ,MAAME,UAAU,CAACrB,YAAYsB,OAAO;IACpC,MAAMC,QAAQtB,MAAMuB,KAAK,GAAG5B,eAAeK,MAAMuB,KAAK,CAACC,EAAE,IAAIC;IAC7D,OAAO;QACLC,OAAO3B,YAAY4B,QAAQ;QAC3BlB;QACAO;QACAf;QACAmB;QACAQ,OAAO/B,kBAAkB;YAAEY;YAAMO;YAAMI;QAAQ;QAC/CG,OACEvB,MAAMuB,KAAK,IAAID,QACX;YAAEE,IAAIF,MAAME,EAAE;YAAEK,OAAOP,MAAMO,KAAK;YAAEC,MAAM9B,MAAMuB,KAAK,CAACO,IAAI;QAAC,IAC3D;IACR;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/ai-usage-meter.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 { aiCatalogEntry } from '../providers/catalog'\nimport { publicAssistQuota, type AssistReservation } from './assist-usage'\nimport { aiUsageMeterState, type AiUsageMeterWire } from './ai-usage-wire'\nimport { assistCreditsFromUsd } from './assist-credits'\nimport { FREE_AI_TASTE_CREDITS_PER_MONTH } from '../plan-entitlements'\n\n/**\n * The usage strip's envelope from what a door already holds (AGL-2942): the\n * reservation it took and, once the provider answered, what the exchange\n * cost. No read — the reservation measured the pool and the caller's month\n * inside the transaction it already runs.\n *\n * Every figure is credits. The pool comes through `publicAssistQuota`, the\n * one projection allowed to turn our provider bill into a customer figure;\n * the caller's month comes off the allotment gate, which the meter wrote in\n * credits in the first place — or, on the Free plan, off the person's Free\n * allowance the reservation read, net of give-backs (AGL-3722).\n *\n * `last` is added to both months, because the reservation measured them\n * BEFORE this exchange and the strip is read after it.\n */\nexport function aiUsageMeter(\n reservation: Pick<AssistReservation, 'monthKey' | 'allowed'> &\n Partial<AssistReservation>,\n extra: {\n lastCredits?: number | null\n model?: { id: string; auto: boolean } | null\n } = {},\n): AiUsageMeterWire {\n const last =\n typeof extra.lastCredits === 'number' && Number.isFinite(extra.lastCredits)\n ? Math.max(0, Math.ceil(extra.lastCredits))\n : null\n const spent = last ?? 0\n let pool: AiUsageMeterWire['pool'] = { used: spent, limit: null }\n try {\n const credits = publicAssistQuota(reservation as AssistReservation)?.credits\n if (credits) pool = { used: credits.used + spent, limit: credits.limit }\n } catch {\n // A reservation without its dollar figures (a double, a refusal taken\n // before the month was read) reports the exchange alone.\n }\n const gate = reservation.allotment ?? null\n const binding = gate?.binding ?? null\n // On the Free plan the person's own line is their Free allowance (AGL-3722):\n // 300 credits a month across every Free workspace they hold, net of\n // give-backs — the wall the reservation refuses at. The workspace's\n // per-member row it used to read is gross (a refund or a staff reset never\n // reaches it) and this workspace's alone, so it said \"You: 401 credits this\n // month\" to a person with 48 of their 300 left (2026-10-10).\n const freeAccountUsd = reservation.free ? reservation.freeAccountCostUsd : null\n const mine: AiUsageMeterWire['mine'] = binding\n ? {\n used: binding.used + spent,\n limit: binding.credits,\n mode: binding.mode,\n scope: binding.scope,\n }\n : typeof freeAccountUsd === 'number' && Number.isFinite(freeAccountUsd)\n ? {\n used: assistCreditsFromUsd(freeAccountUsd) + spent,\n limit: FREE_AI_TASTE_CREDITS_PER_MONTH,\n mode: 'hard',\n scope: null,\n free: true,\n }\n : {\n used: (gate?.personalCredits ?? 0) + spent,\n limit: null,\n mode: null,\n scope: null,\n }\n const refused = !reservation.allowed\n const entry = extra.model ? aiCatalogEntry(extra.model.id) : undefined\n return {\n month: reservation.monthKey,\n pool,\n mine,\n last,\n refused,\n state: aiUsageMeterState({ pool, mine, refused }),\n model:\n extra.model && entry\n ? { id: entry.id, label: entry.label, auto: extra.model.auto }\n : null,\n }\n}\n"],"names":["aiCatalogEntry","publicAssistQuota","aiUsageMeterState","assistCreditsFromUsd","FREE_AI_TASTE_CREDITS_PER_MONTH","aiUsageMeter","reservation","extra","last","lastCredits","Number","isFinite","Math","max","ceil","spent","pool","used","limit","credits","gate","allotment","binding","freeAccountUsd","free","freeAccountCostUsd","mine","mode","scope","personalCredits","refused","allowed","entry","model","id","undefined","month","monthKey","state","label","auto"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,cAAc,QAAQ,0BAAsB;AACrD,SAASC,iBAAiB,QAAgC,oBAAgB;AAC1E,SAASC,iBAAiB,QAA+B,qBAAiB;AAC1E,SAASC,oBAAoB,QAAQ,sBAAkB;AACvD,SAASC,+BAA+B,QAAQ,0BAAsB;AAEtE;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,aACdC,WAC4B,EAC5BC,QAGI,CAAC,CAAC;QAeOD;IAbb,MAAME,OACJ,OAAOD,MAAME,WAAW,KAAK,YAAYC,OAAOC,QAAQ,CAACJ,MAAME,WAAW,IACtEG,KAAKC,GAAG,CAAC,GAAGD,KAAKE,IAAI,CAACP,MAAME,WAAW,KACvC;IACN,MAAMM,QAAQP,eAAAA,OAAQ;IACtB,IAAIQ,OAAiC;QAAEC,MAAMF;QAAOG,OAAO;IAAK;IAChE,IAAI;YACcjB;QAAhB,MAAMkB,WAAUlB,qBAAAA,kBAAkBK,iCAAlBL,mBAAqDkB,OAAO;QAC5E,IAAIA,SAASH,OAAO;YAAEC,MAAME,QAAQF,IAAI,GAAGF;YAAOG,OAAOC,QAAQD,KAAK;QAAC;IACzE,EAAE,eAAM;IACN,sEAAsE;IACtE,yDAAyD;IAC3D;IACA,MAAME,QAAOd,yBAAAA,YAAYe,SAAS,YAArBf,yBAAyB;IACtC,MAAMgB,kBAAUF,wBAAAA,KAAME,OAAO,mBAAI;IACjC,6EAA6E;IAC7E,oEAAoE;IACpE,oEAAoE;IACpE,2EAA2E;IAC3E,4EAA4E;IAC5E,6DAA6D;IAC7D,MAAMC,iBAAiBjB,YAAYkB,IAAI,GAAGlB,YAAYmB,kBAAkB,GAAG;IAC3E,MAAMC,OAAiCJ,UACnC;QACEL,MAAMK,QAAQL,IAAI,GAAGF;QACrBG,OAAOI,QAAQH,OAAO;QACtBQ,MAAML,QAAQK,IAAI;QAClBC,OAAON,QAAQM,KAAK;IACtB,IACA,OAAOL,mBAAmB,YAAYb,OAAOC,QAAQ,CAACY,kBACpD;QACEN,MAAMd,qBAAqBoB,kBAAkBR;QAC7CG,OAAOd;QACPuB,MAAM;QACNC,OAAO;QACPJ,MAAM;IACR,IACA;QACEP,MAAM,UAACG,wBAAAA,KAAMS,eAAe,oBAAI,KAAKd;QACrCG,OAAO;QACPS,MAAM;QACNC,OAAO;IACT;IACN,MAAME,UAAU,CAACxB,YAAYyB,OAAO;IACpC,MAAMC,QAAQzB,MAAM0B,KAAK,GAAGjC,eAAeO,MAAM0B,KAAK,CAACC,EAAE,IAAIC;IAC7D,OAAO;QACLC,OAAO9B,YAAY+B,QAAQ;QAC3BrB;QACAU;QACAlB;QACAsB;QACAQ,OAAOpC,kBAAkB;YAAEc;YAAMU;YAAMI;QAAQ;QAC/CG,OACE1B,MAAM0B,KAAK,IAAID,QACX;YAAEE,IAAIF,MAAME,EAAE;YAAEK,OAAOP,MAAMO,KAAK;YAAEC,MAAMjC,MAAM0B,KAAK,CAACO,IAAI;QAAC,IAC3D;IACR;AACF"}
@@ -100,6 +100,12 @@ export interface AiUsageMeterWire {
100
100
  limit: number | null;
101
101
  mode: AiAllotmentMode | null;
102
102
  scope: AiCreditAllotmentScope | null;
103
+ /**
104
+ * `mine` is the person's Free allowance (AGL-3722): `used` of the 300
105
+ * free credits a month every Free workspace they hold draws on, net of
106
+ * give-backs. Absent on a paid workspace.
107
+ */
108
+ free?: boolean;
103
109
  };
104
110
  /** Credits the exchange this envelope answers cost; `null` when no model ran. */
105
111
  last: number | null;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/ai-usage-wire.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// By their own entry points rather than the barrel: the route that serves\n// this shape imports it on the server, where the whole library is dead weight.\nimport {\n topAiUsageKinds,\n type AiUsageByUserMonth,\n} from '../model/ai-usage-by-user'\nimport {\n AI_ALLOTMENT_WARN_SHARE,\n type AiAllotmentMode,\n type AiCreditAllotmentScope,\n} from '../model/ai-allotments'\nimport { csvCell } from '@aglyn/aglyn/app-utils/csv-import'\n\n/**\n * What `/api/ai/usage` answers (AGL-2928), shared by the route and the\n * console surfaces that read it — the Usage table, the roster column, the\n * site collaborators card and the member card. Dependency-free on purpose:\n * the route imports the Admin SDK and a client component may not.\n */\n\n/** Header naming the row count the export undertook to send. */\nexport const AI_USAGE_EXPORT_ROWS_HEADER = 'X-Aglyn-Export-Rows'\n\n/** A row as a customer sees it — credits and a share, never `estCostUsd`. */\nexport interface OrgAiUsageRowWire {\n uid: string\n /** The roster's name — display name, else email, else the uid. */\n name: string\n email: string | null\n role: string | null\n credits: number\n /** Of the workspace's measured spend for the month, in `[0, 1]`. */\n share: number\n requests: number\n refusals: number\n byKind: Record<string, number>\n byHost: Record<string, number>\n /** Credits on the site the request named, when it named one. */\n hostCredits?: number\n}\n\nexport interface OrgAiUsageTableWire {\n month: string\n /** The months a reader may ask for, newest first. */\n months: string[]\n /** The workspace's own credits for the month — the pool the shares are of. */\n orgCredits: number\n rows: OrgAiUsageRowWire[]\n}\n\n/** One person's month, as the member card reads it. */\nexport interface UserAiUsageMonthWire {\n month: string\n credits: number\n share: number\n requests: number\n refusals: number\n byKind: Record<string, number>\n byHost: Record<string, number>\n}\n\nexport interface UserAiUsageWire {\n uid: string\n /** Newest first. */\n months: UserAiUsageMonthWire[]\n}\n\n/** The roster's month, joined and shared, projected onto the wire. */\nexport function toAiUsageWireRow(\n row: AiUsageByUserMonth & {\n name: string\n email: string | null\n role: string | null\n share: number\n },\n hostId: string,\n): OrgAiUsageRowWire {\n const wire: OrgAiUsageRowWire = {\n uid: row.uid,\n name: row.name,\n email: row.email,\n role: row.role,\n credits: row.credits,\n share: row.share,\n requests: row.requests,\n refusals: row.refusals,\n byKind: row.byKind,\n byHost: row.byHost,\n }\n if (hostId) wire.hostCredits = row.byHost[hostId] ?? 0\n return wire\n}\n\n/** The CSV's columns, in order. */\nexport const AI_USAGE_CSV_HEADER = [\n 'Member',\n 'Email',\n 'Role',\n 'Credits',\n 'Share',\n 'Requests',\n 'Refusals',\n 'Top kinds',\n 'Sites',\n] as const\n\n/** A share as the table and the file print it: one decimal of a percent. */\nexport function formatAiUsageShare(share: number): string {\n return `${Math.round(share * 1000) / 10}%`\n}\n\nexport function aiUsageCsvLine(row: OrgAiUsageRowWire): string {\n const kinds = topAiUsageKinds(row.byKind, 3)\n .map((entry) => `${entry.kind} ${entry.credits}`)\n .join('; ')\n const sites = Object.entries(row.byHost)\n .sort((a, b) => b[1] - a[1])\n .map(([hostId, credits]) => `${hostId} ${credits}`)\n .join('; ')\n return [\n row.name,\n row.email ?? '',\n row.role ?? '',\n row.credits,\n formatAiUsageShare(row.share),\n row.requests,\n row.refusals,\n kinds,\n sites,\n ]\n .map(csvCell)\n .join(',')\n}\n\n/**\n * THE USAGE STRIP'S ENVELOPE (AGL-2942): what every AI door adds to the\n * answer it already sends — the chat door's `done` event, the copy\n * assistant's JSON, a job's create response, and each door's quota refusal —\n * so the strip in the panel and the generation dialogs updates with no read\n * of its own.\n *\n * Credits only. `pool` is the workspace's month against its band; `mine` is\n * the caller's own month, measured against the allotment that binds them\n * when one does — for a site allotment, the site's month, because that is\n * the line the next request meets.\n */\nexport type AiUsageMeterState = 'ok' | 'warn' | 'capped'\n\nexport interface AiUsageMeterWire {\n month: string\n pool: { used: number; limit: number | null }\n mine: {\n used: number\n limit: number | null\n mode: AiAllotmentMode | null\n scope: AiCreditAllotmentScope | null\n }\n /** Credits the exchange this envelope answers cost; `null` when no model ran. */\n last: number | null\n /** True when the envelope rides a refusal. */\n refused: boolean\n state: AiUsageMeterState\n /** The model that answered, as the catalog names it. */\n model: { id: string; label: string; auto: boolean } | null\n}\n\n/**\n * `capped` when a hard allotment is spent or the request was refused, `warn`\n * from 80% of the allotment that binds or of the pool, `ok` otherwise.\n */\nexport function aiUsageMeterState(\n meter: Pick<AiUsageMeterWire, 'pool' | 'mine' | 'refused'>,\n): AiUsageMeterState {\n const { mine, pool } = meter\n if (meter.refused) return 'capped'\n if (mine.limit !== null && mine.mode === 'hard' && mine.used >= mine.limit) return 'capped'\n const past = (used: number, limit: number | null) =>\n limit !== null && limit > 0 && used >= limit * AI_ALLOTMENT_WARN_SHARE\n return past(mine.used, mine.limit) || past(pool.used, pool.limit) ? 'warn' : 'ok'\n}\n\n/**\n * What `/api/ai/allotments` answers (AGL-2942): the allotments a reader may\n * see, each with what its subject drew this month, and the roster, sites\n * and models the editors pick from. Credits only.\n */\nexport interface AiAllotmentRowWire {\n subject: string\n scope: 'member' | 'collab' | 'host' | 'org'\n uid: string | null\n hostId: string | null\n credits: number | null\n mode: AiAllotmentMode\n models: string[] | null\n /** Credits the subject drew this month — the figure the gate measures. */\n used: number\n}\n\nexport interface AiAllotmentsMemberWire {\n uid: string\n name: string\n email: string | null\n role: string | null\n /** False for a site collaborator, whose allotments are per site. */\n orgWide: boolean\n /** Credits this month, every site. */\n credits: number\n /** Credits this month by site. */\n byHost: Record<string, number>\n}\n\nexport interface AiAllotmentsHostWire {\n hostId: string\n name: string\n /** The site's credits this month, everyone on it. */\n credits: number\n}\n\nexport interface AiAllotmentsWire {\n /** The org the answer is about — resolved from the site when only one was named. */\n orgId: string\n month: string\n pool: { used: number; limit: number | null }\n allotments: AiAllotmentRowWire[]\n members: AiAllotmentsMemberWire[]\n hosts: AiAllotmentsHostWire[]\n /** The catalog models this deployment serves, for an allowlist. */\n models: Array<{ id: string; label: string; tier: string }>\n /** Whether the plan lets the workspace restrict models org-wide. */\n restrictionAvailable: boolean\n canEdit: {\n /** Members, sites and the restriction: `billing.manage`. */\n billing: boolean\n /** Collaborators on the site asked about: `billing.manage`, or its admin. */\n collaborators: boolean\n }\n /** Who is reading, so an editor can refuse to offer a self-raise. */\n callerUid: string\n}\n\n/** `2026-09` as a reader says it, on the UTC calendar the key is cut from. */\nexport function aiUsageMonthLabel(month: string): string {\n const [year, monthOfYear] = month.split('-').map(Number)\n if (!year || !monthOfYear) return month\n try {\n return new Date(Date.UTC(year, monthOfYear - 1, 1)).toLocaleDateString(undefined, {\n month: 'long',\n year: 'numeric',\n timeZone: 'UTC',\n })\n } catch {\n return month\n }\n}\n"],"names":["topAiUsageKinds","AI_ALLOTMENT_WARN_SHARE","csvCell","AI_USAGE_EXPORT_ROWS_HEADER","toAiUsageWireRow","row","hostId","wire","uid","name","email","role","credits","share","requests","refusals","byKind","byHost","hostCredits","AI_USAGE_CSV_HEADER","formatAiUsageShare","Math","round","aiUsageCsvLine","kinds","map","entry","kind","join","sites","Object","entries","sort","a","b","aiUsageMeterState","meter","mine","pool","refused","limit","mode","used","past","aiUsageMonthLabel","month","year","monthOfYear","split","Number","Date","UTC","toLocaleDateString","undefined","timeZone"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/ai-usage-wire.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// By their own entry points rather than the barrel: the route that serves\n// this shape imports it on the server, where the whole library is dead weight.\nimport {\n topAiUsageKinds,\n type AiUsageByUserMonth,\n} from '../model/ai-usage-by-user'\nimport {\n AI_ALLOTMENT_WARN_SHARE,\n type AiAllotmentMode,\n type AiCreditAllotmentScope,\n} from '../model/ai-allotments'\nimport { csvCell } from '@aglyn/aglyn/app-utils/csv-import'\n\n/**\n * What `/api/ai/usage` answers (AGL-2928), shared by the route and the\n * console surfaces that read it — the Usage table, the roster column, the\n * site collaborators card and the member card. Dependency-free on purpose:\n * the route imports the Admin SDK and a client component may not.\n */\n\n/** Header naming the row count the export undertook to send. */\nexport const AI_USAGE_EXPORT_ROWS_HEADER = 'X-Aglyn-Export-Rows'\n\n/** A row as a customer sees it — credits and a share, never `estCostUsd`. */\nexport interface OrgAiUsageRowWire {\n uid: string\n /** The roster's name — display name, else email, else the uid. */\n name: string\n email: string | null\n role: string | null\n credits: number\n /** Of the workspace's measured spend for the month, in `[0, 1]`. */\n share: number\n requests: number\n refusals: number\n byKind: Record<string, number>\n byHost: Record<string, number>\n /** Credits on the site the request named, when it named one. */\n hostCredits?: number\n}\n\nexport interface OrgAiUsageTableWire {\n month: string\n /** The months a reader may ask for, newest first. */\n months: string[]\n /** The workspace's own credits for the month — the pool the shares are of. */\n orgCredits: number\n rows: OrgAiUsageRowWire[]\n}\n\n/** One person's month, as the member card reads it. */\nexport interface UserAiUsageMonthWire {\n month: string\n credits: number\n share: number\n requests: number\n refusals: number\n byKind: Record<string, number>\n byHost: Record<string, number>\n}\n\nexport interface UserAiUsageWire {\n uid: string\n /** Newest first. */\n months: UserAiUsageMonthWire[]\n}\n\n/** The roster's month, joined and shared, projected onto the wire. */\nexport function toAiUsageWireRow(\n row: AiUsageByUserMonth & {\n name: string\n email: string | null\n role: string | null\n share: number\n },\n hostId: string,\n): OrgAiUsageRowWire {\n const wire: OrgAiUsageRowWire = {\n uid: row.uid,\n name: row.name,\n email: row.email,\n role: row.role,\n credits: row.credits,\n share: row.share,\n requests: row.requests,\n refusals: row.refusals,\n byKind: row.byKind,\n byHost: row.byHost,\n }\n if (hostId) wire.hostCredits = row.byHost[hostId] ?? 0\n return wire\n}\n\n/** The CSV's columns, in order. */\nexport const AI_USAGE_CSV_HEADER = [\n 'Member',\n 'Email',\n 'Role',\n 'Credits',\n 'Share',\n 'Requests',\n 'Refusals',\n 'Top kinds',\n 'Sites',\n] as const\n\n/** A share as the table and the file print it: one decimal of a percent. */\nexport function formatAiUsageShare(share: number): string {\n return `${Math.round(share * 1000) / 10}%`\n}\n\nexport function aiUsageCsvLine(row: OrgAiUsageRowWire): string {\n const kinds = topAiUsageKinds(row.byKind, 3)\n .map((entry) => `${entry.kind} ${entry.credits}`)\n .join('; ')\n const sites = Object.entries(row.byHost)\n .sort((a, b) => b[1] - a[1])\n .map(([hostId, credits]) => `${hostId} ${credits}`)\n .join('; ')\n return [\n row.name,\n row.email ?? '',\n row.role ?? '',\n row.credits,\n formatAiUsageShare(row.share),\n row.requests,\n row.refusals,\n kinds,\n sites,\n ]\n .map(csvCell)\n .join(',')\n}\n\n/**\n * THE USAGE STRIP'S ENVELOPE (AGL-2942): what every AI door adds to the\n * answer it already sends — the chat door's `done` event, the copy\n * assistant's JSON, a job's create response, and each door's quota refusal —\n * so the strip in the panel and the generation dialogs updates with no read\n * of its own.\n *\n * Credits only. `pool` is the workspace's month against its band; `mine` is\n * the caller's own month, measured against the allotment that binds them\n * when one does — for a site allotment, the site's month, because that is\n * the line the next request meets.\n */\nexport type AiUsageMeterState = 'ok' | 'warn' | 'capped'\n\nexport interface AiUsageMeterWire {\n month: string\n pool: { used: number; limit: number | null }\n mine: {\n used: number\n limit: number | null\n mode: AiAllotmentMode | null\n scope: AiCreditAllotmentScope | null\n /**\n * `mine` is the person's Free allowance (AGL-3722): `used` of the 300\n * free credits a month every Free workspace they hold draws on, net of\n * give-backs. Absent on a paid workspace.\n */\n free?: boolean\n }\n /** Credits the exchange this envelope answers cost; `null` when no model ran. */\n last: number | null\n /** True when the envelope rides a refusal. */\n refused: boolean\n state: AiUsageMeterState\n /** The model that answered, as the catalog names it. */\n model: { id: string; label: string; auto: boolean } | null\n}\n\n/**\n * `capped` when a hard allotment is spent or the request was refused, `warn`\n * from 80% of the allotment that binds or of the pool, `ok` otherwise.\n */\nexport function aiUsageMeterState(\n meter: Pick<AiUsageMeterWire, 'pool' | 'mine' | 'refused'>,\n): AiUsageMeterState {\n const { mine, pool } = meter\n if (meter.refused) return 'capped'\n if (mine.limit !== null && mine.mode === 'hard' && mine.used >= mine.limit) return 'capped'\n const past = (used: number, limit: number | null) =>\n limit !== null && limit > 0 && used >= limit * AI_ALLOTMENT_WARN_SHARE\n return past(mine.used, mine.limit) || past(pool.used, pool.limit) ? 'warn' : 'ok'\n}\n\n/**\n * What `/api/ai/allotments` answers (AGL-2942): the allotments a reader may\n * see, each with what its subject drew this month, and the roster, sites\n * and models the editors pick from. Credits only.\n */\nexport interface AiAllotmentRowWire {\n subject: string\n scope: 'member' | 'collab' | 'host' | 'org'\n uid: string | null\n hostId: string | null\n credits: number | null\n mode: AiAllotmentMode\n models: string[] | null\n /** Credits the subject drew this month — the figure the gate measures. */\n used: number\n}\n\nexport interface AiAllotmentsMemberWire {\n uid: string\n name: string\n email: string | null\n role: string | null\n /** False for a site collaborator, whose allotments are per site. */\n orgWide: boolean\n /** Credits this month, every site. */\n credits: number\n /** Credits this month by site. */\n byHost: Record<string, number>\n}\n\nexport interface AiAllotmentsHostWire {\n hostId: string\n name: string\n /** The site's credits this month, everyone on it. */\n credits: number\n}\n\nexport interface AiAllotmentsWire {\n /** The org the answer is about — resolved from the site when only one was named. */\n orgId: string\n month: string\n pool: { used: number; limit: number | null }\n allotments: AiAllotmentRowWire[]\n members: AiAllotmentsMemberWire[]\n hosts: AiAllotmentsHostWire[]\n /** The catalog models this deployment serves, for an allowlist. */\n models: Array<{ id: string; label: string; tier: string }>\n /** Whether the plan lets the workspace restrict models org-wide. */\n restrictionAvailable: boolean\n canEdit: {\n /** Members, sites and the restriction: `billing.manage`. */\n billing: boolean\n /** Collaborators on the site asked about: `billing.manage`, or its admin. */\n collaborators: boolean\n }\n /** Who is reading, so an editor can refuse to offer a self-raise. */\n callerUid: string\n}\n\n/** `2026-09` as a reader says it, on the UTC calendar the key is cut from. */\nexport function aiUsageMonthLabel(month: string): string {\n const [year, monthOfYear] = month.split('-').map(Number)\n if (!year || !monthOfYear) return month\n try {\n return new Date(Date.UTC(year, monthOfYear - 1, 1)).toLocaleDateString(undefined, {\n month: 'long',\n year: 'numeric',\n timeZone: 'UTC',\n })\n } catch {\n return month\n }\n}\n"],"names":["topAiUsageKinds","AI_ALLOTMENT_WARN_SHARE","csvCell","AI_USAGE_EXPORT_ROWS_HEADER","toAiUsageWireRow","row","hostId","wire","uid","name","email","role","credits","share","requests","refusals","byKind","byHost","hostCredits","AI_USAGE_CSV_HEADER","formatAiUsageShare","Math","round","aiUsageCsvLine","kinds","map","entry","kind","join","sites","Object","entries","sort","a","b","aiUsageMeterState","meter","mine","pool","refused","limit","mode","used","past","aiUsageMonthLabel","month","year","monthOfYear","split","Number","Date","UTC","toLocaleDateString","undefined","timeZone"],"mappings":"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"}
@@ -269,6 +269,13 @@ export interface AssistReservation extends AssistQuotaVerdict {
269
269
  * on, and the record must charge that account and no other.
270
270
  */
271
271
  free: FreeAssistAccount | null;
272
+ /**
273
+ * The Free account's month in billed USD, net of give-backs, as read inside
274
+ * the transaction (AGL-3722) — the allowance every Free workspace of the
275
+ * person draws on. `null` for a paid workspace and for a reservation that
276
+ * did not read it. Optional so a double without it reads as not read.
277
+ */
278
+ freeAccountCostUsd?: number | null;
272
279
  /**
273
280
  * Measured provider spend for `monthKey` in USD as read inside the
274
281
  * transaction, or `null` when the transaction did not consult it.
@@ -414,6 +414,9 @@ async function reserveInTransaction(firestore, orgId, entitled, now, org, subjec
414
414
  // Net of any credits given back this month (AGL-3595), so a give-back
415
415
  // re-opens the band it was meant to re-open.
416
416
  const costUsd = monthlySnapshot ? assistSpendAfterReturnsUsd(monthlySnapshot.get('estCostUsd'), monthlySnapshot.get(ASSIST_RETURNED_USD_FIELD)) : null;
417
+ // The person's Free allowance, net of give-backs (AGL-3722): what the
418
+ // usage strip says is theirs, never the workspace's gross per-member row.
419
+ const freeAccountCostUsd = accountSnapshot ? assistSpendAfterReturnsUsd(accountSnapshot.get('estCostUsd'), accountSnapshot.get(ASSIST_RETURNED_USD_FIELD)) : null;
417
420
  if (!(used < limit)) {
418
421
  recordAssistRefusal(firestore, orgId, month, 'messages');
419
422
  return {
@@ -428,7 +431,8 @@ async function reserveInTransaction(firestore, orgId, entitled, now, org, subjec
428
431
  costUsd,
429
432
  costLimitUsd,
430
433
  budgetUsd,
431
- free
434
+ free,
435
+ freeAccountCostUsd
432
436
  };
433
437
  }
434
438
  // The dollar ceiling, checked AFTER the message cap so the cheaper and
@@ -449,7 +453,8 @@ async function reserveInTransaction(firestore, orgId, entitled, now, org, subjec
449
453
  costUsd,
450
454
  costLimitUsd,
451
455
  budgetUsd,
452
- free
456
+ free,
457
+ freeAccountCostUsd
453
458
  };
454
459
  }
455
460
  // The org's ceiling on its OVERAGE (AGL-2898), checked after the band
@@ -478,7 +483,8 @@ async function reserveInTransaction(firestore, orgId, entitled, now, org, subjec
478
483
  costUsd,
479
484
  costLimitUsd,
480
485
  budgetUsd,
481
- free
486
+ free,
487
+ freeAccountCostUsd
482
488
  };
483
489
  }
484
490
  // AGLYN'S OWN OVERAGE GUARDS (AGL-3011), after the workspace's own
@@ -518,7 +524,8 @@ async function reserveInTransaction(firestore, orgId, entitled, now, org, subjec
518
524
  costUsd,
519
525
  costLimitUsd,
520
526
  budgetUsd,
521
- free
527
+ free,
528
+ freeAccountCostUsd
522
529
  };
523
530
  }
524
531
  // The allotments that apply to the person asking (AGL-2942): read only
@@ -541,6 +548,7 @@ async function reserveInTransaction(firestore, orgId, entitled, now, org, subjec
541
548
  costLimitUsd,
542
549
  budgetUsd,
543
550
  free,
551
+ freeAccountCostUsd,
544
552
  allotment
545
553
  };
546
554
  }
@@ -563,6 +571,7 @@ async function reserveInTransaction(firestore, orgId, entitled, now, org, subjec
563
571
  costLimitUsd,
564
572
  budgetUsd,
565
573
  free,
574
+ freeAccountCostUsd,
566
575
  allotment
567
576
  };
568
577
  }
@@ -602,6 +611,7 @@ async function reserveInTransaction(firestore, orgId, entitled, now, org, subjec
602
611
  costLimitUsd,
603
612
  budgetUsd,
604
613
  free,
614
+ freeAccountCostUsd,
605
615
  allotment
606
616
  };
607
617
  });