cartethyia 2.5.9 → 2.6.0

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 (386) hide show
  1. package/.env.example +15 -42
  2. package/README.md +11 -10
  3. package/dist/dashboard/assets/{BannedPage-Brvuj_Eb.js → BannedPage-l6XMnfr9.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-CqfXtI3y.js → CliToolDetailPage-DwOzAW45.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-GQeS91bg.js → CliToolsPage-BT3Up3QA.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-BDRsTXBB.js → CombosPage-6kYIUpzR.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-zP8AFHX0.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-BmfrNZ14.js → CustomizationPage-C7m-H1Rz.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-DoreIgm8.js → LoginPage-CbphLvjg.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-C7pDb41R.js → ModelPicker-DVny4S5_.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-BnoPgA7w.js → ProvidersPage-Dfb2896L.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
  27. package/dist/dashboard/assets/{ProxyPage-Bq_TGgdb.js → ProxyPage-BaCIdAUt.js} +1 -1
  28. package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-Day3mCOn.js → SetupPage-D4g6vOGc.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-DQxllnB5.js → SortableList-C93i14BD.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js → StudioPage-SgQpymjm.js} +1 -1
  38. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
  39. package/dist/dashboard/assets/{UsagePage-DXa1oR-4.js → UsagePage-BFME-eba.js} +2 -2
  40. package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
  41. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  42. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  43. package/dist/dashboard/assets/{app-BIt446yE.js → app-2KhtHCv9.js} +1 -1
  44. package/dist/dashboard/assets/{app-BIt446yE.js.gz → app-2KhtHCv9.js.gz} +0 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  46. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  47. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  48. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-CR72dasT.js → badge-CKsF123d.js} +1 -1
  50. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  51. package/dist/dashboard/assets/{banner-CI9_eSfO.js → banner-8j7uChA9.js} +1 -1
  52. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  53. package/dist/dashboard/assets/{card-DS7CQEsO.js → card-CdsSlWA3.js} +1 -1
  54. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  55. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  56. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  57. package/dist/dashboard/assets/{clipboard-button-611pSsaX.js → clipboard-button-uL5aYLfW.js} +1 -1
  58. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  59. package/dist/dashboard/assets/{dialog-k37exjNt.js → dialog-C07hr5_G.js} +1 -1
  60. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  61. package/dist/dashboard/assets/{drawer-CX7N8ucR.js → drawer-BPO1TcKH.js} +1 -1
  62. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CTP8AyiS.js → entry-Bz_CtDfC.js} +1 -1
  64. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
  66. package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  68. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  69. package/dist/dashboard/assets/{github-badge-sltk1qUi.js → github-badge-DmQreSDd.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-z3zDOSMM.js → index-Bblu6erR.js} +2 -2
  72. package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-BhY2sSnO.js → inline-C9dCYaBZ.js} +1 -1
  74. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-V1umFiBk.js → input-CjCnXweJ.js} +1 -1
  76. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  77. package/dist/dashboard/assets/{layout-BBOYycyS.js → layout-V6y8DAkl.js} +1 -1
  78. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-1zzZYM0B.js → list-row-CM1AaG_y.js} +1 -1
  80. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  82. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  86. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  87. package/dist/dashboard/assets/{numbers-DkfMR5Qs.js → numbers-BOevJzdz.js} +1 -1
  88. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  92. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-C6MMiaio.js → select-C980c4cz.js} +1 -1
  94. package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  96. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-BHTfXdz_.js → stack-_gfTdLVB.js} +1 -1
  98. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-mPDRlQkl.js → state-2c5V4zo7.js} +1 -1
  100. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DOmJxg7H.js → switch-Dkke7Naf.js} +1 -1
  102. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  103. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  104. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  105. package/dist/dashboard/index.html +1 -1
  106. package/dist/dashboard/index.html.gz +0 -0
  107. package/dist/dashboard/precompressed-assets.json +51 -51
  108. package/migrations/README.md +52 -52
  109. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  110. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  111. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  112. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  113. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  114. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  115. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  116. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  117. package/package.json +96 -97
  118. package/scripts/cli/admin.ts +126 -125
  119. package/scripts/cli/autostart.ts +237 -237
  120. package/scripts/cli/background.ts +120 -110
  121. package/scripts/cli/bin/carte.cjs +8 -8
  122. package/scripts/cli/bin/cartethyia.cjs +167 -167
  123. package/scripts/cli/console-menu.ts +308 -275
  124. package/scripts/cli/controller.ts +145 -0
  125. package/scripts/cli/doctor.ts +1 -1
  126. package/scripts/cli/env.ts +99 -99
  127. package/scripts/cli/gateway.ts +264 -261
  128. package/scripts/{health.ts → cli/health.ts} +4 -4
  129. package/scripts/cli/instance.ts +234 -224
  130. package/scripts/cli/main.ts +188 -164
  131. package/scripts/cli/platform.ts +64 -64
  132. package/scripts/cli/prompt.ts +275 -107
  133. package/scripts/cli/services.ts +88 -88
  134. package/scripts/cli/setup.ts +445 -445
  135. package/scripts/cli/start.ts +85 -82
  136. package/scripts/cli/startup-signal.ts +23 -23
  137. package/scripts/cli/telemetry.ts +70 -0
  138. package/scripts/cli/tray/index.ts +263 -263
  139. package/scripts/cli/tray/windows.ps1 +107 -107
  140. package/scripts/cli/tray.ts +192 -208
  141. package/scripts/cli/update.ts +39 -39
  142. package/scripts/cli/version.ts +67 -67
  143. package/scripts/cli/wordmark.ts +58 -58
  144. package/src/boot/app.ts +16 -1
  145. package/src/boot/dependencies.ts +128 -25
  146. package/src/config/package-paths.ts +24 -0
  147. package/src/config/resources.ts +137 -0
  148. package/src/config/runtime.ts +133 -76
  149. package/src/config/spec.ts +30 -29
  150. package/src/config/telemetry.ts +0 -25
  151. package/src/console/api-keys/bucket-spend.ts +14 -17
  152. package/src/console/api-keys/contracts.ts +410 -410
  153. package/src/console/api-keys/routes.ts +12 -0
  154. package/src/console/audit/store.ts +2 -3
  155. package/src/console/auth/setup-service.ts +8 -1
  156. package/src/console/backup/routes.ts +7 -0
  157. package/src/console/backup/service.ts +463 -463
  158. package/src/console/backup/store.ts +16 -11
  159. package/src/console/backup/validate.ts +1 -4
  160. package/src/console/cli-tools/fs-ops.ts +18 -2
  161. package/src/console/data-management/contracts.ts +1 -8
  162. package/src/console/data-management/operations.ts +61 -48
  163. package/src/console/data-management/routes.ts +4 -2
  164. package/src/console/domain-registration.ts +25 -3
  165. package/src/console/inferhub/client.ts +139 -0
  166. package/src/console/inferhub/key-store.ts +129 -0
  167. package/src/console/inferhub/normalize.ts +347 -0
  168. package/src/console/inferhub/routes.ts +411 -0
  169. package/src/console/inferhub/store.ts +109 -0
  170. package/src/console/inferhub/types.ts +183 -0
  171. package/src/console/observability/contracts.ts +2 -41
  172. package/src/console/observability/store.ts +176 -371
  173. package/src/console/providers/catalog/public-model-store.ts +0 -0
  174. package/src/console/providers/catalog/store.ts +23 -36
  175. package/src/console/quota/account-quota-shared.ts +52 -52
  176. package/src/console/quota/account-quota-tenant.ts +16 -24
  177. package/src/console/quota/refresh.ts +441 -441
  178. package/src/console/remote-support/service.ts +270 -114
  179. package/src/console/routing/pools/import-parse.ts +206 -206
  180. package/src/console/routing/pools/operations.ts +543 -543
  181. package/src/console/routing/pools/probe-verdict.ts +132 -132
  182. package/src/console/routing/pools/routes.ts +181 -181
  183. package/src/console/routing/pools/store.ts +1 -1
  184. package/src/console/settings/contracts.ts +42 -40
  185. package/src/console/settings/store.ts +25 -25
  186. package/src/console/share/share-stats.ts +22 -4
  187. package/src/console/share/share-usage.ts +30 -4
  188. package/src/console/shared/cursor.ts +15 -0
  189. package/src/console/updates/repository-updates.ts +40 -39
  190. package/src/console/updates/routes.ts +12 -0
  191. package/src/main.ts +13 -5
  192. package/src/network/pool/resolver.ts +1104 -1104
  193. package/src/observability/client-name.ts +22 -0
  194. package/src/observability/event-log.ts +5 -29
  195. package/src/observability/event-query.ts +690 -0
  196. package/src/observability/log-ring.ts +26 -2
  197. package/src/observability/log-sanitize.ts +118 -0
  198. package/src/observability/logger.ts +17 -13
  199. package/src/observability/metrics.ts +35 -0
  200. package/src/observability/payload-capture.ts +114 -41
  201. package/src/observability/payload-store.ts +249 -24
  202. package/src/observability/raw-event-writer.ts +118 -0
  203. package/src/observability/runtime-metrics.ts +71 -1
  204. package/src/observability/telemetry-buffer.ts +100 -17
  205. package/src/observability/telemetry-reconciliation.ts +417 -0
  206. package/src/persistence/boot.ts +20 -9
  207. package/src/persistence/db-pg.ts +13 -0
  208. package/src/persistence/db-sqlite.ts +569 -497
  209. package/src/persistence/dialect.ts +3 -2
  210. package/src/persistence/migrate.ts +181 -181
  211. package/src/persistence/schema.ts +1496 -1406
  212. package/src/persistence/shared-cache.ts +5 -1
  213. package/src/persistence/stores/api-key-store.ts +581 -581
  214. package/src/persistence/stores/telemetry-store.ts +124 -63
  215. package/src/protocol/reasoning-text.ts +30 -0
  216. package/src/protocol/registry.ts +2 -0
  217. package/src/protocol/response/chat.ts +33 -7
  218. package/src/protocol/response/codex.ts +14 -2
  219. package/src/protocol/response/messages.ts +5 -2
  220. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  221. package/src/providers/bundled-discovery-providers.ts +122 -122
  222. package/src/providers/default-registry.ts +14 -0
  223. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  224. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  225. package/src/providers/discovery/probe-phases.ts +43 -7
  226. package/src/providers/discovery/probing-service.ts +70 -32
  227. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  228. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  231. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  232. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  233. package/src/providers/integrations/buddy/capabilities.ts +4 -0
  234. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  235. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  236. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  237. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  238. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  239. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  240. package/src/providers/integrations/claude/claude.ts +3 -4
  241. package/src/providers/integrations/cline/cline.ts +94 -8
  242. package/src/providers/integrations/codex/codex.ts +61 -37
  243. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  244. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  245. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  246. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  247. package/src/providers/integrations/cursor/cursor.ts +29 -17
  248. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  249. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  250. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  251. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  252. package/src/providers/integrations/github/github.ts +3 -1
  253. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  254. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  255. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  256. package/src/providers/integrations/hermes.ts +7 -1
  257. package/src/providers/integrations/inferhub.ts +201 -73
  258. package/src/providers/integrations/kilo/kilo.ts +152 -152
  259. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  260. package/src/providers/integrations/opencode/provider.ts +2 -0
  261. package/src/providers/integrations/openrouter/provider.ts +129 -129
  262. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  263. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  264. package/src/providers/integrations/trae/trae-headers.ts +17 -48
  265. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  266. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  267. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  268. package/src/providers/integrations/trae/trae.ts +22 -24
  269. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  270. package/src/providers/integrations/zcode/oauth.ts +1 -1
  271. package/src/providers/operations/account-health-service.ts +605 -279
  272. package/src/providers/provider-metadata.ts +12 -1
  273. package/src/providers/provider-model-definition.ts +373 -373
  274. package/src/providers/provider-probe.ts +3 -0
  275. package/src/providers/quota/quota-contracts.ts +3 -7
  276. package/src/providers/search/search-provider.ts +2 -6
  277. package/src/security/admission/contracts.ts +10 -0
  278. package/src/security/admission/in-memory-store.ts +89 -14
  279. package/src/security/admission/lua.ts +191 -182
  280. package/src/security/admission/redis-store.ts +30 -0
  281. package/src/security/admission/service.ts +18 -0
  282. package/src/security/api-key-auth-cache.ts +96 -0
  283. package/src/security/api-key-auth.ts +11 -100
  284. package/src/security/client-router-fingerprint.ts +3 -7
  285. package/src/security/ip-boundary.ts +7 -3
  286. package/src/shared/coerce.ts +67 -0
  287. package/src/shared/hash.ts +6 -0
  288. package/src/shared/time.ts +7 -0
  289. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  290. package/src/transport/dispatch/attempt-loop.ts +103 -81
  291. package/src/transport/dispatch/capture-policy.ts +78 -0
  292. package/src/transport/dispatch/proxy-request.ts +4 -2
  293. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  294. package/src/transport/dispatch/streaming/stream.ts +19 -2
  295. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  296. package/src/transport/failure-policy.ts +27 -0
  297. package/src/transport/guards/abuse-guard.ts +2 -2
  298. package/src/transport/guards/auth-guard.ts +17 -0
  299. package/src/transport/guards/body-policy.ts +3 -2
  300. package/src/transport/guards/concurrency-guard.ts +89 -13
  301. package/src/transport/guards/early-rejection-capture.ts +43 -3
  302. package/src/transport/guards/error-lifecycle.ts +10 -2
  303. package/src/transport/guards/guard-errors.ts +23 -0
  304. package/src/transport/guards/headroom-guard.ts +41 -7
  305. package/src/transport/guards/pipeline.ts +24 -0
  306. package/src/transport/guards/request-context.ts +2 -1
  307. package/src/transport/routing/admission.ts +51 -9
  308. package/src/transport/routing/bounded-lru.ts +49 -0
  309. package/src/transport/routing/candidate-index.ts +88 -0
  310. package/src/transport/routing/reservations.ts +121 -7
  311. package/src/transport/routing/round-robin.ts +10 -22
  312. package/src/transport/routing/route-catalog.ts +26 -13
  313. package/src/transport/routing/route-model.ts +22 -2
  314. package/src/transport/routing/router.ts +53 -31
  315. package/src/transport/surface/chat/encode.ts +2 -3
  316. package/src/transport/surface/chat/parse.ts +2 -1
  317. package/src/transport/surface/dialects.ts +11 -3
  318. package/src/transport/surface/messages/encode.ts +2 -1
  319. package/src/transport/surface/messages/parse.ts +3 -1
  320. package/src/transport/surface/messages/stream.ts +2 -6
  321. package/src/workers/maintenance-lease.ts +200 -0
  322. package/src/workers/scheduler.ts +101 -1
  323. package/dist/dashboard/assets/BannedPage-Brvuj_Eb.js.gz +0 -0
  324. package/dist/dashboard/assets/CliToolDetailPage-CqfXtI3y.js.gz +0 -0
  325. package/dist/dashboard/assets/CliToolsPage-GQeS91bg.js.gz +0 -0
  326. package/dist/dashboard/assets/CombosPage-BDRsTXBB.js.gz +0 -0
  327. package/dist/dashboard/assets/ConsoleLogPage-zP8AFHX0.js.gz +0 -0
  328. package/dist/dashboard/assets/CustomizationPage-BmfrNZ14.js.gz +0 -0
  329. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js +0 -1
  330. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js.gz +0 -0
  331. package/dist/dashboard/assets/LoginPage-DoreIgm8.js.gz +0 -0
  332. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  333. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js +0 -1
  334. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js.gz +0 -0
  335. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js +0 -3
  336. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js.gz +0 -0
  337. package/dist/dashboard/assets/ProvidersPage-BnoPgA7w.js.gz +0 -0
  338. package/dist/dashboard/assets/ProxyPage-Bq_TGgdb.js.gz +0 -0
  339. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js +0 -1
  340. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js.gz +0 -0
  341. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js +0 -1
  342. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js.gz +0 -0
  343. package/dist/dashboard/assets/SetupPage-Day3mCOn.js.gz +0 -0
  344. package/dist/dashboard/assets/SortableList-DQxllnB5.js.gz +0 -0
  345. package/dist/dashboard/assets/UsagePage-DXa1oR-4.js.gz +0 -0
  346. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  347. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  348. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  349. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  350. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  351. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  352. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  353. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  354. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  355. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  356. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  357. package/dist/dashboard/assets/entry-B9Xvtnfj.js +0 -1
  358. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-ByMGkR3C.js +0 -4
  360. package/dist/dashboard/assets/entry-ByMGkR3C.js.gz +0 -0
  361. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  363. package/dist/dashboard/assets/index-z3zDOSMM.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  365. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  367. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  368. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  369. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  370. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  371. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  372. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  373. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  374. package/dist/dashboard/assets/select-C6MMiaio.js.gz +0 -0
  375. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  376. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  377. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  378. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  379. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  380. package/scripts/build/aot.ts +0 -143
  381. package/scripts/build/build.ts +0 -24
  382. package/scripts/build/compile.ts +0 -88
  383. package/scripts/build/restart.ts +0 -120
  384. package/scripts/build/start.ts +0 -25
  385. package/src/observability/event-compaction.ts +0 -317
  386. package/src/observability/event-day-index.ts +0 -440
