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,581 +1,581 @@
1
- // Persisted API-key store: the console API-key domain and gateway authentication both consume this boundary.
2
-
3
- import { and, eq, inArray, isNull, sql } from "drizzle-orm";
4
- import { nextSortIndex, reorderSortIndex, type SortOrderDb } from "../sort-order";
5
- import { getDbHandle } from "../boot";
6
- import type { CartethyiaDatabase } from "../db-handle";
7
- import { lockRows } from "../dialect";
8
- import { apiKeys, shareLinks, sharePopups, type ApiKeyMode, type ApiKeyModelAccessMode } from "../schema";
9
- import type { AccessScope } from "../../security/access-control";
10
-
11
- /** Persisted API key. Only its one-way hash authenticates; plaintext is never stored. */
12
- export interface ApiKeyRecord {
13
- readonly id: string;
14
- readonly tenantId: string;
15
- readonly keyHash: string | null;
16
- readonly keyMode: ApiKeyMode;
17
- readonly parentKeyId?: string;
18
- readonly issuedClientIp?: string;
19
- readonly issuedClientIpKey?: string;
20
- readonly label: string;
21
- readonly enabled: boolean;
22
- readonly scopes: readonly AccessScope[];
23
- /** The tenant's mandatory gateway key: rotatable, never revocable. */
24
- readonly isDefault: boolean;
25
- /** Non-secret prefix configured on a key or used to identify a share child. */
26
- readonly keyPrefix?: string;
27
- /** Encrypted copy exists only for personal keys used by CLI handoff. */
28
- readonly keyEncrypted?: Buffer;
29
- readonly notesTitle?: string;
30
- readonly notesSubtitle?: string;
31
- readonly notesBody?: string;
32
- readonly sharePopupEnabled?: boolean;
33
- /** Uploaded popup art; served from the share page rather than a remote URL. */
34
- readonly sharePopupImage?: Buffer;
35
- readonly sharePopupImageMime?: string;
36
- readonly sharePopupTitle?: string;
37
- readonly sharePopupBody?: string;
38
- readonly requestsPerMinute?: number;
39
- readonly dailyTokenLimit?: number;
40
- readonly monthlyTokenLimit?: number;
41
- readonly lifetimeTokenBudget?: number;
42
- readonly maxConcurrentRequests?: number;
43
- readonly modelPrefix?: string;
44
- readonly modelAccessMode?: ApiKeyModelAccessMode;
45
- readonly modelList?: readonly string[];
46
- /** Client-router ids this key refuses; see the schema column for the contract. */
47
- readonly clientRouterDenylist?: readonly string[];
48
- readonly createdAt: Date;
49
- readonly revokedAt?: Date;
50
- readonly tokensConsumed: number;
51
- }
52
-
53
- /**
54
- * Partial key update. Nullable policy fields clear their values; `undefined`
55
- * leaves them unchanged. Credential metadata is writable only for a mode
56
- * transition performed by the API-key domain.
57
- */
58
- export interface ApiKeyPatch
59
- extends Omit<
60
- Partial<ApiKeyRecord>,
61
- | "id"
62
- | "tenantId"
63
- | "keyHash"
64
- | "parentKeyId"
65
- | "issuedClientIp"
66
- | "issuedClientIpKey"
67
- | "createdAt"
68
- | "revokedAt"
69
- | "tokensConsumed"
70
- | "keyEncrypted"
71
- | "keyPrefix"
72
- | "notesTitle"
73
- | "notesSubtitle"
74
- | "notesBody"
75
- | "sharePopupEnabled"
76
- | "sharePopupImage"
77
- | "sharePopupImageMime"
78
- | "sharePopupTitle"
79
- | "sharePopupBody"
80
- | "requestsPerMinute"
81
- | "dailyTokenLimit"
82
- | "monthlyTokenLimit"
83
- | "lifetimeTokenBudget"
84
- | "maxConcurrentRequests"
85
- > {
86
- readonly keyHash?: string | null;
87
- readonly keyEncrypted?: Buffer | null;
88
- readonly keyPrefix?: string | null;
89
- readonly notesTitle?: string | null;
90
- readonly notesSubtitle?: string | null;
91
- readonly notesBody?: string | null;
92
- readonly sharePopupEnabled?: boolean | null;
93
- /** Null clears both bytes and mime; setting one without the other is rejected upstream. */
94
- readonly sharePopupImage?: Buffer | null;
95
- readonly sharePopupImageMime?: string | null;
96
- readonly sharePopupTitle?: string | null;
97
- readonly sharePopupBody?: string | null;
98
- readonly requestsPerMinute?: number | null;
99
- readonly dailyTokenLimit?: number | null;
100
- readonly monthlyTokenLimit?: number | null;
101
- readonly lifetimeTokenBudget?: number | null;
102
- readonly maxConcurrentRequests?: number | null;
103
- }
104
-
105
- interface PopupFields {
106
- readonly sharePopupEnabled?: boolean | null;
107
- readonly sharePopupImage?: Buffer | null;
108
- readonly sharePopupImageMime?: string | null;
109
- readonly sharePopupTitle?: string | null;
110
- readonly sharePopupBody?: string | null;
111
- }
112
-
113
- function popupPatchTouched(patch: PopupFields): boolean {
114
- return (
115
- patch.sharePopupEnabled !== undefined ||
116
- patch.sharePopupImage !== undefined ||
117
- patch.sharePopupImageMime !== undefined ||
118
- patch.sharePopupTitle !== undefined ||
119
- patch.sharePopupBody !== undefined
120
- );
121
- }
122
-
123
- /** Merges a partial popup patch onto the stored row, so an omitted field is kept. */
124
- async function mergeSharePopup(
125
- db: CartethyiaDatabase,
126
- apiKeyId: string,
127
- patch: PopupFields,
128
- ): Promise<void> {
129
- const existing = await db
130
- .select()
131
- .from(sharePopups)
132
- .where(eq(sharePopups.apiKeyId, apiKeyId))
133
- .limit(1);
134
- const row = existing[0];
135
- await writeSharePopup(db, apiKeyId, {
136
- sharePopupEnabled: patch.sharePopupEnabled !== undefined ? patch.sharePopupEnabled : (row?.enabled ?? false),
137
- sharePopupImage: patch.sharePopupImage !== undefined ? patch.sharePopupImage : (row?.image ?? null),
138
- sharePopupImageMime: patch.sharePopupImageMime !== undefined ? patch.sharePopupImageMime : (row?.imageMime ?? null),
139
- sharePopupTitle: patch.sharePopupTitle !== undefined ? patch.sharePopupTitle : (row?.title ?? null),
140
- sharePopupBody: patch.sharePopupBody !== undefined ? patch.sharePopupBody : (row?.body ?? null),
141
- });
142
- }
143
-
144
- /** Writes the popup row, or removes it when the owner cleared every field. */
145
- async function writeSharePopup(
146
- db: CartethyiaDatabase,
147
- apiKeyId: string,
148
- fields: PopupFields,
149
- ): Promise<void> {
150
- const enabled = fields.sharePopupEnabled ?? false;
151
- const image = fields.sharePopupImage ?? null;
152
- const title = fields.sharePopupTitle ?? null;
153
- const body = fields.sharePopupBody ?? null;
154
- if (!enabled && image === null && title === null && body === null) {
155
- await db.delete(sharePopups).where(eq(sharePopups.apiKeyId, apiKeyId));
156
- return;
157
- }
158
- await db
159
- .insert(sharePopups)
160
- .values({
161
- apiKeyId,
162
- enabled,
163
- image,
164
- imageMime: fields.sharePopupImageMime ?? null,
165
- title,
166
- body,
167
- })
168
- .onConflictDoUpdate({
169
- target: sharePopups.apiKeyId,
170
- set: {
171
- enabled,
172
- image,
173
- imageMime: fields.sharePopupImageMime ?? null,
174
- title,
175
- body,
176
- },
177
- });
178
- }
179
-
180
- /** Explicit persistence boundary. Production must provide this; there is no fallback store. */
181
- export interface ApiKeyStore {
182
- list(tenantId: string): Promise<readonly ApiKeyRecord[]>;
183
- get(tenantId: string, keyId: string): Promise<ApiKeyRecord | undefined>;
184
- listChildren(tenantId: string, parentKeyId: string): Promise<readonly ApiKeyRecord[]>;
185
- create(record: ApiKeyRecord): Promise<void>;
186
- /** Rewrites list order for one tenant; `keyIds` is the full desired order. */
187
- reorder(tenantId: string, keyIds: readonly string[]): Promise<void>;
188
- update(tenantId: string, keyId: string, patch: ApiKeyPatch): Promise<ApiKeyRecord | undefined>;
189
- revoke(tenantId: string, keyId: string, revokedAt: Date): Promise<boolean>;
190
- remove(tenantId: string, keyId: string): Promise<boolean>;
191
- findActiveByHash?(hash: string): Promise<typeof apiKeys.$inferSelect | undefined>;
192
- findActiveById?(keyId: string): Promise<typeof apiKeys.$inferSelect | undefined>;
193
- sumChildrenConsumed?(parentKeyId: string): Promise<number>;
194
- }
195
-
196
- export class DrizzleApiKeyStore implements ApiKeyStore {
197
- constructor(private readonly db: CartethyiaDatabase) {}
198
-
199
- /** Attaches the popup, which lives in its own table so the auth read never loads it. */
200
- private async withPopups(
201
- db: SortOrderDb | CartethyiaDatabase,
202
- rows: readonly (typeof apiKeys.$inferSelect)[],
203
- ): Promise<ApiKeyRecord[]> {
204
- if (rows.length === 0) return [];
205
- const popups = await db
206
- .select()
207
- .from(sharePopups)
208
- .where(inArray(sharePopups.apiKeyId, rows.map((row) => row.id)));
209
- const byKey = new Map(popups.map((popup) => [popup.apiKeyId, popup]));
210
- return rows.map((row) => this.map(row, byKey.get(row.id)));
211
- }
212
-
213
- private map(
214
- row: typeof apiKeys.$inferSelect,
215
- popup?: typeof sharePopups.$inferSelect,
216
- ): ApiKeyRecord {
217
- return {
218
- id: row.id,
219
- tenantId: row.tenantId,
220
- keyHash: row.keyHash,
221
- keyMode: row.keyMode,
222
- isDefault: row.isDefault,
223
- ...(row.parentKeyId === null ? {} : { parentKeyId: row.parentKeyId }),
224
- ...(row.issuedClientIp === null ? {} : { issuedClientIp: row.issuedClientIp }),
225
- ...(row.issuedClientIpKey === null
226
- ? {}
227
- : { issuedClientIpKey: row.issuedClientIpKey }),
228
- label: row.label,
229
- enabled: row.enabled,
230
- scopes: row.scopes as ApiKeyRecord["scopes"],
231
- ...(row.keyPrefix === null ? {} : { keyPrefix: row.keyPrefix }),
232
- ...(row.keyEncrypted === null ? {} : { keyEncrypted: row.keyEncrypted }),
233
- ...(row.notesTitle === null ? {} : { notesTitle: row.notesTitle }),
234
- ...(row.notesSubtitle === null ? {} : { notesSubtitle: row.notesSubtitle }),
235
- ...(row.notesBody === null ? {} : { notesBody: row.notesBody }),
236
- ...(popup?.enabled ? { sharePopupEnabled: popup.enabled } : {}),
237
- ...(popup?.image == null ? {} : { sharePopupImage: popup.image }),
238
- ...(popup?.imageMime == null ? {} : { sharePopupImageMime: popup.imageMime }),
239
- ...(popup?.title == null ? {} : { sharePopupTitle: popup.title }),
240
- ...(popup?.body == null ? {} : { sharePopupBody: popup.body }),
241
- ...(row.requestsPerMinute === null ? {} : { requestsPerMinute: row.requestsPerMinute }),
242
- ...(row.dailyTokenLimit === null ? {} : { dailyTokenLimit: row.dailyTokenLimit }),
243
- ...(row.monthlyTokenLimit === null ? {} : { monthlyTokenLimit: row.monthlyTokenLimit }),
244
- ...(row.lifetimeTokenBudget === null ? {} : { lifetimeTokenBudget: row.lifetimeTokenBudget }),
245
- ...(row.maxConcurrentRequests === null
246
- ? {}
247
- : { maxConcurrentRequests: row.maxConcurrentRequests }),
248
- ...(row.modelPrefix === null ? {} : { modelPrefix: row.modelPrefix }),
249
- modelAccessMode: row.modelAccessMode,
250
- ...(row.modelList === null ? {} : { modelList: row.modelList as string[] }),
251
- ...(row.clientRouterDenylist === null
252
- ? {}
253
- : { clientRouterDenylist: row.clientRouterDenylist as string[] }),
254
- createdAt: row.createdAt,
255
- ...(row.revokedAt ? { revokedAt: row.revokedAt } : {}),
256
- tokensConsumed: row.lifetimeTokensConsumed,
257
- };
258
- }
259
- async list(tenantId: string): Promise<readonly ApiKeyRecord[]> {
260
- // Bounded: a large tenant key list must not load fully into memory.
261
- // Ordered by the explicit `sort_index`, not `created_at`: a stable key means
262
- // rows keep their position across reloads (and can be reordered) instead of
263
- // shifting when two rows share a timestamp or one is updated.
264
- const rows = await this.db
265
- .select()
266
- .from(apiKeys)
267
- .where(eq(apiKeys.tenantId, tenantId))
268
- .orderBy(apiKeys.sortIndex, apiKeys.createdAt, apiKeys.id)
269
- .limit(1000);
270
- return this.withPopups(this.db, rows);
271
- }
272
-
273
- /** Rewrites the list order for one tenant; `keyIds` is the full order. */
274
- async reorder(tenantId: string, keyIds: readonly string[]): Promise<void> {
275
- await reorderSortIndex(
276
- this.db,
277
- { table: apiKeys, idColumn: apiKeys.id, sortIndexColumn: apiKeys.sortIndex },
278
- eq(apiKeys.tenantId, tenantId),
279
- keyIds,
280
- );
281
- }
282
-
283
- /** Next free list position for a new key in this tenant. */
284
- private async nextSortIndex(tenantId: string): Promise<number> {
285
- return nextSortIndex(
286
- this.db,
287
- { table: apiKeys, idColumn: apiKeys.id, sortIndexColumn: apiKeys.sortIndex },
288
- eq(apiKeys.tenantId, tenantId),
289
- );
290
- }
291
- async get(tenantId: string, keyId: string): Promise<ApiKeyRecord | undefined> {
292
- const rows = await this.db
293
- .select()
294
- .from(apiKeys)
295
- .where(and(eq(apiKeys.tenantId, tenantId), eq(apiKeys.id, keyId)))
296
- .limit(1);
297
- return rows[0] ? (await this.withPopups(this.db, rows))[0] : undefined;
298
- }
299
- async listChildren(tenantId: string, parentKeyId: string): Promise<readonly ApiKeyRecord[]> {
300
- const rows = await this.db
301
- .select()
302
- .from(apiKeys)
303
- .where(
304
- and(
305
- eq(apiKeys.tenantId, tenantId),
306
- eq(apiKeys.parentKeyId, parentKeyId),
307
- ),
308
- )
309
- .orderBy(apiKeys.createdAt, apiKeys.id);
310
- return this.withPopups(this.db, rows);
311
- }
312
- async create(record: ApiKeyRecord): Promise<void> {
313
- // Append to the end of the tenant's list rather than relying on creation
314
- // time, so a new key never displaces the existing rows' positions.
315
- const sortIndex = await this.nextSortIndex(record.tenantId);
316
- await this.db.insert(apiKeys).values({
317
- id: record.id,
318
- tenantId: record.tenantId,
319
- sortIndex,
320
- keyHash: record.keyHash,
321
- keyMode: record.keyMode,
322
- parentKeyId: record.parentKeyId ?? null,
323
- issuedClientIp: record.issuedClientIp ?? null,
324
- issuedClientIpKey: record.issuedClientIpKey ?? null,
325
- label: record.label,
326
- scopes: record.scopes,
327
- enabled: record.enabled,
328
- keyPrefix: record.keyPrefix ?? null,
329
- keyEncrypted: record.keyEncrypted ?? null,
330
- notesTitle: record.notesTitle ?? null,
331
- notesSubtitle: record.notesSubtitle ?? null,
332
- notesBody: record.notesBody ?? null,
333
- requestsPerMinute: record.requestsPerMinute ?? null,
334
- dailyTokenLimit: record.dailyTokenLimit ?? null,
335
- monthlyTokenLimit: record.monthlyTokenLimit ?? null,
336
- lifetimeTokenBudget: record.lifetimeTokenBudget ?? null,
337
- maxConcurrentRequests: record.maxConcurrentRequests ?? null,
338
- modelPrefix: record.modelPrefix ?? null,
339
- modelAccessMode: record.modelAccessMode ?? "whitelist",
340
- modelList: record.modelList ?? null,
341
- clientRouterDenylist: record.clientRouterDenylist ?? null,
342
- lifetimeTokensConsumed: record.tokensConsumed,
343
- });
344
- await writeSharePopup(this.db, record.id, record);
345
- }
346
- async update(
347
- tenantId: string,
348
- keyId: string,
349
- patch: ApiKeyPatch,
350
- ): Promise<ApiKeyRecord | undefined> {
351
- return this.db.transaction(async (tx) => {
352
- const existingRows = await lockRows(
353
- tx
354
- .select({
355
- id: apiKeys.id,
356
- keyMode: apiKeys.keyMode,
357
- parentKeyId: apiKeys.parentKeyId,
358
- })
359
- .from(apiKeys)
360
- .where(and(eq(apiKeys.tenantId, tenantId), eq(apiKeys.id, keyId)))
361
- .limit(1),
362
- );
363
- const existing = existingRows[0];
364
- if (!existing) return undefined;
365
- if (existing.parentKeyId !== null && patch.keyMode !== undefined && patch.keyMode !== existing.keyMode)
366
- return undefined;
367
-
368
- if (existing.keyMode === "share" && patch.keyMode === "personal") {
369
- await tx
370
- .update(apiKeys)
371
- .set({ revokedAt: new Date() })
372
- .where(and(eq(apiKeys.parentKeyId, keyId), isNull(apiKeys.revokedAt)));
373
- await tx
374
- .update(shareLinks)
375
- .set({ active: false })
376
- .where(and(eq(shareLinks.apiKeyId, keyId), eq(shareLinks.active, true)));
377
- }
378
-
379
- const rows = await tx
380
- .update(apiKeys)
381
- .set({
382
- ...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}),
383
- ...(patch.label !== undefined ? { label: patch.label } : {}),
384
- ...(patch.isDefault !== undefined ? { isDefault: patch.isDefault } : {}),
385
- ...(patch.keyHash !== undefined ? { keyHash: patch.keyHash } : {}),
386
- ...(patch.keyMode !== undefined ? { keyMode: patch.keyMode } : {}),
387
- ...(patch.keyEncrypted !== undefined ? { keyEncrypted: patch.keyEncrypted } : {}),
388
- ...(patch.keyPrefix !== undefined ? { keyPrefix: patch.keyPrefix } : {}),
389
- ...(patch.notesTitle !== undefined ? { notesTitle: patch.notesTitle } : {}),
390
- ...(patch.notesSubtitle !== undefined ? { notesSubtitle: patch.notesSubtitle } : {}),
391
- ...(patch.notesBody !== undefined ? { notesBody: patch.notesBody } : {}),
392
- ...(patch.requestsPerMinute !== undefined
393
- ? { requestsPerMinute: patch.requestsPerMinute }
394
- : {}),
395
- ...(patch.dailyTokenLimit !== undefined ? { dailyTokenLimit: patch.dailyTokenLimit } : {}),
396
- ...(patch.monthlyTokenLimit !== undefined
397
- ? { monthlyTokenLimit: patch.monthlyTokenLimit }
398
- : {}),
399
- ...(patch.lifetimeTokenBudget !== undefined
400
- ? { lifetimeTokenBudget: patch.lifetimeTokenBudget }
401
- : {}),
402
- ...(patch.maxConcurrentRequests !== undefined
403
- ? { maxConcurrentRequests: patch.maxConcurrentRequests }
404
- : {}),
405
- ...(patch.modelPrefix !== undefined ? { modelPrefix: patch.modelPrefix } : {}),
406
- ...(patch.modelAccessMode !== undefined ? { modelAccessMode: patch.modelAccessMode } : {}),
407
- ...(patch.modelList !== undefined ? { modelList: patch.modelList } : {}),
408
- ...(patch.clientRouterDenylist !== undefined
409
- ? { clientRouterDenylist: patch.clientRouterDenylist }
410
- : {}),
411
- // Scopes are validated once, in `validateApiKeyRequest`, against the
412
- // tenant scope set; the store writes what it is handed. Omitting the
413
- // field here means an update is type-checked, accepted, and then
414
- // silently dropped — a grant like `routing:cli_mapping` can be
415
- // turned on and never stick.
416
- ...(patch.scopes === undefined ? {} : { scopes: patch.scopes }),
417
- })
418
- .where(and(eq(apiKeys.tenantId, tenantId), eq(apiKeys.id, keyId)))
419
- .returning();
420
- if (!rows[0]) return undefined;
421
- if (popupPatchTouched(patch)) await mergeSharePopup(tx, keyId, patch);
422
- return (await this.withPopups(tx, rows))[0];
423
- });
424
- }
425
- async revoke(tenantId: string, keyId: string, revokedAt: Date): Promise<boolean> {
426
- return this.db.transaction(async (tx) => {
427
- const rows = await tx
428
- .update(apiKeys)
429
- .set({ revokedAt })
430
- .where(
431
- and(
432
- eq(apiKeys.tenantId, tenantId),
433
- eq(apiKeys.id, keyId),
434
- isNull(apiKeys.revokedAt),
435
- ),
436
- )
437
- .returning({ id: apiKeys.id, keyMode: apiKeys.keyMode, parentKeyId: apiKeys.parentKeyId });
438
- const row = rows[0];
439
- if (!row) return false;
440
- if (row.keyMode === "share" && row.parentKeyId === null) {
441
- await tx
442
- .update(apiKeys)
443
- .set({ revokedAt })
444
- .where(and(eq(apiKeys.parentKeyId, keyId), isNull(apiKeys.revokedAt)));
445
- await tx
446
- .update(shareLinks)
447
- .set({ active: false })
448
- .where(and(eq(shareLinks.apiKeyId, keyId), eq(shareLinks.active, true)));
449
- }
450
- return true;
451
- });
452
- }
453
- async remove(tenantId: string, keyId: string): Promise<boolean> {
454
- return this.db.transaction(async (tx) => {
455
- const rows = await tx
456
- .delete(apiKeys)
457
- .where(
458
- and(
459
- eq(apiKeys.tenantId, tenantId),
460
- eq(apiKeys.id, keyId),
461
- eq(apiKeys.isDefault, false),
462
- ),
463
- )
464
- .returning({ id: apiKeys.id });
465
- return rows.length > 0;
466
- });
467
- }
468
- async findActiveByHash(hash: string): Promise<typeof apiKeys.$inferSelect | undefined> {
469
- // The SQLite backend holds this lookup prepared. The builder path rebuilds
470
- // the query per call, which measured 60x the lookup itself, so the hot
471
- // path skips it. PostgreSQL has no prepared statement here and uses the
472
- // builder, whose plan is cached server-side.
473
- const prepared = getDbHandle().auth;
474
- if (prepared) return this.authRow(prepared.byHash.get(hash));
475
- const rows = await this.db
476
- .select()
477
- .from(apiKeys)
478
- .where(and(eq(apiKeys.keyHash, hash), eq(apiKeys.enabled, true), isNull(apiKeys.revokedAt)))
479
- .limit(1);
480
- return rows[0];
481
- }
482
-
483
- async findActiveById(keyId: string): Promise<typeof apiKeys.$inferSelect | undefined> {
484
- const prepared = getDbHandle().auth;
485
- if (prepared) return this.authRow(prepared.byId.get(keyId));
486
- const rows = await this.db
487
- .select()
488
- .from(apiKeys)
489
- .where(and(eq(apiKeys.id, keyId), eq(apiKeys.enabled, true), isNull(apiKeys.revokedAt)))
490
- .limit(1);
491
- return rows[0];
492
- }
493
-
494
- /**
495
- * Maps a raw SQLite auth row to the shape drizzle returns.
496
- *
497
- * The prepared statement selects the table's own column names, which are
498
- * snake_case, while every reader of this row — `resolveApiKeyAuthorization`
499
- * included — reads the camelCase `$inferSelect` fields. Copying the raw
500
- * keys and casting would typecheck and then hand auth `undefined` for
501
- * `parentKeyId`, `tenantId`, and every limit. SQLite also stores JSON as
502
- * text and booleans as integers, so those are converted here too. The SQL
503
- * stays a plain select.
504
- */
505
- private authRow(raw: Record<string, unknown> | null): typeof apiKeys.$inferSelect | undefined {
506
- if (raw === null) return undefined;
507
- const text = (key: string): string | null => {
508
- const value = raw[key];
509
- return typeof value === "string" ? value : null;
510
- };
511
- const date = (key: string): Date | null => {
512
- const value = text(key);
513
- return value === null ? null : new Date(value);
514
- };
515
- const integer = (key: string): number | null => {
516
- const value = raw[key];
517
- return typeof value === "number" ? value : null;
518
- };
519
- const json = (key: string): unknown => {
520
- const value = raw[key];
521
- return typeof value === "string" ? JSON.parse(value) : (value ?? null);
522
- };
523
- const flag = (key: string): boolean => raw[key] === 1 || raw[key] === true;
524
- return {
525
- id: text("id") ?? "",
526
- tenantId: text("tenant_id") ?? "",
527
- keyHash: text("key_hash"),
528
- keyMode: (text("key_mode") ?? "personal") as ApiKeyMode,
529
- parentKeyId: text("parent_key_id"),
530
- issuedClientIp: text("issued_client_ip"),
531
- issuedClientIpKey: text("issued_client_ip_key"),
532
- label: text("label") ?? "",
533
- scopes: json("scopes"),
534
- requestsPerMinute: integer("requests_per_minute"),
535
- dailyTokenLimit: integer("daily_token_limit"),
536
- monthlyTokenLimit: integer("monthly_token_limit"),
537
- lifetimeTokenBudget: integer("lifetime_token_budget"),
538
- lifetimeTokensConsumed: integer("lifetime_tokens_consumed") ?? 0,
539
- maxConcurrentRequests: integer("max_concurrent_requests"),
540
- modelAccessMode: (text("model_access_mode") ?? "whitelist") as ApiKeyModelAccessMode,
541
- modelList: json("model_list"),
542
- clientRouterDenylist: json("client_router_denylist"),
543
- modelPrefix: text("model_prefix"),
544
- createdAt: date("created_at") ?? new Date(0),
545
- enabled: flag("enabled"),
546
- revokedAt: date("revoked_at"),
547
- keyPrefix: text("key_prefix"),
548
- keyEncrypted: raw["key_encrypted"] ?? null,
549
- notesTitle: text("notes_title"),
550
- notesSubtitle: text("notes_subtitle"),
551
- notesBody: text("notes_body"),
552
- isDefault: flag("is_default"),
553
- sortIndex: integer("sort_index") ?? 0,
554
- } as unknown as typeof apiKeys.$inferSelect;
555
- }
556
-
557
- /**
558
- * Total lifetime consumption across one key's children, revoked included.
559
- *
560
- * The parent's own counter never moves for child traffic — attribution
561
- * lands on the child row — so quota enforcement reads the parent plus this
562
- * sum rather than the parent alone.
563
- *
564
- * Revoked children stay in the sum on purpose. Tokens a recipient already
565
- * spent are spent; dropping them on revoke would refund the parent's budget
566
- * the moment the Redis lifetime counter is re-seeded from this total (which
567
- * happens on every purge, and whenever that counter's TTL lapses), letting a
568
- * recipient burn the whole allowance, be revoked, and hand the next
569
- * recipient a fresh one. It also keeps this figure equal to the "Total
570
- * quota" the share dialog reports, which counts every recipient.
571
- */
572
- async sumChildrenConsumed(parentKeyId: string): Promise<number> {
573
- const prepared = getDbHandle().auth;
574
- if (prepared) return Number(prepared.childrenConsumed.get(parentKeyId)?.total ?? 0);
575
- const rows = await this.db
576
- .select({ total: sql<number>`coalesce(sum(${apiKeys.lifetimeTokensConsumed}), 0)` })
577
- .from(apiKeys)
578
- .where(eq(apiKeys.parentKeyId, parentKeyId));
579
- return Number(rows[0]?.total ?? 0);
580
- }
581
- }
1
+ // Persisted API-key store: the console API-key domain and gateway authentication both consume this boundary.
2
+
3
+ import { and, eq, inArray, isNull, sql } from "drizzle-orm";
4
+ import { nextSortIndex, reorderSortIndex, type SortOrderDb } from "../sort-order";
5
+ import { getDbHandle } from "../boot";
6
+ import type { CartethyiaDatabase } from "../db-handle";
7
+ import { lockRows } from "../dialect";
8
+ import { apiKeys, shareLinks, sharePopups, type ApiKeyMode, type ApiKeyModelAccessMode } from "../schema";
9
+ import type { AccessScope } from "../../security/access-control";
10
+
11
+ /** Persisted API key. Only its one-way hash authenticates; plaintext is never stored. */
12
+ export interface ApiKeyRecord {
13
+ readonly id: string;
14
+ readonly tenantId: string;
15
+ readonly keyHash: string | null;
16
+ readonly keyMode: ApiKeyMode;
17
+ readonly parentKeyId?: string;
18
+ readonly issuedClientIp?: string;
19
+ readonly issuedClientIpKey?: string;
20
+ readonly label: string;
21
+ readonly enabled: boolean;
22
+ readonly scopes: readonly AccessScope[];
23
+ /** The tenant's mandatory gateway key: rotatable, never revocable. */
24
+ readonly isDefault: boolean;
25
+ /** Non-secret prefix configured on a key or used to identify a share child. */
26
+ readonly keyPrefix?: string;
27
+ /** Encrypted copy exists only for personal keys used by CLI handoff. */
28
+ readonly keyEncrypted?: Buffer;
29
+ readonly notesTitle?: string;
30
+ readonly notesSubtitle?: string;
31
+ readonly notesBody?: string;
32
+ readonly sharePopupEnabled?: boolean;
33
+ /** Uploaded popup art; served from the share page rather than a remote URL. */
34
+ readonly sharePopupImage?: Buffer;
35
+ readonly sharePopupImageMime?: string;
36
+ readonly sharePopupTitle?: string;
37
+ readonly sharePopupBody?: string;
38
+ readonly requestsPerMinute?: number;
39
+ readonly dailyTokenLimit?: number;
40
+ readonly monthlyTokenLimit?: number;
41
+ readonly lifetimeTokenBudget?: number;
42
+ readonly maxConcurrentRequests?: number;
43
+ readonly modelPrefix?: string;
44
+ readonly modelAccessMode?: ApiKeyModelAccessMode;
45
+ readonly modelList?: readonly string[];
46
+ /** Client-router ids this key refuses; see the schema column for the contract. */
47
+ readonly clientRouterDenylist?: readonly string[];
48
+ readonly createdAt: Date;
49
+ readonly revokedAt?: Date;
50
+ readonly tokensConsumed: number;
51
+ }
52
+
53
+ /**
54
+ * Partial key update. Nullable policy fields clear their values; `undefined`
55
+ * leaves them unchanged. Credential metadata is writable only for a mode
56
+ * transition performed by the API-key domain.
57
+ */
58
+ export interface ApiKeyPatch
59
+ extends Omit<
60
+ Partial<ApiKeyRecord>,
61
+ | "id"
62
+ | "tenantId"
63
+ | "keyHash"
64
+ | "parentKeyId"
65
+ | "issuedClientIp"
66
+ | "issuedClientIpKey"
67
+ | "createdAt"
68
+ | "revokedAt"
69
+ | "tokensConsumed"
70
+ | "keyEncrypted"
71
+ | "keyPrefix"
72
+ | "notesTitle"
73
+ | "notesSubtitle"
74
+ | "notesBody"
75
+ | "sharePopupEnabled"
76
+ | "sharePopupImage"
77
+ | "sharePopupImageMime"
78
+ | "sharePopupTitle"
79
+ | "sharePopupBody"
80
+ | "requestsPerMinute"
81
+ | "dailyTokenLimit"
82
+ | "monthlyTokenLimit"
83
+ | "lifetimeTokenBudget"
84
+ | "maxConcurrentRequests"
85
+ > {
86
+ readonly keyHash?: string | null;
87
+ readonly keyEncrypted?: Buffer | null;
88
+ readonly keyPrefix?: string | null;
89
+ readonly notesTitle?: string | null;
90
+ readonly notesSubtitle?: string | null;
91
+ readonly notesBody?: string | null;
92
+ readonly sharePopupEnabled?: boolean | null;
93
+ /** Null clears both bytes and mime; setting one without the other is rejected upstream. */
94
+ readonly sharePopupImage?: Buffer | null;
95
+ readonly sharePopupImageMime?: string | null;
96
+ readonly sharePopupTitle?: string | null;
97
+ readonly sharePopupBody?: string | null;
98
+ readonly requestsPerMinute?: number | null;
99
+ readonly dailyTokenLimit?: number | null;
100
+ readonly monthlyTokenLimit?: number | null;
101
+ readonly lifetimeTokenBudget?: number | null;
102
+ readonly maxConcurrentRequests?: number | null;
103
+ }
104
+
105
+ interface PopupFields {
106
+ readonly sharePopupEnabled?: boolean | null;
107
+ readonly sharePopupImage?: Buffer | null;
108
+ readonly sharePopupImageMime?: string | null;
109
+ readonly sharePopupTitle?: string | null;
110
+ readonly sharePopupBody?: string | null;
111
+ }
112
+
113
+ function popupPatchTouched(patch: PopupFields): boolean {
114
+ return (
115
+ patch.sharePopupEnabled !== undefined ||
116
+ patch.sharePopupImage !== undefined ||
117
+ patch.sharePopupImageMime !== undefined ||
118
+ patch.sharePopupTitle !== undefined ||
119
+ patch.sharePopupBody !== undefined
120
+ );
121
+ }
122
+
123
+ /** Merges a partial popup patch onto the stored row, so an omitted field is kept. */
124
+ async function mergeSharePopup(
125
+ db: CartethyiaDatabase,
126
+ apiKeyId: string,
127
+ patch: PopupFields,
128
+ ): Promise<void> {
129
+ const existing = await db
130
+ .select()
131
+ .from(sharePopups)
132
+ .where(eq(sharePopups.apiKeyId, apiKeyId))
133
+ .limit(1);
134
+ const row = existing[0];
135
+ await writeSharePopup(db, apiKeyId, {
136
+ sharePopupEnabled: patch.sharePopupEnabled !== undefined ? patch.sharePopupEnabled : (row?.enabled ?? false),
137
+ sharePopupImage: patch.sharePopupImage !== undefined ? patch.sharePopupImage : (row?.image ?? null),
138
+ sharePopupImageMime: patch.sharePopupImageMime !== undefined ? patch.sharePopupImageMime : (row?.imageMime ?? null),
139
+ sharePopupTitle: patch.sharePopupTitle !== undefined ? patch.sharePopupTitle : (row?.title ?? null),
140
+ sharePopupBody: patch.sharePopupBody !== undefined ? patch.sharePopupBody : (row?.body ?? null),
141
+ });
142
+ }
143
+
144
+ /** Writes the popup row, or removes it when the owner cleared every field. */
145
+ async function writeSharePopup(
146
+ db: CartethyiaDatabase,
147
+ apiKeyId: string,
148
+ fields: PopupFields,
149
+ ): Promise<void> {
150
+ const enabled = fields.sharePopupEnabled ?? false;
151
+ const image = fields.sharePopupImage ?? null;
152
+ const title = fields.sharePopupTitle ?? null;
153
+ const body = fields.sharePopupBody ?? null;
154
+ if (!enabled && image === null && title === null && body === null) {
155
+ await db.delete(sharePopups).where(eq(sharePopups.apiKeyId, apiKeyId));
156
+ return;
157
+ }
158
+ await db
159
+ .insert(sharePopups)
160
+ .values({
161
+ apiKeyId,
162
+ enabled,
163
+ image,
164
+ imageMime: fields.sharePopupImageMime ?? null,
165
+ title,
166
+ body,
167
+ })
168
+ .onConflictDoUpdate({
169
+ target: sharePopups.apiKeyId,
170
+ set: {
171
+ enabled,
172
+ image,
173
+ imageMime: fields.sharePopupImageMime ?? null,
174
+ title,
175
+ body,
176
+ },
177
+ });
178
+ }
179
+
180
+ /** Explicit persistence boundary. Production must provide this; there is no fallback store. */
181
+ export interface ApiKeyStore {
182
+ list(tenantId: string): Promise<readonly ApiKeyRecord[]>;
183
+ get(tenantId: string, keyId: string): Promise<ApiKeyRecord | undefined>;
184
+ listChildren(tenantId: string, parentKeyId: string): Promise<readonly ApiKeyRecord[]>;
185
+ create(record: ApiKeyRecord): Promise<void>;
186
+ /** Rewrites list order for one tenant; `keyIds` is the full desired order. */
187
+ reorder(tenantId: string, keyIds: readonly string[]): Promise<void>;
188
+ update(tenantId: string, keyId: string, patch: ApiKeyPatch): Promise<ApiKeyRecord | undefined>;
189
+ revoke(tenantId: string, keyId: string, revokedAt: Date): Promise<boolean>;
190
+ remove(tenantId: string, keyId: string): Promise<boolean>;
191
+ findActiveByHash?(hash: string): Promise<typeof apiKeys.$inferSelect | undefined>;
192
+ findActiveById?(keyId: string): Promise<typeof apiKeys.$inferSelect | undefined>;
193
+ sumChildrenConsumed?(parentKeyId: string): Promise<number>;
194
+ }
195
+
196
+ export class DrizzleApiKeyStore implements ApiKeyStore {
197
+ constructor(private readonly db: CartethyiaDatabase) {}
198
+
199
+ /** Attaches the popup, which lives in its own table so the auth read never loads it. */
200
+ private async withPopups(
201
+ db: SortOrderDb | CartethyiaDatabase,
202
+ rows: readonly (typeof apiKeys.$inferSelect)[],
203
+ ): Promise<ApiKeyRecord[]> {
204
+ if (rows.length === 0) return [];
205
+ const popups = await db
206
+ .select()
207
+ .from(sharePopups)
208
+ .where(inArray(sharePopups.apiKeyId, rows.map((row) => row.id)));
209
+ const byKey = new Map(popups.map((popup) => [popup.apiKeyId, popup]));
210
+ return rows.map((row) => this.map(row, byKey.get(row.id)));
211
+ }
212
+
213
+ private map(
214
+ row: typeof apiKeys.$inferSelect,
215
+ popup?: typeof sharePopups.$inferSelect,
216
+ ): ApiKeyRecord {
217
+ return {
218
+ id: row.id,
219
+ tenantId: row.tenantId,
220
+ keyHash: row.keyHash,
221
+ keyMode: row.keyMode,
222
+ isDefault: row.isDefault,
223
+ ...(row.parentKeyId === null ? {} : { parentKeyId: row.parentKeyId }),
224
+ ...(row.issuedClientIp === null ? {} : { issuedClientIp: row.issuedClientIp }),
225
+ ...(row.issuedClientIpKey === null
226
+ ? {}
227
+ : { issuedClientIpKey: row.issuedClientIpKey }),
228
+ label: row.label,
229
+ enabled: row.enabled,
230
+ scopes: row.scopes as ApiKeyRecord["scopes"],
231
+ ...(row.keyPrefix === null ? {} : { keyPrefix: row.keyPrefix }),
232
+ ...(row.keyEncrypted === null ? {} : { keyEncrypted: row.keyEncrypted }),
233
+ ...(row.notesTitle === null ? {} : { notesTitle: row.notesTitle }),
234
+ ...(row.notesSubtitle === null ? {} : { notesSubtitle: row.notesSubtitle }),
235
+ ...(row.notesBody === null ? {} : { notesBody: row.notesBody }),
236
+ ...(popup?.enabled ? { sharePopupEnabled: popup.enabled } : {}),
237
+ ...(popup?.image == null ? {} : { sharePopupImage: popup.image }),
238
+ ...(popup?.imageMime == null ? {} : { sharePopupImageMime: popup.imageMime }),
239
+ ...(popup?.title == null ? {} : { sharePopupTitle: popup.title }),
240
+ ...(popup?.body == null ? {} : { sharePopupBody: popup.body }),
241
+ ...(row.requestsPerMinute === null ? {} : { requestsPerMinute: row.requestsPerMinute }),
242
+ ...(row.dailyTokenLimit === null ? {} : { dailyTokenLimit: row.dailyTokenLimit }),
243
+ ...(row.monthlyTokenLimit === null ? {} : { monthlyTokenLimit: row.monthlyTokenLimit }),
244
+ ...(row.lifetimeTokenBudget === null ? {} : { lifetimeTokenBudget: row.lifetimeTokenBudget }),
245
+ ...(row.maxConcurrentRequests === null
246
+ ? {}
247
+ : { maxConcurrentRequests: row.maxConcurrentRequests }),
248
+ ...(row.modelPrefix === null ? {} : { modelPrefix: row.modelPrefix }),
249
+ modelAccessMode: row.modelAccessMode,
250
+ ...(row.modelList === null ? {} : { modelList: row.modelList as string[] }),
251
+ ...(row.clientRouterDenylist === null
252
+ ? {}
253
+ : { clientRouterDenylist: row.clientRouterDenylist as string[] }),
254
+ createdAt: row.createdAt,
255
+ ...(row.revokedAt ? { revokedAt: row.revokedAt } : {}),
256
+ tokensConsumed: row.lifetimeTokensConsumed,
257
+ };
258
+ }
259
+ async list(tenantId: string): Promise<readonly ApiKeyRecord[]> {
260
+ // Bounded: a large tenant key list must not load fully into memory.
261
+ // Ordered by the explicit `sort_index`, not `created_at`: a stable key means
262
+ // rows keep their position across reloads (and can be reordered) instead of
263
+ // shifting when two rows share a timestamp or one is updated.
264
+ const rows = await this.db
265
+ .select()
266
+ .from(apiKeys)
267
+ .where(eq(apiKeys.tenantId, tenantId))
268
+ .orderBy(apiKeys.sortIndex, apiKeys.createdAt, apiKeys.id)
269
+ .limit(1000);
270
+ return this.withPopups(this.db, rows);
271
+ }
272
+
273
+ /** Rewrites the list order for one tenant; `keyIds` is the full order. */
274
+ async reorder(tenantId: string, keyIds: readonly string[]): Promise<void> {
275
+ await reorderSortIndex(
276
+ this.db,
277
+ { table: apiKeys, idColumn: apiKeys.id, sortIndexColumn: apiKeys.sortIndex },
278
+ eq(apiKeys.tenantId, tenantId),
279
+ keyIds,
280
+ );
281
+ }
282
+
283
+ /** Next free list position for a new key in this tenant. */
284
+ private async nextSortIndex(tenantId: string): Promise<number> {
285
+ return nextSortIndex(
286
+ this.db,
287
+ { table: apiKeys, idColumn: apiKeys.id, sortIndexColumn: apiKeys.sortIndex },
288
+ eq(apiKeys.tenantId, tenantId),
289
+ );
290
+ }
291
+ async get(tenantId: string, keyId: string): Promise<ApiKeyRecord | undefined> {
292
+ const rows = await this.db
293
+ .select()
294
+ .from(apiKeys)
295
+ .where(and(eq(apiKeys.tenantId, tenantId), eq(apiKeys.id, keyId)))
296
+ .limit(1);
297
+ return rows[0] ? (await this.withPopups(this.db, rows))[0] : undefined;
298
+ }
299
+ async listChildren(tenantId: string, parentKeyId: string): Promise<readonly ApiKeyRecord[]> {
300
+ const rows = await this.db
301
+ .select()
302
+ .from(apiKeys)
303
+ .where(
304
+ and(
305
+ eq(apiKeys.tenantId, tenantId),
306
+ eq(apiKeys.parentKeyId, parentKeyId),
307
+ ),
308
+ )
309
+ .orderBy(apiKeys.createdAt, apiKeys.id);
310
+ return this.withPopups(this.db, rows);
311
+ }
312
+ async create(record: ApiKeyRecord): Promise<void> {
313
+ // Append to the end of the tenant's list rather than relying on creation
314
+ // time, so a new key never displaces the existing rows' positions.
315
+ const sortIndex = await this.nextSortIndex(record.tenantId);
316
+ await this.db.insert(apiKeys).values({
317
+ id: record.id,
318
+ tenantId: record.tenantId,
319
+ sortIndex,
320
+ keyHash: record.keyHash,
321
+ keyMode: record.keyMode,
322
+ parentKeyId: record.parentKeyId ?? null,
323
+ issuedClientIp: record.issuedClientIp ?? null,
324
+ issuedClientIpKey: record.issuedClientIpKey ?? null,
325
+ label: record.label,
326
+ scopes: record.scopes,
327
+ enabled: record.enabled,
328
+ keyPrefix: record.keyPrefix ?? null,
329
+ keyEncrypted: record.keyEncrypted ?? null,
330
+ notesTitle: record.notesTitle ?? null,
331
+ notesSubtitle: record.notesSubtitle ?? null,
332
+ notesBody: record.notesBody ?? null,
333
+ requestsPerMinute: record.requestsPerMinute ?? null,
334
+ dailyTokenLimit: record.dailyTokenLimit ?? null,
335
+ monthlyTokenLimit: record.monthlyTokenLimit ?? null,
336
+ lifetimeTokenBudget: record.lifetimeTokenBudget ?? null,
337
+ maxConcurrentRequests: record.maxConcurrentRequests ?? null,
338
+ modelPrefix: record.modelPrefix ?? null,
339
+ modelAccessMode: record.modelAccessMode ?? "whitelist",
340
+ modelList: record.modelList ?? null,
341
+ clientRouterDenylist: record.clientRouterDenylist ?? null,
342
+ lifetimeTokensConsumed: record.tokensConsumed,
343
+ });
344
+ await writeSharePopup(this.db, record.id, record);
345
+ }
346
+ async update(
347
+ tenantId: string,
348
+ keyId: string,
349
+ patch: ApiKeyPatch,
350
+ ): Promise<ApiKeyRecord | undefined> {
351
+ return this.db.transaction(async (tx) => {
352
+ const existingRows = await lockRows(
353
+ tx
354
+ .select({
355
+ id: apiKeys.id,
356
+ keyMode: apiKeys.keyMode,
357
+ parentKeyId: apiKeys.parentKeyId,
358
+ })
359
+ .from(apiKeys)
360
+ .where(and(eq(apiKeys.tenantId, tenantId), eq(apiKeys.id, keyId)))
361
+ .limit(1),
362
+ );
363
+ const existing = existingRows[0];
364
+ if (!existing) return undefined;
365
+ if (existing.parentKeyId !== null && patch.keyMode !== undefined && patch.keyMode !== existing.keyMode)
366
+ return undefined;
367
+
368
+ if (existing.keyMode === "share" && patch.keyMode === "personal") {
369
+ await tx
370
+ .update(apiKeys)
371
+ .set({ revokedAt: new Date() })
372
+ .where(and(eq(apiKeys.parentKeyId, keyId), isNull(apiKeys.revokedAt)));
373
+ await tx
374
+ .update(shareLinks)
375
+ .set({ active: false })
376
+ .where(and(eq(shareLinks.apiKeyId, keyId), eq(shareLinks.active, true)));
377
+ }
378
+
379
+ const rows = await tx
380
+ .update(apiKeys)
381
+ .set({
382
+ ...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}),
383
+ ...(patch.label !== undefined ? { label: patch.label } : {}),
384
+ ...(patch.isDefault !== undefined ? { isDefault: patch.isDefault } : {}),
385
+ ...(patch.keyHash !== undefined ? { keyHash: patch.keyHash } : {}),
386
+ ...(patch.keyMode !== undefined ? { keyMode: patch.keyMode } : {}),
387
+ ...(patch.keyEncrypted !== undefined ? { keyEncrypted: patch.keyEncrypted } : {}),
388
+ ...(patch.keyPrefix !== undefined ? { keyPrefix: patch.keyPrefix } : {}),
389
+ ...(patch.notesTitle !== undefined ? { notesTitle: patch.notesTitle } : {}),
390
+ ...(patch.notesSubtitle !== undefined ? { notesSubtitle: patch.notesSubtitle } : {}),
391
+ ...(patch.notesBody !== undefined ? { notesBody: patch.notesBody } : {}),
392
+ ...(patch.requestsPerMinute !== undefined
393
+ ? { requestsPerMinute: patch.requestsPerMinute }
394
+ : {}),
395
+ ...(patch.dailyTokenLimit !== undefined ? { dailyTokenLimit: patch.dailyTokenLimit } : {}),
396
+ ...(patch.monthlyTokenLimit !== undefined
397
+ ? { monthlyTokenLimit: patch.monthlyTokenLimit }
398
+ : {}),
399
+ ...(patch.lifetimeTokenBudget !== undefined
400
+ ? { lifetimeTokenBudget: patch.lifetimeTokenBudget }
401
+ : {}),
402
+ ...(patch.maxConcurrentRequests !== undefined
403
+ ? { maxConcurrentRequests: patch.maxConcurrentRequests }
404
+ : {}),
405
+ ...(patch.modelPrefix !== undefined ? { modelPrefix: patch.modelPrefix } : {}),
406
+ ...(patch.modelAccessMode !== undefined ? { modelAccessMode: patch.modelAccessMode } : {}),
407
+ ...(patch.modelList !== undefined ? { modelList: patch.modelList } : {}),
408
+ ...(patch.clientRouterDenylist !== undefined
409
+ ? { clientRouterDenylist: patch.clientRouterDenylist }
410
+ : {}),
411
+ // Scopes are validated once, in `validateApiKeyRequest`, against the
412
+ // tenant scope set; the store writes what it is handed. Omitting the
413
+ // field here means an update is type-checked, accepted, and then
414
+ // silently dropped — a grant like `routing:cli_mapping` can be
415
+ // turned on and never stick.
416
+ ...(patch.scopes === undefined ? {} : { scopes: patch.scopes }),
417
+ })
418
+ .where(and(eq(apiKeys.tenantId, tenantId), eq(apiKeys.id, keyId)))
419
+ .returning();
420
+ if (!rows[0]) return undefined;
421
+ if (popupPatchTouched(patch)) await mergeSharePopup(tx, keyId, patch);
422
+ return (await this.withPopups(tx, rows))[0];
423
+ });
424
+ }
425
+ async revoke(tenantId: string, keyId: string, revokedAt: Date): Promise<boolean> {
426
+ return this.db.transaction(async (tx) => {
427
+ const rows = await tx
428
+ .update(apiKeys)
429
+ .set({ revokedAt })
430
+ .where(
431
+ and(
432
+ eq(apiKeys.tenantId, tenantId),
433
+ eq(apiKeys.id, keyId),
434
+ isNull(apiKeys.revokedAt),
435
+ ),
436
+ )
437
+ .returning({ id: apiKeys.id, keyMode: apiKeys.keyMode, parentKeyId: apiKeys.parentKeyId });
438
+ const row = rows[0];
439
+ if (!row) return false;
440
+ if (row.keyMode === "share" && row.parentKeyId === null) {
441
+ await tx
442
+ .update(apiKeys)
443
+ .set({ revokedAt })
444
+ .where(and(eq(apiKeys.parentKeyId, keyId), isNull(apiKeys.revokedAt)));
445
+ await tx
446
+ .update(shareLinks)
447
+ .set({ active: false })
448
+ .where(and(eq(shareLinks.apiKeyId, keyId), eq(shareLinks.active, true)));
449
+ }
450
+ return true;
451
+ });
452
+ }
453
+ async remove(tenantId: string, keyId: string): Promise<boolean> {
454
+ return this.db.transaction(async (tx) => {
455
+ const rows = await tx
456
+ .delete(apiKeys)
457
+ .where(
458
+ and(
459
+ eq(apiKeys.tenantId, tenantId),
460
+ eq(apiKeys.id, keyId),
461
+ eq(apiKeys.isDefault, false),
462
+ ),
463
+ )
464
+ .returning({ id: apiKeys.id });
465
+ return rows.length > 0;
466
+ });
467
+ }
468
+ async findActiveByHash(hash: string): Promise<typeof apiKeys.$inferSelect | undefined> {
469
+ // The SQLite backend holds this lookup prepared. The builder path rebuilds
470
+ // the query per call, which measured 60x the lookup itself, so the hot
471
+ // path skips it. PostgreSQL has no prepared statement here and uses the
472
+ // builder, whose plan is cached server-side.
473
+ const prepared = getDbHandle().auth;
474
+ if (prepared) return this.authRow(prepared.byHash.get(hash));
475
+ const rows = await this.db
476
+ .select()
477
+ .from(apiKeys)
478
+ .where(and(eq(apiKeys.keyHash, hash), eq(apiKeys.enabled, true), isNull(apiKeys.revokedAt)))
479
+ .limit(1);
480
+ return rows[0];
481
+ }
482
+
483
+ async findActiveById(keyId: string): Promise<typeof apiKeys.$inferSelect | undefined> {
484
+ const prepared = getDbHandle().auth;
485
+ if (prepared) return this.authRow(prepared.byId.get(keyId));
486
+ const rows = await this.db
487
+ .select()
488
+ .from(apiKeys)
489
+ .where(and(eq(apiKeys.id, keyId), eq(apiKeys.enabled, true), isNull(apiKeys.revokedAt)))
490
+ .limit(1);
491
+ return rows[0];
492
+ }
493
+
494
+ /**
495
+ * Maps a raw SQLite auth row to the shape drizzle returns.
496
+ *
497
+ * The prepared statement selects the table's own column names, which are
498
+ * snake_case, while every reader of this row — `resolveApiKeyAuthorization`
499
+ * included — reads the camelCase `$inferSelect` fields. Copying the raw
500
+ * keys and casting would typecheck and then hand auth `undefined` for
501
+ * `parentKeyId`, `tenantId`, and every limit. SQLite also stores JSON as
502
+ * text and booleans as integers, so those are converted here too. The SQL
503
+ * stays a plain select.
504
+ */
505
+ private authRow(raw: Record<string, unknown> | null): typeof apiKeys.$inferSelect | undefined {
506
+ if (raw === null) return undefined;
507
+ const text = (key: string): string | null => {
508
+ const value = raw[key];
509
+ return typeof value === "string" ? value : null;
510
+ };
511
+ const date = (key: string): Date | null => {
512
+ const value = text(key);
513
+ return value === null ? null : new Date(value);
514
+ };
515
+ const integer = (key: string): number | null => {
516
+ const value = raw[key];
517
+ return typeof value === "number" ? value : null;
518
+ };
519
+ const json = (key: string): unknown => {
520
+ const value = raw[key];
521
+ return typeof value === "string" ? JSON.parse(value) : (value ?? null);
522
+ };
523
+ const flag = (key: string): boolean => raw[key] === 1 || raw[key] === true;
524
+ return {
525
+ id: text("id") ?? "",
526
+ tenantId: text("tenant_id") ?? "",
527
+ keyHash: text("key_hash"),
528
+ keyMode: (text("key_mode") ?? "personal") as ApiKeyMode,
529
+ parentKeyId: text("parent_key_id"),
530
+ issuedClientIp: text("issued_client_ip"),
531
+ issuedClientIpKey: text("issued_client_ip_key"),
532
+ label: text("label") ?? "",
533
+ scopes: json("scopes"),
534
+ requestsPerMinute: integer("requests_per_minute"),
535
+ dailyTokenLimit: integer("daily_token_limit"),
536
+ monthlyTokenLimit: integer("monthly_token_limit"),
537
+ lifetimeTokenBudget: integer("lifetime_token_budget"),
538
+ lifetimeTokensConsumed: integer("lifetime_tokens_consumed") ?? 0,
539
+ maxConcurrentRequests: integer("max_concurrent_requests"),
540
+ modelAccessMode: (text("model_access_mode") ?? "whitelist") as ApiKeyModelAccessMode,
541
+ modelList: json("model_list"),
542
+ clientRouterDenylist: json("client_router_denylist"),
543
+ modelPrefix: text("model_prefix"),
544
+ createdAt: date("created_at") ?? new Date(0),
545
+ enabled: flag("enabled"),
546
+ revokedAt: date("revoked_at"),
547
+ keyPrefix: text("key_prefix"),
548
+ keyEncrypted: raw["key_encrypted"] ?? null,
549
+ notesTitle: text("notes_title"),
550
+ notesSubtitle: text("notes_subtitle"),
551
+ notesBody: text("notes_body"),
552
+ isDefault: flag("is_default"),
553
+ sortIndex: integer("sort_index") ?? 0,
554
+ } as unknown as typeof apiKeys.$inferSelect;
555
+ }
556
+
557
+ /**
558
+ * Total lifetime consumption across one key's children, revoked included.
559
+ *
560
+ * The parent's own counter never moves for child traffic — attribution
561
+ * lands on the child row — so quota enforcement reads the parent plus this
562
+ * sum rather than the parent alone.
563
+ *
564
+ * Revoked children stay in the sum on purpose. Tokens a recipient already
565
+ * spent are spent; dropping them on revoke would refund the parent's budget
566
+ * the moment the Redis lifetime counter is re-seeded from this total (which
567
+ * happens on every purge, and whenever that counter's TTL lapses), letting a
568
+ * recipient burn the whole allowance, be revoked, and hand the next
569
+ * recipient a fresh one. It also keeps this figure equal to the "Total
570
+ * quota" the share dialog reports, which counts every recipient.
571
+ */
572
+ async sumChildrenConsumed(parentKeyId: string): Promise<number> {
573
+ const prepared = getDbHandle().auth;
574
+ if (prepared) return Number(prepared.childrenConsumed.get(parentKeyId)?.total ?? 0);
575
+ const rows = await this.db
576
+ .select({ total: sql<number>`coalesce(sum(${apiKeys.lifetimeTokensConsumed}), 0)` })
577
+ .from(apiKeys)
578
+ .where(eq(apiKeys.parentKeyId, parentKeyId));
579
+ return Number(rows[0]?.total ?? 0);
580
+ }
581
+ }