@@ -1,410 +1,410 @@
1
- // Console API-key domain contracts: request/response shapes and pure helpers.
2
- //
3
- // Single source for key creation validation and response sanitization;
4
- // routes consume it, they do not redeclare it.
5
-
6
- import type { AuditSink } from "../audit/contracts";
7
- import { randomBytes } from "node:crypto";
8
- import type { ApiKeyRecord, ApiKeyStore } from "../../persistence/stores/api-key-store";
9
- import { hashSecret } from "../../security/crypto";
10
- import { type ShareLinkStore, type ShareLinkSummary } from "../../persistence/stores/share-store";
11
- import { API_KEY_MODES, type ApiKeyMode, type ApiKeyModelAccessMode, type ShareLinkKind } from "../../persistence/schema";
12
- import { isValidTenantKeyScope, type AccessScope } from "../../security/access-control";
13
- import { normalizeClientRouterId } from "../../security/client-router-fingerprint";
14
- import type { ShareActivityPort } from "../share/share-usage";
15
- import { ConsoleDomainError } from "../shared/errors";
16
- import type { ConsoleAccessResolver } from "../auth/access";
17
- import type { ApiKeyAdmissionService } from "../../security/admission/service";
18
- import {
19
- SHARE_POPUP_IMAGE_MAX_BYTES,
20
- SHARE_POPUP_IMAGE_MIMES,
21
- sniffSharePopupImageMime,
22
- type SharePopupImageMime,
23
- } from "./share-popup-image";
24
-
25
- // Re-exported so consumers reach the image bounds from this contract module.
26
- // `share-popup-image.ts` is the pure authority; it stays import-free so the
27
- // dashboard can read the same bounds its upload control enforces.
28
- export {
29
- SHARE_POPUP_IMAGE_MAX_BYTES,
30
- SHARE_POPUP_IMAGE_MIMES,
31
- type SharePopupImageMime,
32
- } from "./share-popup-image";
33
- export type {
34
- SharedKeySummary,
35
- SharedKeyActivityDetail,
36
- } from "../share/share-usage";
37
- /** Input accepted when creating or editing a key. Null limits mean unlimited. */
38
- export interface CreateApiKeyRequest {
39
- enabled?: boolean;
40
- label?: string;
41
- keyMode?: ApiKeyMode;
42
- scopes?: readonly string[];
43
- /** Public leading fragment used for generated keys; defaults to the product prefix. */
44
- keyPrefix?: string;
45
- /** Owner-supplied raw key value; valid only for personal keys. */
46
- key?: string;
47
- requestsPerMinute?: number | null;
48
- dailyTokenLimit?: number | null;
49
- monthlyTokenLimit?: number | null;
50
- lifetimeTokenBudget?: number | null;
51
- maxConcurrentRequests?: number | null;
52
- modelPrefix?: string;
53
- modelAccessMode?: ApiKeyModelAccessMode;
54
- modelList?: readonly string[];
55
- /** Client-router ids this key refuses; see `client-router-fingerprint.ts`. */
56
- clientRouterDenylist?: readonly string[];
57
- notesTitle?: string;
58
- notesSubtitle?: string;
59
- notesBody?: string;
60
- sharePopupEnabled?: boolean | null;
61
- /**
62
- * Uploaded popup art as a `data:<mime>;base64,…` URL. `null` clears it and
63
- * omitting it leaves the stored image alone. There is no remote-URL form: a
64
- * hotlink leaked each visitor's IP to a third party and rotted on host moves.
65
- */
66
- sharePopupImage?: string | null;
67
- sharePopupTitle?: string;
68
- sharePopupBody?: string;
69
- }
70
-
71
- /** Public key representation; it never contains a secret, hash, or IP-key digest. */
72
- export interface ApiKeyResponse {
73
- readonly id: string;
74
- readonly label: string;
75
- readonly enabled: boolean;
76
- readonly keyMode: ApiKeyMode;
77
- readonly scopes: readonly AccessScope[];
78
- /** The tenant's mandatory gateway key: rotatable, never revocable. */
79
- readonly isDefault: boolean;
80
- readonly keyPrefix?: string;
81
- readonly parentKeyId?: string;
82
- readonly requestsPerMinute?: number;
83
- readonly dailyTokenLimit?: number;
84
- readonly monthlyTokenLimit?: number;
85
- readonly lifetimeTokenBudget?: number;
86
- readonly modelPrefix?: string;
87
- readonly modelAccessMode?: ApiKeyModelAccessMode;
88
- readonly modelList?: readonly string[];
89
- readonly clientRouterDenylist?: readonly string[];
90
- readonly maxConcurrentRequests?: number;
91
- readonly notesTitle?: string;
92
- readonly notesSubtitle?: string;
93
- readonly notesBody?: string;
94
- readonly sharePopupEnabled?: boolean;
95
- /** Mime of the stored popup art; its bytes are served by a dedicated route. */
96
- readonly sharePopupImageMime?: string;
97
- readonly sharePopupTitle?: string;
98
- readonly sharePopupBody?: string;
99
- readonly createdAt: string;
100
- readonly revokedAt?: string;
101
- readonly tokensConsumed: number;
102
- }
103
- /** Creation result; plaintext is returned only when a personal key is minted. */
104
- export interface CreateApiKeyResponse extends ApiKeyResponse {
105
- readonly secret?: string;
106
- }
107
-
108
- /** Edit result carries one new secret on personal rotation or template conversion. */
109
- export interface UpdateApiKeyResponse extends ApiKeyResponse {
110
- readonly secret?: string;
111
- }
112
-
113
- /** API-key route dependency boundary. */
114
- export interface ApiKeyConfig {
115
- readonly store: ApiKeyStore;
116
- readonly accessResolver: ConsoleAccessResolver;
117
- readonly auditSink?: AuditSink;
118
- readonly shareStore?: ShareLinkStore;
119
- readonly shareActivity?: ShareActivityPort;
120
- readonly admissionService: Pick<ApiKeyAdmissionService, "purgeKey"> & {
121
- seedBuckets?(request: {
122
- readonly apiKeyId: string;
123
- readonly now?: number;
124
- readonly daily?: number;
125
- readonly monthly?: number;
126
- }): Promise<void>;
127
- };
128
- /**
129
- * Reads tokens already spent in the current daily/monthly bucket, so a
130
- * newly-added limit is enforced against the whole bucket. Optional: without
131
- * it a new limit simply starts counting from the moment it was set.
132
- */
133
- readonly bucketSpend?: (
134
- keyId: string,
135
- now: Date,
136
- ) => Promise<{ daily: number; monthly: number }>;
137
- }
138
-
139
- /** Result of minting a share link. The bearer token is returned exactly once. */
140
- export interface ShareKeyResponse {
141
- readonly id: string;
142
- readonly url: string;
143
- /**
144
- * The bearer half of the link. Null when the row was written under an
145
- * encryption key this deployment no longer holds: the page stays reachable
146
- * from `url`, but the secret cannot be shown again and must be regenerated
147
- * before the link can authenticate.
148
- */
149
- readonly token: string | null;
150
- readonly kind: ShareLinkKind;
151
- readonly expiresAt: string | null;
152
- }
153
-
154
- /** One share link as shown in the owner's console list. Never carries the token. */
155
- export interface ShareLinkResponse {
156
- readonly id: string;
157
- readonly kind: ShareLinkKind;
158
- readonly active: boolean;
159
- readonly createdAt: string;
160
- readonly expiresAt: string | null;
161
- readonly lastViewedAt: string | null;
162
- }
163
-
164
- /**
165
- * Default prefix for newly generated API keys.
166
- *
167
- * The separator is a hyphen, not an underscore, and the random tail is a fixed
168
- * 16 characters: `pn-carte-` for personal keys, `sh-carte-` for the ones a
169
- * share template issues. Both are metadata shown in the console — nothing
170
- * looks a key up by its prefix — so changing these does not invalidate rows
171
- * already stored, which keep the prefix they were issued with.
172
- */
173
- export const DEFAULT_API_KEY_PREFIX = "pn-carte-";
174
- /** Default prefix used by share templates and the keys they issue. */
175
- export const DEFAULT_SHARE_API_KEY_PREFIX = "sh-carte-";
176
-
177
- /**
178
- * Bytes of entropy behind the random tail.
179
- *
180
- * 12 bytes base64url-encodes to exactly 16 characters with no padding, which
181
- * is the whole reason for the number: the length in the constants above and
182
- * the length a generated key actually has cannot disagree.
183
- */
184
- const KEY_SECRET_BYTES = 12;
185
-
186
- /** Label of the default gateway API key seeded at first boot; never revoked. */
187
- export const DEFAULT_API_KEY_LABEL = "Default Cartethyia API key";
188
-
189
- /** Recognizes the default gateway API key by its persisted flag. */
190
- export function isDefaultGatewayApiKey(
191
- record: Pick<ApiKeyRecord, "isDefault">,
192
- ): boolean {
193
- return record.isDefault;
194
- }
195
-
196
- /** Generates an inbound key and its one-way hash. */
197
- export function generateApiKeySecret(prefix = DEFAULT_API_KEY_PREFIX): {
198
- secret: string;
199
- hash: string;
200
- prefix: string;
201
- } {
202
- const secret = `${prefix}${randomBytes(KEY_SECRET_BYTES).toString("base64url")}`;
203
- return { secret, hash: hashSecret(secret), prefix };
204
- }
205
-
206
- /** Normalizes a caller-supplied prefix; falls back to the default. */
207
- export function resolveKeyPrefix(raw: string | undefined): string {
208
- const trimmed = raw?.trim();
209
- return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_API_KEY_PREFIX;
210
- }
211
-
212
- /** Resolves the share-key prefix; falls back to the default. */
213
- export function resolveShareKeyPrefix(raw: string | undefined): string {
214
- const trimmed = raw?.trim();
215
- return !trimmed ? DEFAULT_SHARE_API_KEY_PREFIX : trimmed;
216
- }
217
-
218
- /** Maximum hint the enroll page accepts; the dashboard caps its input there. */
219
- export const SHARED_CHILD_HINT_MAX_LENGTH = 20;
220
-
221
- /** Hashes an owner-supplied personal key; persistence encrypts it for CLI handoff. */
222
- export function prepareCustomKey(secret: string): { secret: string; hash: string } {
223
- if (secret.trim().length === 0) {
224
- throw new ConsoleDomainError("invalid_key", 400, "Custom key must be a non-empty string");
225
- }
226
- return { secret, hash: hashSecret(secret) };
227
- }
228
-
229
- export function finitePositive(value: number | null | undefined, name: string): void {
230
- // `null` explicitly clears a previously set limit; `undefined` leaves it
231
- // unchanged. Only a real non-null number must be finite and positive.
232
- if (value !== undefined && value !== null && (!Number.isFinite(value) || value < 1)) {
233
- throw new ConsoleDomainError("invalid_limits", 400, `${name} must be a finite positive number`);
234
- }
235
- }
236
-
237
- /**
238
- * Decodes an uploaded popup image into the bytes and mime to persist.
239
- *
240
- * Returns `null` for absent/blank input (nothing to store). Throws
241
- * `invalid_share_popup` for a non-data URL, an oversized payload, or bytes that
242
- * are not one of the four raster formats the share page may safely serve.
243
- */
244
- export function parseSharePopupImage(
245
- value: string | null | undefined,
246
- ): { image: Buffer; mime: SharePopupImageMime } | null {
247
- if (value === null || value === undefined) return null;
248
- const raw = value.trim();
249
- if (raw.length === 0) return null;
250
- const match =
251
- /^data:([a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+);base64,([A-Za-z0-9+/=]+)$/i.exec(raw);
252
- const declared = match?.[1]?.toLowerCase();
253
- const payload = match?.[2];
254
- if (!payload || declared === undefined || !(SHARE_POPUP_IMAGE_MIMES as readonly string[]).includes(declared)) {
255
- throw new ConsoleDomainError(
256
- "invalid_share_popup",
257
- 400,
258
- "sharePopupImage must be an uploaded PNG, JPEG, WebP, or GIF file",
259
- );
260
- }
261
- const image = Buffer.from(payload, "base64");
262
- if (image.length === 0 || image.length > SHARE_POPUP_IMAGE_MAX_BYTES) {
263
- throw new ConsoleDomainError(
264
- "invalid_share_popup",
265
- 400,
266
- `sharePopupImage must be at most ${Math.floor(SHARE_POPUP_IMAGE_MAX_BYTES / (1024 * 1024))} MB`,
267
- );
268
- }
269
- const mime = sniffSharePopupImageMime(image);
270
- if (mime === undefined || mime !== declared) {
271
- throw new ConsoleDomainError(
272
- "invalid_share_popup",
273
- 400,
274
- "sharePopupImage bytes do not match a supported image format",
275
- );
276
- }
277
- return { image, mime };
278
- }
279
-
280
- /** Validates scopes and quota fields before persistence. */
281
- export function validateApiKeyRequest(request: CreateApiKeyRequest): readonly AccessScope[] {
282
- const keyMode = request.keyMode ?? "personal";
283
- if (!(API_KEY_MODES as readonly string[]).includes(keyMode)) {
284
- throw new ConsoleDomainError("invalid_key_mode", 400, "keyMode must be personal or share");
285
- }
286
- if (keyMode === "share" && request.key !== undefined) {
287
- throw new ConsoleDomainError("invalid_key_mode", 400, "Share templates cannot carry a personal key");
288
- }
289
- const scopes = request.scopes ?? ["routing:invoke", "search:invoke"];
290
- for (const scope of scopes) {
291
- if (!isValidTenantKeyScope(scope as AccessScope)) {
292
- throw new ConsoleDomainError("invalid_scope", 400, `Scope is not allowed: ${scope}`);
293
- }
294
- }
295
- finitePositive(request.requestsPerMinute, "requestsPerMinute");
296
- finitePositive(request.dailyTokenLimit, "dailyTokenLimit");
297
- finitePositive(request.monthlyTokenLimit, "monthlyTokenLimit");
298
- finitePositive(request.lifetimeTokenBudget, "lifetimeTokenBudget");
299
- finitePositive(request.maxConcurrentRequests, "maxConcurrentRequests");
300
- parseSharePopupImage(request.sharePopupImage);
301
- if (request.sharePopupTitle != null && request.sharePopupTitle.length > 120) {
302
- throw new ConsoleDomainError("invalid_share_popup", 400, "Share popup title is too long");
303
- }
304
- if (request.sharePopupBody != null && request.sharePopupBody.length > 1200) {
305
- throw new ConsoleDomainError("invalid_share_popup", 400, "Share popup body is too long");
306
- }
307
- if (
308
- request.modelPrefix !== undefined &&
309
- (typeof request.modelPrefix !== "string" || request.modelPrefix.trim().length === 0)
310
- ) {
311
- throw new ConsoleDomainError(
312
- "invalid_model_prefix",
313
- 400,
314
- "modelPrefix must be a non-empty string if provided",
315
- );
316
- }
317
- if (
318
- request.keyPrefix !== undefined &&
319
- (typeof request.keyPrefix !== "string" || request.keyPrefix.trim().length === 0)
320
- ) {
321
- throw new ConsoleDomainError(
322
- "invalid_key_prefix",
323
- 400,
324
- "keyPrefix must be a non-empty string if provided",
325
- );
326
- }
327
- if (request.clientRouterDenylist !== undefined) {
328
- if (!Array.isArray(request.clientRouterDenylist)) {
329
- throw new ConsoleDomainError(
330
- "invalid_client_router_denylist",
331
- 400,
332
- "clientRouterDenylist must be an array of client-router ids",
333
- );
334
- }
335
- // An unrecognised id would persist a rule that can never match — the operator
336
- // would believe a router is blocked while nothing blocks it. Rejecting it is
337
- // the only outcome that keeps the stored policy honest.
338
- for (const id of request.clientRouterDenylist) {
339
- if (typeof id !== "string" || normalizeClientRouterId(id) === undefined) {
340
- throw new ConsoleDomainError(
341
- "invalid_client_router_denylist",
342
- 400,
343
- `Unknown client router: ${String(id)}`,
344
- );
345
- }
346
- }
347
- }
348
- return scopes as AccessScope[];
349
- }
350
-
351
- /** Serializes a persisted record without credential material or IP-key digests. */
352
- export function sanitizeApiKeyResponse(record: ApiKeyRecord): ApiKeyResponse {
353
- const keyPrefix =
354
- record.keyMode === "share" ? resolveShareKeyPrefix(record.keyPrefix) : record.keyPrefix;
355
- return {
356
- id: record.id,
357
- label: record.label,
358
- enabled: record.enabled,
359
- keyMode: record.keyMode,
360
- scopes: record.scopes,
361
- /**
362
- * The persisted flag alone decides this: the label is display text, so
363
- * renaming the key cannot change which key is the default.
364
- */
365
- isDefault: isDefaultGatewayApiKey(record),
366
- ...(keyPrefix === undefined ? {} : { keyPrefix }),
367
- ...(record.parentKeyId === undefined ? {} : { parentKeyId: record.parentKeyId }),
368
- ...(record.notesTitle === undefined ? {} : { notesTitle: record.notesTitle }),
369
- ...(record.notesSubtitle === undefined ? {} : { notesSubtitle: record.notesSubtitle }),
370
- ...(record.notesBody === undefined ? {} : { notesBody: record.notesBody }),
371
- ...(record.sharePopupEnabled === undefined ? {} : { sharePopupEnabled: record.sharePopupEnabled }),
372
- ...(record.sharePopupImageMime === undefined
373
- ? {}
374
- : { sharePopupImageMime: record.sharePopupImageMime }),
375
- ...(record.sharePopupTitle === undefined ? {} : { sharePopupTitle: record.sharePopupTitle }),
376
- ...(record.sharePopupBody === undefined ? {} : { sharePopupBody: record.sharePopupBody }),
377
- ...(record.requestsPerMinute === undefined ? {} : { requestsPerMinute: record.requestsPerMinute }),
378
- ...(record.dailyTokenLimit === undefined ? {} : { dailyTokenLimit: record.dailyTokenLimit }),
379
- ...(record.monthlyTokenLimit === undefined ? {} : { monthlyTokenLimit: record.monthlyTokenLimit }),
380
- ...(record.lifetimeTokenBudget === undefined ? {} : { lifetimeTokenBudget: record.lifetimeTokenBudget }),
381
- ...(record.maxConcurrentRequests === undefined ? {} : { maxConcurrentRequests: record.maxConcurrentRequests }),
382
- ...(record.modelPrefix === undefined ? {} : { modelPrefix: record.modelPrefix }),
383
- ...(record.modelAccessMode === undefined ? {} : { modelAccessMode: record.modelAccessMode }),
384
- ...(record.modelList === undefined ? {} : { modelList: record.modelList }),
385
- ...(record.clientRouterDenylist === undefined
386
- ? {}
387
- : { clientRouterDenylist: record.clientRouterDenylist }),
388
- createdAt: record.createdAt.toISOString(),
389
- ...(record.revokedAt === undefined ? {} : { revokedAt: record.revokedAt.toISOString() }),
390
- tokensConsumed: record.tokensConsumed,
391
- };
392
- }
393
-
394
- export function parseRequest(value: unknown): CreateApiKeyRequest {
395
- if (typeof value !== "object" || value === null)
396
- throw new ConsoleDomainError("invalid_request", 400, "Request body must be an object");
397
- return value as CreateApiKeyRequest;
398
- }
399
-
400
- /** Maps a persisted share link to the console response (ISO timestamps, no token). */
401
- export function mapShareLinkResponse(link: ShareLinkSummary): ShareLinkResponse {
402
- return {
403
- id: link.id,
404
- kind: link.kind,
405
- active: link.active,
406
- createdAt: link.createdAt.toISOString(),
407
- expiresAt: link.expiresAt === null ? null : link.expiresAt.toISOString(),
408
- lastViewedAt: link.lastViewedAt === null ? null : link.lastViewedAt.toISOString(),
409
- };
410
- }
1
+ // Console API-key domain contracts: request/response shapes and pure helpers.
2
+ //
3
+ // Single source for key creation validation and response sanitization;
4
+ // routes consume it, they do not redeclare it.
5
+
6
+ import type { AuditSink } from "../audit/contracts";
7
+ import { randomBytes } from "node:crypto";
8
+ import type { ApiKeyRecord, ApiKeyStore } from "../../persistence/stores/api-key-store";
9
+ import { hashSecret } from "../../security/crypto";
10
+ import { type ShareLinkStore, type ShareLinkSummary } from "../../persistence/stores/share-store";
11
+ import { API_KEY_MODES, type ApiKeyMode, type ApiKeyModelAccessMode, type ShareLinkKind } from "../../persistence/schema";
12
+ import { isValidTenantKeyScope, type AccessScope } from "../../security/access-control";
13
+ import { normalizeClientRouterId } from "../../security/client-router-fingerprint";
14
+ import type { ShareActivityPort } from "../share/share-usage";
15
+ import { ConsoleDomainError } from "../shared/errors";
16
+ import type { ConsoleAccessResolver } from "../auth/access";
17
+ import type { ApiKeyAdmissionService } from "../../security/admission/service";
18
+ import {
19
+ SHARE_POPUP_IMAGE_MAX_BYTES,
20
+ SHARE_POPUP_IMAGE_MIMES,
21
+ sniffSharePopupImageMime,
22
+ type SharePopupImageMime,
23
+ } from "./share-popup-image";
24
+
25
+ // Re-exported so consumers reach the image bounds from this contract module.
26
+ // `share-popup-image.ts` is the pure authority; it stays import-free so the
27
+ // dashboard can read the same bounds its upload control enforces.
28
+ export {
29
+ SHARE_POPUP_IMAGE_MAX_BYTES,
30
+ SHARE_POPUP_IMAGE_MIMES,
31
+ type SharePopupImageMime,
32
+ } from "./share-popup-image";
33
+ export type {
34
+ SharedKeySummary,
35
+ SharedKeyActivityDetail,
36
+ } from "../share/share-usage";
37
+ /** Input accepted when creating or editing a key. Null limits mean unlimited. */
38
+ export interface CreateApiKeyRequest {
39
+ enabled?: boolean;
40
+ label?: string;
41
+ keyMode?: ApiKeyMode;
42
+ scopes?: readonly string[];
43
+ /** Public leading fragment used for generated keys; defaults to the product prefix. */
44
+ keyPrefix?: string;
45
+ /** Owner-supplied raw key value; valid only for personal keys. */
46
+ key?: string;
47
+ requestsPerMinute?: number | null;
48
+ dailyTokenLimit?: number | null;
49
+ monthlyTokenLimit?: number | null;
50
+ lifetimeTokenBudget?: number | null;
51
+ maxConcurrentRequests?: number | null;
52
+ modelPrefix?: string;
53
+ modelAccessMode?: ApiKeyModelAccessMode;
54
+ modelList?: readonly string[];
55
+ /** Client-router ids this key refuses; see `client-router-fingerprint.ts`. */
56
+ clientRouterDenylist?: readonly string[];
57
+ notesTitle?: string;
58
+ notesSubtitle?: string;
59
+ notesBody?: string;
60
+ sharePopupEnabled?: boolean | null;
61
+ /**
62
+ * Uploaded popup art as a `data:<mime>;base64,…` URL. `null` clears it and
63
+ * omitting it leaves the stored image alone. There is no remote-URL form: a
64
+ * hotlink leaked each visitor's IP to a third party and rotted on host moves.
65
+ */
66
+ sharePopupImage?: string | null;
67
+ sharePopupTitle?: string;
68
+ sharePopupBody?: string;
69
+ }
70
+
71
+ /** Public key representation; it never contains a secret, hash, or IP-key digest. */
72
+ export interface ApiKeyResponse {
73
+ readonly id: string;
74
+ readonly label: string;
75
+ readonly enabled: boolean;
76
+ readonly keyMode: ApiKeyMode;
77
+ readonly scopes: readonly AccessScope[];
78
+ /** The tenant's mandatory gateway key: rotatable, never revocable. */
79
+ readonly isDefault: boolean;
80
+ readonly keyPrefix?: string;
81
+ readonly parentKeyId?: string;
82
+ readonly requestsPerMinute?: number;
83
+ readonly dailyTokenLimit?: number;
84
+ readonly monthlyTokenLimit?: number;
85
+ readonly lifetimeTokenBudget?: number;
86
+ readonly modelPrefix?: string;
87
+ readonly modelAccessMode?: ApiKeyModelAccessMode;
88
+ readonly modelList?: readonly string[];
89
+ readonly clientRouterDenylist?: readonly string[];
90
+ readonly maxConcurrentRequests?: number;
91
+ readonly notesTitle?: string;
92
+ readonly notesSubtitle?: string;
93
+ readonly notesBody?: string;
94
+ readonly sharePopupEnabled?: boolean;
95
+ /** Mime of the stored popup art; its bytes are served by a dedicated route. */
96
+ readonly sharePopupImageMime?: string;
97
+ readonly sharePopupTitle?: string;
98
+ readonly sharePopupBody?: string;
99
+ readonly createdAt: string;
100
+ readonly revokedAt?: string;
101
+ readonly tokensConsumed: number;
102
+ }
103
+ /** Creation result; plaintext is returned only when a personal key is minted. */
104
+ export interface CreateApiKeyResponse extends ApiKeyResponse {
105
+ readonly secret?: string;
106
+ }
107
+
108
+ /** Edit result carries one new secret on personal rotation or template conversion. */
109
+ export interface UpdateApiKeyResponse extends ApiKeyResponse {
110
+ readonly secret?: string;
111
+ }
112
+
113
+ /** API-key route dependency boundary. */
114
+ export interface ApiKeyConfig {
115
+ readonly store: ApiKeyStore;
116
+ readonly accessResolver: ConsoleAccessResolver;
117
+ readonly auditSink?: AuditSink;
118
+ readonly shareStore?: ShareLinkStore;
119
+ readonly shareActivity?: ShareActivityPort;
120
+ readonly admissionService: Pick<ApiKeyAdmissionService, "purgeKey"> & {
121
+ seedBuckets?(request: {
122
+ readonly apiKeyId: string;
123
+ readonly now?: number;
124
+ readonly daily?: number;
125
+ readonly monthly?: number;
126
+ }): Promise<void>;
127
+ };
128
+ /**
129
+ * Reads tokens already spent in the current daily/monthly bucket, so a
130
+ * newly-added limit is enforced against the whole bucket. Optional: without
131
+ * it a new limit simply starts counting from the moment it was set.
132
+ */
133
+ readonly bucketSpend?: (
134
+ keyId: string,
135
+ now: Date,
136
+ ) => Promise<{ daily: number; monthly: number }>;
137
+ }
138
+
139
+ /** Result of minting a share link. The bearer token is returned exactly once. */
140
+ export interface ShareKeyResponse {
141
+ readonly id: string;
142
+ readonly url: string;
143
+ /**
144
+ * The bearer half of the link. Null when the row was written under an
145
+ * encryption key this deployment no longer holds: the page stays reachable
146
+ * from `url`, but the secret cannot be shown again and must be regenerated
147
+ * before the link can authenticate.
148
+ */
149
+ readonly token: string | null;
150
+ readonly kind: ShareLinkKind;
151
+ readonly expiresAt: string | null;
152
+ }
153
+
154
+ /** One share link as shown in the owner's console list. Never carries the token. */
155
+ export interface ShareLinkResponse {
156
+ readonly id: string;
157
+ readonly kind: ShareLinkKind;
158
+ readonly active: boolean;
159
+ readonly createdAt: string;
160
+ readonly expiresAt: string | null;
161
+ readonly lastViewedAt: string | null;
162
+ }
163
+
164
+ /**
165
+ * Default prefix for newly generated API keys.
166
+ *
167
+ * The separator is a hyphen, not an underscore, and the random tail is a fixed
168
+ * 16 characters: `pn-carte-` for personal keys, `sh-carte-` for the ones a
169
+ * share template issues. Both are metadata shown in the console — nothing
170
+ * looks a key up by its prefix — so changing these does not invalidate rows
171
+ * already stored, which keep the prefix they were issued with.
172
+ */
173
+ export const DEFAULT_API_KEY_PREFIX = "pn-carte-";
174
+ /** Default prefix used by share templates and the keys they issue. */
175
+ export const DEFAULT_SHARE_API_KEY_PREFIX = "sh-carte-";
176
+
177
+ /**
178
+ * Bytes of entropy behind the random tail.
179
+ *
180
+ * 12 bytes base64url-encodes to exactly 16 characters with no padding, which
181
+ * is the whole reason for the number: the length in the constants above and
182
+ * the length a generated key actually has cannot disagree.
183
+ */
184
+ const KEY_SECRET_BYTES = 12;
185
+
186
+ /** Label of the default gateway API key seeded at first boot; never revoked. */
187
+ export const DEFAULT_API_KEY_LABEL = "Default Cartethyia API key";
188
+
189
+ /** Recognizes the default gateway API key by its persisted flag. */
190
+ export function isDefaultGatewayApiKey(
191
+ record: Pick<ApiKeyRecord, "isDefault">,
192
+ ): boolean {
193
+ return record.isDefault;
194
+ }
195
+
196
+ /** Generates an inbound key and its one-way hash. */
197
+ export function generateApiKeySecret(prefix = DEFAULT_API_KEY_PREFIX): {
198
+ secret: string;
199
+ hash: string;
200
+ prefix: string;
201
+ } {
202
+ const secret = `${prefix}${randomBytes(KEY_SECRET_BYTES).toString("base64url")}`;
203
+ return { secret, hash: hashSecret(secret), prefix };
204
+ }
205
+
206
+ /** Normalizes a caller-supplied prefix; falls back to the default. */
207
+ export function resolveKeyPrefix(raw: string | undefined): string {
208
+ const trimmed = raw?.trim();
209
+ return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_API_KEY_PREFIX;
210
+ }
211
+
212
+ /** Resolves the share-key prefix; falls back to the default. */
213
+ export function resolveShareKeyPrefix(raw: string | undefined): string {
214
+ const trimmed = raw?.trim();
215
+ return !trimmed ? DEFAULT_SHARE_API_KEY_PREFIX : trimmed;
216
+ }
217
+
218
+ /** Maximum hint the enroll page accepts; the dashboard caps its input there. */
219
+ export const SHARED_CHILD_HINT_MAX_LENGTH = 20;
220
+
221
+ /** Hashes an owner-supplied personal key; persistence encrypts it for CLI handoff. */
222
+ export function prepareCustomKey(secret: string): { secret: string; hash: string } {
223
+ if (secret.trim().length === 0) {
224
+ throw new ConsoleDomainError("invalid_key", 400, "Custom key must be a non-empty string");
225
+ }
226
+ return { secret, hash: hashSecret(secret) };
227
+ }
228
+
229
+ export function finitePositive(value: number | null | undefined, name: string): void {
230
+ // `null` explicitly clears a previously set limit; `undefined` leaves it
231
+ // unchanged. Only a real non-null number must be finite and positive.
232
+ if (value !== undefined && value !== null && (!Number.isFinite(value) || value < 1)) {
233
+ throw new ConsoleDomainError("invalid_limits", 400, `${name} must be a finite positive number`);
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Decodes an uploaded popup image into the bytes and mime to persist.
239
+ *
240
+ * Returns `null` for absent/blank input (nothing to store). Throws
241
+ * `invalid_share_popup` for a non-data URL, an oversized payload, or bytes that
242
+ * are not one of the four raster formats the share page may safely serve.
243
+ */
244
+ export function parseSharePopupImage(
245
+ value: string | null | undefined,
246
+ ): { image: Buffer; mime: SharePopupImageMime } | null {
247
+ if (value === null || value === undefined) return null;
248
+ const raw = value.trim();
249
+ if (raw.length === 0) return null;
250
+ const match =
251
+ /^data:([a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+);base64,([A-Za-z0-9+/=]+)$/i.exec(raw);
252
+ const declared = match?.[1]?.toLowerCase();
253
+ const payload = match?.[2];
254
+ if (!payload || declared === undefined || !(SHARE_POPUP_IMAGE_MIMES as readonly string[]).includes(declared)) {
255
+ throw new ConsoleDomainError(
256
+ "invalid_share_popup",
257
+ 400,
258
+ "sharePopupImage must be an uploaded PNG, JPEG, WebP, or GIF file",
259
+ );
260
+ }
261
+ const image = Buffer.from(payload, "base64");
262
+ if (image.length === 0 || image.length > SHARE_POPUP_IMAGE_MAX_BYTES) {
263
+ throw new ConsoleDomainError(
264
+ "invalid_share_popup",
265
+ 400,
266
+ `sharePopupImage must be at most ${Math.floor(SHARE_POPUP_IMAGE_MAX_BYTES / (1024 * 1024))} MB`,
267
+ );
268
+ }
269
+ const mime = sniffSharePopupImageMime(image);
270
+ if (mime === undefined || mime !== declared) {
271
+ throw new ConsoleDomainError(
272
+ "invalid_share_popup",
273
+ 400,
274
+ "sharePopupImage bytes do not match a supported image format",
275
+ );
276
+ }
277
+ return { image, mime };
278
+ }
279
+
280
+ /** Validates scopes and quota fields before persistence. */
281
+ export function validateApiKeyRequest(request: CreateApiKeyRequest): readonly AccessScope[] {
282
+ const keyMode = request.keyMode ?? "personal";
283
+ if (!(API_KEY_MODES as readonly string[]).includes(keyMode)) {
284
+ throw new ConsoleDomainError("invalid_key_mode", 400, "keyMode must be personal or share");
285
+ }
286
+ if (keyMode === "share" && request.key !== undefined) {
287
+ throw new ConsoleDomainError("invalid_key_mode", 400, "Share templates cannot carry a personal key");
288
+ }
289
+ const scopes = request.scopes ?? ["routing:invoke", "search:invoke"];
290
+ for (const scope of scopes) {
291
+ if (!isValidTenantKeyScope(scope as AccessScope)) {
292
+ throw new ConsoleDomainError("invalid_scope", 400, `Scope is not allowed: ${scope}`);
293
+ }
294
+ }
295
+ finitePositive(request.requestsPerMinute, "requestsPerMinute");
296
+ finitePositive(request.dailyTokenLimit, "dailyTokenLimit");
297
+ finitePositive(request.monthlyTokenLimit, "monthlyTokenLimit");
298
+ finitePositive(request.lifetimeTokenBudget, "lifetimeTokenBudget");
299
+ finitePositive(request.maxConcurrentRequests, "maxConcurrentRequests");
300
+ parseSharePopupImage(request.sharePopupImage);
301
+ if (request.sharePopupTitle != null && request.sharePopupTitle.length > 120) {
302
+ throw new ConsoleDomainError("invalid_share_popup", 400, "Share popup title is too long");
303
+ }
304
+ if (request.sharePopupBody != null && request.sharePopupBody.length > 1200) {
305
+ throw new ConsoleDomainError("invalid_share_popup", 400, "Share popup body is too long");
306
+ }
307
+ if (
308
+ request.modelPrefix !== undefined &&
309
+ (typeof request.modelPrefix !== "string" || request.modelPrefix.trim().length === 0)
310
+ ) {
311
+ throw new ConsoleDomainError(
312
+ "invalid_model_prefix",
313
+ 400,
314
+ "modelPrefix must be a non-empty string if provided",
315
+ );
316
+ }
317
+ if (
318
+ request.keyPrefix !== undefined &&
319
+ (typeof request.keyPrefix !== "string" || request.keyPrefix.trim().length === 0)
320
+ ) {
321
+ throw new ConsoleDomainError(
322
+ "invalid_key_prefix",
323
+ 400,
324
+ "keyPrefix must be a non-empty string if provided",
325
+ );
326
+ }
327
+ if (request.clientRouterDenylist !== undefined) {
328
+ if (!Array.isArray(request.clientRouterDenylist)) {
329
+ throw new ConsoleDomainError(
330
+ "invalid_client_router_denylist",
331
+ 400,
332
+ "clientRouterDenylist must be an array of client-router ids",
333
+ );
334
+ }
335
+ // An unrecognised id would persist a rule that can never match — the operator
336
+ // would believe a router is blocked while nothing blocks it. Rejecting it is
337
+ // the only outcome that keeps the stored policy honest.
338
+ for (const id of request.clientRouterDenylist) {
339
+ if (typeof id !== "string" || normalizeClientRouterId(id) === undefined) {
340
+ throw new ConsoleDomainError(
341
+ "invalid_client_router_denylist",
342
+ 400,
343
+ `Unknown client router: ${String(id)}`,
344
+ );
345
+ }
346
+ }
347
+ }
348
+ return scopes as AccessScope[];
349
+ }
350
+
351
+ /** Serializes a persisted record without credential material or IP-key digests. */
352
+ export function sanitizeApiKeyResponse(record: ApiKeyRecord): ApiKeyResponse {
353
+ const keyPrefix =
354
+ record.keyMode === "share" ? resolveShareKeyPrefix(record.keyPrefix) : record.keyPrefix;
355
+ return {
356
+ id: record.id,
357
+ label: record.label,
358
+ enabled: record.enabled,
359
+ keyMode: record.keyMode,
360
+ scopes: record.scopes,
361
+ /**
362
+ * The persisted flag alone decides this: the label is display text, so
363
+ * renaming the key cannot change which key is the default.
364
+ */
365
+ isDefault: isDefaultGatewayApiKey(record),
366
+ ...(keyPrefix === undefined ? {} : { keyPrefix }),
367
+ ...(record.parentKeyId === undefined ? {} : { parentKeyId: record.parentKeyId }),
368
+ ...(record.notesTitle === undefined ? {} : { notesTitle: record.notesTitle }),
369
+ ...(record.notesSubtitle === undefined ? {} : { notesSubtitle: record.notesSubtitle }),
370
+ ...(record.notesBody === undefined ? {} : { notesBody: record.notesBody }),
371
+ ...(record.sharePopupEnabled === undefined ? {} : { sharePopupEnabled: record.sharePopupEnabled }),
372
+ ...(record.sharePopupImageMime === undefined
373
+ ? {}
374
+ : { sharePopupImageMime: record.sharePopupImageMime }),
375
+ ...(record.sharePopupTitle === undefined ? {} : { sharePopupTitle: record.sharePopupTitle }),
376
+ ...(record.sharePopupBody === undefined ? {} : { sharePopupBody: record.sharePopupBody }),
377
+ ...(record.requestsPerMinute === undefined ? {} : { requestsPerMinute: record.requestsPerMinute }),
378
+ ...(record.dailyTokenLimit === undefined ? {} : { dailyTokenLimit: record.dailyTokenLimit }),
379
+ ...(record.monthlyTokenLimit === undefined ? {} : { monthlyTokenLimit: record.monthlyTokenLimit }),
380
+ ...(record.lifetimeTokenBudget === undefined ? {} : { lifetimeTokenBudget: record.lifetimeTokenBudget }),
381
+ ...(record.maxConcurrentRequests === undefined ? {} : { maxConcurrentRequests: record.maxConcurrentRequests }),
382
+ ...(record.modelPrefix === undefined ? {} : { modelPrefix: record.modelPrefix }),
383
+ ...(record.modelAccessMode === undefined ? {} : { modelAccessMode: record.modelAccessMode }),
384
+ ...(record.modelList === undefined ? {} : { modelList: record.modelList }),
385
+ ...(record.clientRouterDenylist === undefined
386
+ ? {}
387
+ : { clientRouterDenylist: record.clientRouterDenylist }),
388
+ createdAt: record.createdAt.toISOString(),
389
+ ...(record.revokedAt === undefined ? {} : { revokedAt: record.revokedAt.toISOString() }),
390
+ tokensConsumed: record.tokensConsumed,
391
+ };
392
+ }
393
+
394
+ export function parseRequest(value: unknown): CreateApiKeyRequest {
395
+ if (typeof value !== "object" || value === null)
396
+ throw new ConsoleDomainError("invalid_request", 400, "Request body must be an object");
397
+ return value as CreateApiKeyRequest;
398
+ }
399
+
400
+ /** Maps a persisted share link to the console response (ISO timestamps, no token). */
401
+ export function mapShareLinkResponse(link: ShareLinkSummary): ShareLinkResponse {
402
+ return {
403
+ id: link.id,
404
+ kind: link.kind,
405
+ active: link.active,
406
+ createdAt: link.createdAt.toISOString(),
407
+ expiresAt: link.expiresAt === null ? null : link.expiresAt.toISOString(),
408
+ lastViewedAt: link.lastViewedAt === null ? null : link.lastViewedAt.toISOString(),
409
+ };
410
+ }