cartethyia 2.5.2 → 2.5.4

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 (277) hide show
  1. package/dist/dashboard/assets/{BannedPage-MsQaeQTs.js → BannedPage-C2orBsI2.js} +1 -1
  2. package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
  3. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +1 -0
  4. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolsPage-DfWHTJ7v.js → CliToolsPage-BV6pxVQo.js} +1 -1
  6. package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
  7. package/dist/dashboard/assets/{CombosPage-BrCBQDAc.js → CombosPage-Bsan4679.js} +1 -1
  8. package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
  9. package/dist/dashboard/assets/{ConsoleLogPage-C6Uh33er.js → ConsoleLogPage-CW-wPmJ6.js} +1 -1
  10. package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
  11. package/dist/dashboard/assets/{CustomizationPage-8aS9hyJu.js → CustomizationPage-0p8uX7vB.js} +1 -1
  12. package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
  13. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js +1 -0
  14. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
  15. package/dist/dashboard/assets/{LoginPage-C8hKyXBe.js → LoginPage-BvzCx8Kr.js} +1 -1
  16. package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
  17. package/dist/dashboard/assets/{ModelPicker-CWkTUtpX.js → ModelPicker-BY1aKEkP.js} +1 -1
  18. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
  19. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +1 -0
  20. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
  21. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +3 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
  23. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js +1 -0
  24. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
  25. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +7 -0
  26. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
  27. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +1 -0
  28. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
  29. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +1 -0
  30. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
  31. package/dist/dashboard/assets/{SetupPage-CXjUlfqr.js → SetupPage-DDbMQsoo.js} +1 -1
  32. package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
  33. package/dist/dashboard/assets/{SortableList-C-_oD3F2.js → SortableList-CB3Z7VTf.js} +1 -1
  34. package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
  35. package/dist/dashboard/assets/{StudioPage-u9IxmYOI.js → StudioPage-D95UCPHI.js} +3 -3
  36. package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
  37. package/dist/dashboard/assets/{UsagePage-BEVLVrwP.js → UsagePage-Cnae9SWA.js} +13 -13
  38. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
  39. package/dist/dashboard/assets/{api-keys-uNywYlfv.js → api-keys-VY68Mhd1.js} +1 -1
  40. package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
  41. package/dist/dashboard/assets/app-COlK95oi.css +2 -0
  42. package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
  43. package/dist/dashboard/assets/{assert-NHwYq95t.js → assert-Chi7pCnt.js} +1 -1
  44. package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
  45. package/dist/dashboard/assets/{badge-6uzDDvqs.js → badge-D4pQapKj.js} +1 -1
  46. package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
  47. package/dist/dashboard/assets/{banner-4_WNg2WZ.js → banner-DH6cg-_B.js} +1 -1
  48. package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
  49. package/dist/dashboard/assets/{card--AtFedAq.js → card-BKqlUOe4.js} +1 -1
  50. package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
  51. package/dist/dashboard/assets/{cli-tools-B3e7dx3N.js → cli-tools-fTgljTGA.js} +1 -1
  52. package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
  53. package/dist/dashboard/assets/{clipboard-button-NreUx6c8.js → clipboard-button-D5Ys_sM0.js} +1 -1
  54. package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
  55. package/dist/dashboard/assets/database-Dz9N3zGd.js +1 -0
  56. package/dist/dashboard/assets/database-Dz9N3zGd.js.gz +0 -0
  57. package/dist/dashboard/assets/{dialog-BtpsJU98.js → dialog-BYj-8WuK.js} +1 -1
  58. package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
  59. package/dist/dashboard/assets/{drawer-WwwtzihF.js → drawer-CmXIZ6H1.js} +1 -1
  60. package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
  61. package/dist/dashboard/assets/entry-CBg5zeu8.js +1 -0
  62. package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
  63. package/dist/dashboard/assets/entry-CQBr6U9c.js +1 -0
  64. package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CgdSAzEV.js +4 -0
  66. package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
  67. package/dist/dashboard/assets/{github-badge-D_eM3IgG.js → github-badge-BlptL8Uf.js} +1 -1
  68. package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
  69. package/dist/dashboard/assets/{index-DCBtROoK.js → index-js6TJvdr.js} +2 -2
  70. package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
  71. package/dist/dashboard/assets/{inline-BcNGF8Gr.js → inline-DUWDSmDV.js} +1 -1
  72. package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
  73. package/dist/dashboard/assets/{input-BG41et9G.js → input-LVjgZq4f.js} +1 -1
  74. package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
  75. package/dist/dashboard/assets/{layout-C8mer_Ak.js → layout-CPfxpsfy.js} +1 -1
  76. package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
  77. package/dist/dashboard/assets/{list-row-Gh_W4OR_.js → list-row-DupvLfDx.js} +1 -1
  78. package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
  79. package/dist/dashboard/assets/{live-BJGYRBnR.js → live-C4pEJUqI.js} +1 -1
  80. package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
  81. package/dist/dashboard/assets/model-bans-BRiuufkB.js +1 -0
  82. package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
  83. package/dist/dashboard/assets/{mutation-BkoLM5Ba.js → mutation-B4ROwk8L.js} +1 -1
  84. package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
  85. package/dist/dashboard/assets/numbers-YNcHvRY7.js +2 -0
  86. package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
  87. package/dist/dashboard/assets/{query-keys-D7wX2vM5.js → query-keys-gmjzC3RS.js} +1 -1
  88. package/dist/dashboard/assets/{query-keys-D7wX2vM5.js.gz → query-keys-gmjzC3RS.js.gz} +0 -0
  89. package/dist/dashboard/assets/{routing-CcusqXA7.js → routing-C7tBT5o-.js} +1 -1
  90. package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
  91. package/dist/dashboard/assets/{select-DsX5QT8K.js → select-WUCXorXw.js} +2 -2
  92. package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
  93. package/dist/dashboard/assets/{settings-mpmXTgUa.js → settings-C_zKl1GS.js} +1 -1
  94. package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
  95. package/dist/dashboard/assets/{stack-DwLmjUFK.js → stack-_3O0F8Xn.js} +1 -1
  96. package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
  97. package/dist/dashboard/assets/{state-DYdQmdAl.js → state-BgTxHcJS.js} +1 -1
  98. package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
  99. package/dist/dashboard/assets/switch-DEjTtOWf.js +1 -0
  100. package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
  101. package/dist/dashboard/assets/terminal-0_IjPWTk.js +1 -0
  102. package/dist/dashboard/assets/terminal-0_IjPWTk.js.gz +0 -0
  103. package/dist/dashboard/assets/{useMutation-w4u4CVue.js → useMutation-BWSC0UAS.js} +1 -1
  104. package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
  105. package/dist/dashboard/assets/zap-C4EeVW4q.js +1 -0
  106. package/dist/dashboard/assets/zap-C4EeVW4q.js.gz +0 -0
  107. package/dist/dashboard/donation/qris-risun-marke.png +0 -0
  108. package/dist/dashboard/index.html +1 -1
  109. package/dist/dashboard/index.html.gz +0 -0
  110. package/dist/dashboard/precompressed-assets.json +54 -54
  111. package/package.json +1 -1
  112. package/scripts/cli/autostart.ts +237 -0
  113. package/scripts/cli/command/console-menu.ts +244 -246
  114. package/scripts/cli/command/tray.ts +208 -204
  115. package/scripts/cli/command/update.ts +27 -10
  116. package/scripts/cli/main.ts +28 -1
  117. package/src/boot/app.ts +693 -693
  118. package/src/boot/dependencies.ts +646 -616
  119. package/src/config/spec.ts +81 -2
  120. package/src/config/telemetry.ts +46 -0
  121. package/src/console/api-keys/bucket-spend.ts +15 -4
  122. package/src/console/backup/contracts.ts +15 -2
  123. package/src/console/backup/routes.ts +52 -1
  124. package/src/console/backup/service.ts +60 -4
  125. package/src/console/console-router.ts +8 -0
  126. package/src/console/data-management/contracts.ts +93 -0
  127. package/src/console/data-management/operations.ts +229 -0
  128. package/src/console/data-management/routes.ts +104 -0
  129. package/src/console/domain-registration.ts +39 -0
  130. package/src/console/observability/contracts.ts +524 -485
  131. package/src/console/observability/store.ts +1115 -1018
  132. package/src/console/providers/catalog/contracts.ts +6 -0
  133. package/src/console/providers/catalog/provider-operations.ts +20 -0
  134. package/src/console/providers/catalog/routes.ts +7 -0
  135. package/src/console/providers/catalog/store.ts +22 -1
  136. package/src/console/quota/account-quota-shared.ts +52 -39
  137. package/src/console/quota/refresh.ts +441 -429
  138. package/src/console/routing/pools/import-parse.ts +206 -0
  139. package/src/console/routing/pools/operations.ts +543 -501
  140. package/src/console/routing/pools/routes.ts +181 -164
  141. package/src/console/settings/contracts.ts +37 -0
  142. package/src/console/settings/store.ts +51 -0
  143. package/src/console/share/share-router.ts +6 -9
  144. package/src/console/updates/routes.ts +16 -0
  145. package/src/main.ts +10 -0
  146. package/src/observability/event-compaction.ts +315 -0
  147. package/src/observability/event-day-index.ts +442 -0
  148. package/src/observability/event-log.ts +152 -3
  149. package/src/observability/payload-capture.ts +373 -344
  150. package/src/observability/payload-store.ts +440 -366
  151. package/src/observability/telemetry-status.ts +28 -16
  152. package/src/persistence/health-event-flush.ts +36 -0
  153. package/src/persistence/schema.ts +1402 -1360
  154. package/src/persistence/stores/api-key-store.ts +6 -0
  155. package/src/persistence/stores/share-store.ts +583 -556
  156. package/src/persistence/stores/telemetry-store.ts +237 -214
  157. package/src/protocol/codecs.ts +1 -2
  158. package/src/protocol/image-clamp.ts +19 -1
  159. package/src/protocol/registry.ts +8 -0
  160. package/src/protocol/request/gemini.ts +9 -2
  161. package/src/protocol/request/messages.ts +14 -2
  162. package/src/protocol/response/gemini.ts +18 -4
  163. package/src/providers/adapters/compatible-adapter.ts +3 -0
  164. package/src/providers/bundled-discovery-providers.ts +122 -119
  165. package/src/providers/configured-openai-providers.ts +136 -134
  166. package/src/providers/default-registry.ts +5 -4
  167. package/src/providers/discovery/openai-model-discovery.ts +5 -0
  168. package/src/providers/discovery/openrouter-free-roster.ts +15 -17
  169. package/src/providers/integrations/antigravity/antigravity-quota.ts +11 -3
  170. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +4 -4
  171. package/src/providers/integrations/buddy/workbuddy/shared.ts +201 -200
  172. package/src/providers/integrations/cline/cline-quota.ts +132 -128
  173. package/src/providers/integrations/codex/codex-headers.ts +25 -39
  174. package/src/providers/integrations/codex/codex.ts +2 -29
  175. package/src/providers/integrations/cursor/cursor-quota.ts +310 -0
  176. package/src/providers/integrations/cursor/cursor-request.ts +616 -471
  177. package/src/providers/integrations/cursor/cursor-session-manager.ts +319 -281
  178. package/src/providers/integrations/cursor/cursor.ts +1485 -960
  179. package/src/providers/integrations/cursor/protocol/messages.ts +25 -0
  180. package/src/providers/integrations/factory/factory.ts +8 -1
  181. package/src/providers/integrations/gemini.ts +6 -1
  182. package/src/providers/integrations/grok/grok-quota.ts +279 -177
  183. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -0
  184. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -0
  185. package/src/providers/integrations/opencode/provider.ts +531 -532
  186. package/src/providers/integrations/qoder/qoder-quota.ts +192 -0
  187. package/src/providers/integrations/tokenharbor.ts +2 -2
  188. package/src/providers/integrations/trae/trae-catalog.ts +5 -3
  189. package/src/providers/integrations/trae/trae-quota.ts +384 -0
  190. package/src/providers/operations/account-health-service.ts +1523 -1457
  191. package/src/providers/operations/client-versions.ts +9 -2
  192. package/src/providers/provider-contracts.ts +7 -0
  193. package/src/providers/provider-metadata.ts +13 -1
  194. package/src/providers/quota/quota-support.ts +8 -0
  195. package/src/security/api-key-auth.ts +504 -494
  196. package/src/transport/dispatch/attempt-finalize.ts +46 -5
  197. package/src/transport/dispatch/generation.ts +6 -0
  198. package/src/transport/dispatch/proxy-request.ts +33 -1
  199. package/src/transport/dispatch/ratelimit-capture.ts +37 -0
  200. package/src/transport/dispatch/streaming/stream.ts +30 -2
  201. package/src/transport/dispatch/upstream.ts +9 -0
  202. package/src/transport/request/preparer.ts +7 -6
  203. package/src/transport/request/state.ts +9 -0
  204. package/src/transport/request/tenant-preferences.ts +60 -0
  205. package/src/transport/routing/route-catalog.ts +854 -799
  206. package/dist/dashboard/assets/BannedPage-MsQaeQTs.js.gz +0 -0
  207. package/dist/dashboard/assets/CliToolDetailPage-BO9nKg9w.js +0 -1
  208. package/dist/dashboard/assets/CliToolDetailPage-BO9nKg9w.js.gz +0 -0
  209. package/dist/dashboard/assets/CliToolsPage-DfWHTJ7v.js.gz +0 -0
  210. package/dist/dashboard/assets/CombosPage-BrCBQDAc.js.gz +0 -0
  211. package/dist/dashboard/assets/ConsoleLogPage-C6Uh33er.js.gz +0 -0
  212. package/dist/dashboard/assets/CustomizationPage-8aS9hyJu.js.gz +0 -0
  213. package/dist/dashboard/assets/HealthEventsModal-qomXThxX.js +0 -1
  214. package/dist/dashboard/assets/HealthEventsModal-qomXThxX.js.gz +0 -0
  215. package/dist/dashboard/assets/LoginPage-C8hKyXBe.js.gz +0 -0
  216. package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
  217. package/dist/dashboard/assets/OverviewPage-C-ExCy1V.js +0 -1
  218. package/dist/dashboard/assets/OverviewPage-C-ExCy1V.js.gz +0 -0
  219. package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js +0 -3
  220. package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js.gz +0 -0
  221. package/dist/dashboard/assets/ProvidersPage-BS9IEpvv.js +0 -1
  222. package/dist/dashboard/assets/ProvidersPage-BS9IEpvv.js.gz +0 -0
  223. package/dist/dashboard/assets/ProxyPage-Rn_qXScb.js +0 -7
  224. package/dist/dashboard/assets/ProxyPage-Rn_qXScb.js.gz +0 -0
  225. package/dist/dashboard/assets/QuotaPage-BihkgQSq.js +0 -1
  226. package/dist/dashboard/assets/QuotaPage-BihkgQSq.js.gz +0 -0
  227. package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js +0 -1
  228. package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js.gz +0 -0
  229. package/dist/dashboard/assets/SetupPage-CXjUlfqr.js.gz +0 -0
  230. package/dist/dashboard/assets/SortableList-C-_oD3F2.js.gz +0 -0
  231. package/dist/dashboard/assets/StudioPage-u9IxmYOI.js.gz +0 -0
  232. package/dist/dashboard/assets/UsagePage-BEVLVrwP.js.gz +0 -0
  233. package/dist/dashboard/assets/api-keys-uNywYlfv.js.gz +0 -0
  234. package/dist/dashboard/assets/app-DxivpfYG.css +0 -2
  235. package/dist/dashboard/assets/app-DxivpfYG.css.gz +0 -0
  236. package/dist/dashboard/assets/assert-NHwYq95t.js.gz +0 -0
  237. package/dist/dashboard/assets/badge-6uzDDvqs.js.gz +0 -0
  238. package/dist/dashboard/assets/banner-4_WNg2WZ.js.gz +0 -0
  239. package/dist/dashboard/assets/card--AtFedAq.js.gz +0 -0
  240. package/dist/dashboard/assets/cli-tools-B3e7dx3N.js.gz +0 -0
  241. package/dist/dashboard/assets/clipboard-button-NreUx6c8.js.gz +0 -0
  242. package/dist/dashboard/assets/dialog-BtpsJU98.js.gz +0 -0
  243. package/dist/dashboard/assets/download-A0SW9OCW.js +0 -1
  244. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  245. package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
  246. package/dist/dashboard/assets/entry-Dj9AR0gY.js +0 -4
  247. package/dist/dashboard/assets/entry-Dj9AR0gY.js.gz +0 -0
  248. package/dist/dashboard/assets/entry-TJIye_jT.js +0 -1
  249. package/dist/dashboard/assets/entry-TJIye_jT.js.gz +0 -0
  250. package/dist/dashboard/assets/entry-jy9_4-Ke.js +0 -1
  251. package/dist/dashboard/assets/entry-jy9_4-Ke.js.gz +0 -0
  252. package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
  253. package/dist/dashboard/assets/index-DCBtROoK.js.gz +0 -0
  254. package/dist/dashboard/assets/inline-BcNGF8Gr.js.gz +0 -0
  255. package/dist/dashboard/assets/input-BG41et9G.js.gz +0 -0
  256. package/dist/dashboard/assets/layout-C8mer_Ak.js.gz +0 -0
  257. package/dist/dashboard/assets/list-row-Gh_W4OR_.js.gz +0 -0
  258. package/dist/dashboard/assets/live-BJGYRBnR.js.gz +0 -0
  259. package/dist/dashboard/assets/model-bans-DeVLDA7I.js +0 -1
  260. package/dist/dashboard/assets/model-bans-DeVLDA7I.js.gz +0 -0
  261. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  262. package/dist/dashboard/assets/numbers-Bs4LOOuf.js +0 -2
  263. package/dist/dashboard/assets/numbers-Bs4LOOuf.js.gz +0 -0
  264. package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
  265. package/dist/dashboard/assets/select-DsX5QT8K.js.gz +0 -0
  266. package/dist/dashboard/assets/settings-mpmXTgUa.js.gz +0 -0
  267. package/dist/dashboard/assets/sparkles-BRaRUxAU.js +0 -1
  268. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  269. package/dist/dashboard/assets/stack-DwLmjUFK.js.gz +0 -0
  270. package/dist/dashboard/assets/state-DYdQmdAl.js.gz +0 -0
  271. package/dist/dashboard/assets/switch-CidSfbG7.js +0 -1
  272. package/dist/dashboard/assets/switch-CidSfbG7.js.gz +0 -0
  273. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +0 -1
  274. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  275. package/dist/dashboard/assets/useMutation-w4u4CVue.js.gz +0 -0
  276. /package/dist/dashboard/assets/{app-CafWtVXA.js → app-DCQZRmHY.js} +0 -0
  277. /package/dist/dashboard/assets/{app-CafWtVXA.js.gz → app-DCQZRmHY.js.gz} +0 -0
@@ -1,494 +1,504 @@
1
- // API-key authentication and authorization snapshots: token extraction, hashing, lookup, and admission identity.
2
-
3
- import type { CartethyiaDatabase } from "../persistence/db-handle";
4
- import { deleteShared, publishInvalidation, readShared, writeShared } from "../persistence/shared-cache";
5
- import { DrizzleApiKeyStore } from "../persistence/stores/api-key-store";
6
- import { GatewayError } from "../protocol/gateway-error";
7
- import { hashSecret } from "./crypto";
8
- import type { AccessScope } from "./access-control";
9
- import type { ApiKeyModelAccessMode } from "../persistence/schema";
10
-
11
- // ── Authorization cache ──────────────────────────────────────────────────────
12
- /**
13
- * TTL-based in-memory cache for `resolveApiKeyAuthorization`, keyed by the
14
- * token hash. The Postgres round-trips this avoids (1–3 SELECTs per request)
15
- * are the single largest latency contributor on the `/v1/*` hot path.
16
- *
17
- * Trade-off: `lifetime_tokens_consumed` in the cached snapshot may be up to
18
- * `AUTH_CACHE_TTL_MS` stale. The admission service's real-time in-flight and
19
- * estimated-token accounting is the actual enforcement mechanism; the
20
- * snapshot's consumed value is a reference point, so a brief stale window
21
- * cannot meaningfully over-consume a budget.
22
- *
23
- * Negative results (unknown/revoked tokens) are deliberately NOT cached, so
24
- * a newly minted key is usable immediately.
25
- */
26
- const AUTH_CACHE_TTL_MS = 3_000;
27
- const AUTH_CACHE_MAX_ENTRIES = 10_000;
28
- /** Negative-result TTL: unknown/revoked tokens miss the DB at most this often. */
29
- const AUTH_NEGATIVE_CACHE_TTL_MS = 1_500;
30
-
31
- interface AuthCacheEntry {
32
- readonly at: number;
33
- readonly value: ResolvedApiKey;
34
- /** Owner of `value`; the reverse-index key this entry is registered under. */
35
- readonly keyId: string;
36
- }
37
-
38
- const authCache = new Map<string, AuthCacheEntry>();
39
- /** Reverse index: keyId → set of token hashes that resolved to it. */
40
- const authCacheByKeyId = new Map<string, Set<string>>();
41
- /** Unknown/revoked token HMACs with their miss time; cleared on any key mutation. */
42
- const authNegativeCache = new Map<string, number>();
43
-
44
- /**
45
- * Short circuit after a credential-store failure.
46
- *
47
- * An overloaded or down database fails every lookup, and each failure costs a
48
- * connection attempt. Without this, a saturated backend received one query per
49
- * inbound request — load the operator sees as traffic, on top of the load that
50
- * caused the outage. For the window, lookups answer "unavailable" instead.
51
- *
52
- * Deliberately short: this is an auth path, and a brief outage must not lock
53
- * out valid keys for long.
54
- */
55
- const AUTH_FAILURE_COOLDOWN_MS = 1_000;
56
- let authFailureUntil = 0;
57
-
58
- /** Thrown when the credential store could not be reached at all. */
59
- export class ApiKeyStoreUnavailableError extends Error {
60
- constructor() {
61
- super("The credential store is unavailable.");
62
- this.name = "ApiKeyStoreUnavailableError";
63
- }
64
- }
65
-
66
- /**
67
- * Source for lifetime-usage deltas recorded but not yet flushed.
68
- *
69
- * Injected rather than imported: this module is on the auth path and must not
70
- * depend on the persistence/write layer it would otherwise pull in. Set once at
71
- * boot (`boot/dependencies.ts`); until then it reads as zero, which is the
72
- * value the code has always used.
73
- */
74
- let pendingUsage: (apiKeyId: string) => number = () => 0;
75
-
76
- /**
77
- * Supplies the in-flight usage source. See {@link pendingUsage}.
78
- *
79
- * The persisted `lifetime_tokens_consumed` is behind by up to one flush
80
- * interval, and the flush coalesces by design — so it is behind by the *most*
81
- * exactly when a key is busiest. Adding the pending delta costs nothing and
82
- * removes the gap.
83
- */
84
- export function setPendingUsageSource(source: (apiKeyId: string) => number): void {
85
- pendingUsage = source;
86
- }
87
- /**
88
- * Negative-result fast path. Unknown/revoked tokens previously hit the DB on
89
- * every request; now a repeat miss within 1.5 s costs one HMAC + one map
90
- * lookup and no query. Keyed by the full HMAC: a cheap pre-hash would let a
91
- * crafted collision reject someone else's valid key, while the HMAC key can
92
- * only match its own token. Bound below beside the positive cache.
93
- */
94
-
95
- /**
96
- * Drops the oldest entries until `authCache` is within budget. Every removal
97
- * must also unregister the hash from `authCacheByKeyId`, otherwise the reverse
98
- * index keeps referencing hashes that no longer exist and grows without bound
99
- * (one dead hash per eviction, forever).
100
- */
101
- function evictAuthCacheOldest(): void {
102
- while (authCache.size > AUTH_CACHE_MAX_ENTRIES) {
103
- const oldest = authCache.keys().next().value;
104
- if (oldest === undefined) return;
105
- const entry = authCache.get(oldest);
106
- authCache.delete(oldest);
107
- if (entry === undefined) continue;
108
- const hashes = authCacheByKeyId.get(entry.keyId);
109
- if (hashes === undefined) continue;
110
- hashes.delete(oldest);
111
- if (hashes.size === 0) authCacheByKeyId.delete(entry.keyId);
112
- }
113
- }
114
-
115
- /** Registers a resolved hash in both the cache and the keyId reverse index. */
116
- function indexAuthCache(hash: string, entry: AuthCacheEntry): void {
117
- authCache.set(hash, entry);
118
- authNegativeCache.delete(hash);
119
- const hashes = authCacheByKeyId.get(entry.keyId) ?? new Set<string>();
120
- hashes.add(hash);
121
- authCacheByKeyId.set(entry.keyId, hashes);
122
- }
123
-
124
- /** Bounds the negative cache; same oldest-first rule as the positive cache. */
125
- function evictAuthNegativeOldest(): void {
126
- while (authNegativeCache.size > AUTH_CACHE_MAX_ENTRIES) {
127
- const oldest = authNegativeCache.keys().next().value;
128
- if (oldest === undefined) return;
129
- authNegativeCache.delete(oldest);
130
- }
131
- }
132
-
133
- /**
134
- * Invalidates every cached authorization entry for one API key (or the entire
135
- * cache when `keyId` is omitted). Called by the API-key domain after any
136
- * mutation that changes a key's scopes, limits, or revoked state.
137
- */
138
- export function invalidateApiKeyCache(keyId?: string): void {
139
- // A mutation may mint the previously-unknown token, so negative entries
140
- // must go with the positive ones.
141
- authNegativeCache.clear();
142
- if (keyId === undefined) {
143
- authCache.clear();
144
- authCacheByKeyId.clear();
145
- void deleteShared("auth");
146
- publishInvalidation({ namespace: "auth" });
147
- return;
148
- }
149
- const hashes = authCacheByKeyId.get(keyId);
150
- if (hashes) {
151
- for (const hash of hashes) authCache.delete(hash);
152
- authCacheByKeyId.delete(keyId);
153
- }
154
- // Peer processes hold their own copy, and `reusePort` means the console
155
- // write may have landed on a different one entirely. Dropping only locally
156
- // leaves a revoked key valid for up to a full TTL everywhere else.
157
- //
158
- // Delivered by broadcast rather than by deleting a shared entry: a mutation
159
- // names a key id, and the shared layer is keyed by token hash, which the
160
- // mutating request does not have. The notice is at-most-once, so the TTL
161
- // stays the backstop for a peer that misses it.
162
- publishInvalidation({ namespace: "auth", key: keyId });
163
- }
164
-
165
- /**
166
- * Resolves an inbound `/v1/*` bearer token to its persisted API key row and
167
- * builds the immutable `ApiKeyAuthorizationSnapshot` consumed by
168
- * `ApiKeyAdmissionService`. This is the only place a raw client bearer token
169
- * is hashed and looked up against Postgres.
170
- */
171
- /** Immutable tenant-scoped authorization snapshot. */
172
-
173
- export interface ApiKeyAuthorizationSnapshot {
174
- readonly api_key_id: string;
175
- readonly tenant_id: string;
176
- /** Admission identity used as key for rolling counters; defaults to api_key_id when absent. */
177
- readonly admission_identity?: string;
178
- /** Empty or absent means unrestricted; values are normalized to frozen arrays. */
179
- readonly model_access_mode?: ApiKeyModelAccessMode | null | undefined;
180
- /** The one list `model_access_mode` interprets; see the schema for semantics. */
181
- readonly model_list?: readonly string[] | ReadonlySet<string> | null | undefined;
182
- /** Client-router ids this key refuses; see `client-router-fingerprint.ts`. */
183
- readonly client_router_denylist?: readonly unknown[] | ReadonlySet<unknown> | null | undefined;
184
- readonly rpm?: number | null | undefined;
185
- readonly rpm_limit?: number | null | undefined;
186
- readonly daily_tokens?: number | null | undefined;
187
- readonly monthly_tokens?: number | null | undefined;
188
- readonly lifetime_token_budget?: number | null | undefined;
189
- readonly lifetime_tokens_consumed?: number | null | undefined;
190
- readonly max_concurrent?: number | null | undefined;
191
- readonly scopes?: readonly string[] | undefined;
192
- }
193
-
194
- function freezeList(
195
- list: readonly unknown[] | ReadonlySet<unknown> | null | undefined,
196
- ): readonly string[] | null | undefined {
197
- if (list == null) return list as null | undefined;
198
- const values = list instanceof Set ? [...list] : Array.isArray(list) ? list : [];
199
- const arr = Object.freeze(values.filter((value): value is string => typeof value === "string"));
200
- return arr;
201
- }
202
-
203
- export function freezeSnapshot(s: ApiKeyAuthorizationSnapshot): ApiKeyAuthorizationSnapshot {
204
- const model_list = freezeList(s.model_list);
205
- const model_access_mode: ApiKeyModelAccessMode =
206
- s.model_access_mode === "blacklist" ? "blacklist" : "whitelist";
207
- const client_router_denylist = freezeList(s.client_router_denylist);
208
-
209
- const api_key_id = s.api_key_id ?? "";
210
- const tenant_id = s.tenant_id ?? "";
211
- const admission_identity = s.admission_identity ?? api_key_id;
212
-
213
- const frozen: ApiKeyAuthorizationSnapshot = {
214
- api_key_id,
215
- tenant_id,
216
- admission_identity,
217
- model_access_mode,
218
- model_list,
219
- client_router_denylist,
220
- rpm: s.rpm ?? s.rpm_limit ?? null,
221
- daily_tokens: s.daily_tokens ?? null,
222
- monthly_tokens: s.monthly_tokens ?? null,
223
- lifetime_token_budget: s.lifetime_token_budget ?? null,
224
- lifetime_tokens_consumed: s.lifetime_tokens_consumed ?? null,
225
- max_concurrent: s.max_concurrent ?? null,
226
- scopes: s.scopes ? Object.freeze([...s.scopes]) : undefined,
227
- };
228
- return Object.freeze(frozen) as ApiKeyAuthorizationSnapshot;
229
- }
230
-
231
- export function createAuthorizationSnapshot(input: {
232
- readonly api_key_id: string;
233
- readonly tenant_id: string;
234
- readonly admission_identity?: string;
235
- readonly model_access_mode?: ApiKeyModelAccessMode | null;
236
- readonly model_list?: readonly string[] | ReadonlySet<string> | null;
237
- readonly client_router_denylist?: readonly unknown[] | ReadonlySet<unknown> | null;
238
- readonly rpm?: number | null;
239
- readonly daily_tokens?: number | null;
240
- readonly monthly_tokens?: number | null;
241
- readonly lifetime_token_budget?: number | null;
242
- readonly lifetime_tokens_consumed?: number | null;
243
- readonly max_concurrent?: number | null;
244
- readonly rpm_limit?: number | null;
245
- readonly scopes?: readonly string[];
246
- }): ApiKeyAuthorizationSnapshot {
247
- return freezeSnapshot({
248
- api_key_id: input.api_key_id,
249
- tenant_id: input.tenant_id,
250
- ...(input.admission_identity !== undefined
251
- ? { admission_identity: input.admission_identity }
252
- : {}),
253
- ...(input.model_access_mode !== undefined && input.model_access_mode !== null
254
- ? { model_access_mode: input.model_access_mode }
255
- : {}),
256
- ...(input.model_list !== undefined && input.model_list !== null
257
- ? { model_list: input.model_list as readonly string[] }
258
- : {}),
259
- ...(input.client_router_denylist !== undefined && input.client_router_denylist !== null
260
- ? { client_router_denylist: input.client_router_denylist }
261
- : {}),
262
- ...(input.rpm !== undefined && input.rpm !== null ? { rpm: input.rpm } : {}),
263
- ...(input.daily_tokens !== undefined && input.daily_tokens !== null
264
- ? { daily_tokens: input.daily_tokens }
265
- : {}),
266
- ...(input.monthly_tokens !== undefined && input.monthly_tokens !== null
267
- ? { monthly_tokens: input.monthly_tokens }
268
- : {}),
269
- ...(input.lifetime_token_budget !== undefined && input.lifetime_token_budget !== null
270
- ? { lifetime_token_budget: input.lifetime_token_budget }
271
- : {}),
272
- ...(input.lifetime_tokens_consumed !== undefined && input.lifetime_tokens_consumed !== null
273
- ? { lifetime_tokens_consumed: input.lifetime_tokens_consumed }
274
- : {}),
275
- ...(input.max_concurrent !== undefined && input.max_concurrent !== null
276
- ? { max_concurrent: input.max_concurrent }
277
- : {}),
278
- ...(input.scopes !== undefined ? { scopes: input.scopes } : {}),
279
- });
280
- }
281
-
282
- /**
283
- * The model-access rule lives in `model-access-rule.ts`, a pure module the
284
- * dashboard can import too (this file pulls in Drizzle/Node). Re-exported here
285
- * so existing callers keep importing from `api-key-auth`.
286
- */
287
- export {
288
- isModelAllowed,
289
- listIncludes,
290
- modelRejectionReason,
291
- type ModelRejectionReason,
292
- } from "./model-access-rule";
293
-
294
-
295
- export function getAdmissionIdentity(snapshot: ApiKeyAuthorizationSnapshot): string {
296
- return snapshot.admission_identity ?? snapshot.api_key_id;
297
- }
298
-
299
- export interface ResolvedApiKey {
300
- readonly id: string;
301
- readonly tenantId: string;
302
- readonly scopes: readonly AccessScope[];
303
- readonly snapshot: ApiKeyAuthorizationSnapshot;
304
- readonly modelPrefix?: string;
305
- /** Share-template id whose remote CLI mappings are inherited by this child. */
306
- readonly cliMappingOwnerId?: string;
307
- }
308
-
309
- /**
310
- * The 401 a caller gets when a key resolves to nothing.
311
- *
312
- * One function, not a message repeated at each boundary: three surfaces refuse
313
- * an unknown key, and any wording drift between them is a caller guessing which
314
- * surface it hit. Unknown, revoked and expired stay deliberately
315
- * indistinguishable — distinguishing them would let a caller confirm that a
316
- * guessed token once existed — so there is no variant carrying a reason.
317
- *
318
- * The rotate instruction is unconditional for the same reason. The key that
319
- * fails is usually the one seeded at first boot, which cannot be deleted and
320
- * only rotates, and an operator hitting this on a personal key loses nothing by
321
- * being told the same thing.
322
- */
323
- export function unknownApiKeyError(): GatewayError {
324
- return new GatewayError(
325
- "authentication_failed",
326
- 401,
327
- "Invalid API key. Rotate the default Cartethyia API key in API Keys and send the new key as: Authorization: Bearer <key>.",
328
- );
329
- }
330
-
331
- /** Extracts exactly one supported credential source, preserving public auth statuses. */
332
- export function requestToken(headers: Headers | Record<string, string>): string {
333
- const get = (name: string): string | null =>
334
- headers instanceof Headers ? headers.get(name) : (headers[name] ?? null);
335
- const authorization = get("authorization")?.trim() ?? "";
336
- const apiKey = get("x-api-key")?.trim() ?? "";
337
- const bearerMatch = /^Bearer\s+(\S+)$/i.exec(authorization);
338
- const hasAuthorization = authorization.length > 0;
339
- const hasApiKey = apiKey.length > 0;
340
- if (hasAuthorization && hasApiKey) {
341
- // Both headers carrying the *same* credential is a compatibility idiom, not
342
- // an error: an Anthropic-compatible client sends `x-api-key` and
343
- // `Authorization: Bearer <same key>` together so a gateway reading either
344
- // one works, and rejecting the pair rejects the whole client. Two
345
- // *different* credentials stay a conflict — accepting whichever came first
346
- // would silently decide the request's identity.
347
- if (bearerMatch?.[1] === apiKey) return apiKey;
348
- throw new GatewayError("invalid_request", 400, "The request sends two different credentials. Send exactly one key, either as Authorization: Bearer <key> or as the api-key header.");
349
- }
350
- if (hasApiKey) return apiKey;
351
- const token = bearerMatch?.[1];
352
- if (token === undefined)
353
- throw new GatewayError("invalid_request", 401, "The request carries no usable credential. Send the key as: Authorization: Bearer <key>.");
354
- return token;
355
- }
356
-
357
- /**
358
- * Whether a key can possibly have children, and so whether summing them can
359
- * return anything but zero.
360
- *
361
- * Children exist only for a `share` parent — the schema's own check constraint
362
- * ties the mode to the parent-child shape (schema.ts:799-803), and no other
363
- * mode mints recipients. Extracted and exported because the alternative is an
364
- * untestable inline comparison guarding a database round-trip.
365
- */
366
- export function canHaveChildKeys(key: { readonly keyMode?: string | null }): boolean {
367
- return key.keyMode === "share";
368
- }
369
-
370
- /**
371
- * Looks up the API key by its hashed bearer token. Returns `undefined` for
372
- * an unknown or revoked key — callers must reject with 401, never fall back
373
- * to an implicit/default identity.
374
- */
375
- export async function resolveApiKeyAuthorization(
376
- db: CartethyiaDatabase,
377
- token: string,
378
- ): Promise<ResolvedApiKey | undefined> {
379
- const now = Date.now();
380
- const hash = hashSecret(token);
381
- const negativeAt = authNegativeCache.get(hash);
382
- if (negativeAt !== undefined) {
383
- if (now - negativeAt < AUTH_NEGATIVE_CACHE_TTL_MS) return undefined;
384
- authNegativeCache.delete(hash);
385
- }
386
- const cached = authCache.get(hash);
387
- if (cached && now - cached.at < AUTH_CACHE_TTL_MS) return cached.value;
388
- // L2: a peer process may have resolved this token already. Reachable only
389
- // after the L1 entry lapsed, so Redis is never consulted on a warm hit.
390
- //
391
- // Keyed by token hash, because that is what a lookup has — invalidation
392
- // arrives as a key id and is delivered as a broadcast instead.
393
- const shared = await readShared("auth", hash);
394
- if (shared !== undefined) {
395
- try {
396
- const parsed = JSON.parse(shared) as ResolvedApiKey;
397
- if (typeof parsed?.id === "string") {
398
- indexAuthCache(hash, { at: now, value: parsed, keyId: parsed.id });
399
- evictAuthCacheOldest();
400
- return parsed;
401
- }
402
- } catch {
403
- // A malformed or schema-drifted entry is ignored and re-resolved.
404
- }
405
- }
406
- // Answered from the cooldown before any I/O: during a store outage the
407
- // query is the expensive part, and it is also the part that fails.
408
- if (now < authFailureUntil) throw new ApiKeyStoreUnavailableError();
409
- const store = new DrizzleApiKeyStore(db);
410
- let row: Awaited<ReturnType<typeof store.findActiveByHash>>;
411
- try {
412
- row = await store.findActiveByHash(hash);
413
- } catch (error) {
414
- // Any failure here is the store's, not the key's — a revoked key is a
415
- // normal empty result, so this only runs on a real fault.
416
- authFailureUntil = Date.now() + AUTH_FAILURE_COOLDOWN_MS;
417
- throw error;
418
- }
419
- if (!row) {
420
- authNegativeCache.set(hash, now);
421
- evictAuthNegativeOldest();
422
- return undefined;
423
- }
424
- // A child (shared) key consumes against its parent's quota, not just its
425
- // own: the parent's budget is the operator's global cap across all
426
- // recipients. Without this the parent's lifetime budget never moves no
427
- // matter how much its children spend, so the limit silently never fires.
428
- // One extra SELECT on the auth path, only for keys that have a parent.
429
- const parent =
430
- row.parentKeyId !== null ? await store.findActiveById(row.parentKeyId) : undefined;
431
- // Share children authenticate as themselves but inherit the template's live
432
- if (row.parentKeyId !== null && parent === undefined) return undefined;
433
- // policy. Copy-on-issue alone went stale the moment an operator tightened a
434
- // limit or blocked a client router on the parent — existing recipients kept
435
- // the old row values forever. Reading the parent here makes denylist /
436
- // one-time / recurring edits apply on the next cache miss.
437
- const policy = parent ?? row;
438
- // Only a `share` parent can have children, and children are the only thing
439
- // the SUM below can find. Every other key ran an aggregate that was certain
440
- // to return 0 — one extra Postgres round-trip on the auth path for nothing.
441
- //
442
- // `row.parentKeyId !== null` is the same test, kept as the mode check because
443
- // the mode is what the share dialog and the schema's own check constraint
444
- // key off (schema.ts:799-803), so the two cannot drift apart.
445
- const canHaveChildren = canHaveChildKeys(parent ?? row);
446
- const consumed =
447
- ((policy.id === row.id ? row.lifetimeTokensConsumed : policy.lifetimeTokensConsumed) ?? 0) +
448
- (canHaveChildren ? await store.sumChildrenConsumed(policy.id) : 0) +
449
- // In-flight deltas: what has been spent but not yet persisted.
450
- pendingUsage(policy.id);
451
- const scopes = Array.isArray(policy.scopes) ? (policy.scopes as AccessScope[]) : [];
452
- const snapshot = createAuthorizationSnapshot({
453
- api_key_id: row.id,
454
- tenant_id: row.tenantId,
455
- // Family quota / concurrency share one admission counter namespace so two
456
- // recipients cannot each burn a full lifetime/daily budget.
457
- ...(parent ? { admission_identity: parent.id } : {}),
458
- ...(policy.modelList ? { model_list: policy.modelList as string[] } : {}),
459
- model_access_mode: policy.modelAccessMode ?? "whitelist",
460
- ...(policy.clientRouterDenylist
461
- ? { client_router_denylist: policy.clientRouterDenylist as string[] }
462
- : {}),
463
- ...(policy.requestsPerMinute != null ? { rpm: policy.requestsPerMinute } : {}),
464
- ...(policy.dailyTokenLimit != null ? { daily_tokens: policy.dailyTokenLimit } : {}),
465
- ...(policy.monthlyTokenLimit != null ? { monthly_tokens: policy.monthlyTokenLimit } : {}),
466
- ...(policy.lifetimeTokenBudget != null ? { lifetime_token_budget: policy.lifetimeTokenBudget } : {}),
467
- lifetime_tokens_consumed: consumed,
468
- ...(policy.maxConcurrentRequests != null ? { max_concurrent: policy.maxConcurrentRequests } : {}),
469
- scopes,
470
- });
471
-
472
- const value: ResolvedApiKey = {
473
- id: row.id,
474
- tenantId: row.tenantId,
475
- scopes,
476
- snapshot,
477
- ...(policy.modelPrefix ? { modelPrefix: policy.modelPrefix } : {}),
478
- ...(parent?.keyMode === "share" ? { cliMappingOwnerId: parent.id } : {}),
479
- };
480
- indexAuthCache(hash, { at: Date.now(), value, keyId: value.id });
481
- evictAuthCacheOldest();
482
- // Shared copy for peer processes, keyed by the token hash so a lookup can
483
- // find it without resolving the key first. Invalidation travels by broadcast,
484
- // since a console mutation names a key id and cannot enumerate the hashes
485
- // that resolved to it in another process.
486
- //
487
- // The cached value deliberately carries `lifetime_tokens_consumed` as read.
488
- // That figure moves constantly, and the module already documents the snapshot
489
- // as advisory on that axis (spending is enforced by the admission counter),
490
- // so a peer serving it stale for up to the TTL is the same tradeoff the
491
- // in-process cache has always made.
492
- void writeShared("auth", hash, JSON.stringify(value), AUTH_CACHE_TTL_MS);
493
- return value;
494
- }
1
+ // API-key authentication and authorization snapshots: token extraction, hashing, lookup, and admission identity.
2
+
3
+ import type { CartethyiaDatabase } from "../persistence/db-handle";
4
+ import { deleteShared, publishInvalidation, readShared, writeShared } from "../persistence/shared-cache";
5
+ import { DrizzleApiKeyStore } from "../persistence/stores/api-key-store";
6
+ import { GatewayError } from "../protocol/gateway-error";
7
+ import { hashSecret } from "./crypto";
8
+ import type { AccessScope } from "./access-control";
9
+ import type { ApiKeyModelAccessMode } from "../persistence/schema";
10
+
11
+ // ── Authorization cache ──────────────────────────────────────────────────────
12
+ /**
13
+ * TTL-based in-memory cache for `resolveApiKeyAuthorization`, keyed by the
14
+ * token hash. The Postgres round-trips this avoids (1–3 SELECTs per request)
15
+ * are the single largest latency contributor on the `/v1/*` hot path.
16
+ *
17
+ * Trade-off: `lifetime_tokens_consumed` in the cached snapshot may be up to
18
+ * `AUTH_CACHE_TTL_MS` stale. The admission service's real-time in-flight and
19
+ * estimated-token accounting is the actual enforcement mechanism; the
20
+ * snapshot's consumed value is a reference point, so a brief stale window
21
+ * cannot meaningfully over-consume a budget.
22
+ *
23
+ * Negative results (unknown/revoked tokens) are deliberately NOT cached, so
24
+ * a newly minted key is usable immediately.
25
+ */
26
+ const AUTH_CACHE_TTL_MS = 3_000;
27
+ const AUTH_CACHE_MAX_ENTRIES = 10_000;
28
+ /** Negative-result TTL: unknown/revoked tokens miss the DB at most this often. */
29
+ const AUTH_NEGATIVE_CACHE_TTL_MS = 1_500;
30
+
31
+ interface AuthCacheEntry {
32
+ readonly at: number;
33
+ readonly value: ResolvedApiKey;
34
+ /** Owner of `value`; the reverse-index key this entry is registered under. */
35
+ readonly keyId: string;
36
+ }
37
+
38
+ const authCache = new Map<string, AuthCacheEntry>();
39
+ /** Reverse index: keyId → set of token hashes that resolved to it. */
40
+ const authCacheByKeyId = new Map<string, Set<string>>();
41
+ /** Unknown/revoked token HMACs with their miss time; cleared on any key mutation. */
42
+ const authNegativeCache = new Map<string, number>();
43
+
44
+ /**
45
+ * Short circuit after a credential-store failure.
46
+ *
47
+ * An overloaded or down database fails every lookup, and each failure costs a
48
+ * connection attempt. Without this, a saturated backend received one query per
49
+ * inbound request — load the operator sees as traffic, on top of the load that
50
+ * caused the outage. For the window, lookups answer "unavailable" instead.
51
+ *
52
+ * Deliberately short: this is an auth path, and a brief outage must not lock
53
+ * out valid keys for long.
54
+ */
55
+ const AUTH_FAILURE_COOLDOWN_MS = 1_000;
56
+ let authFailureUntil = 0;
57
+
58
+ /** Thrown when the credential store could not be reached at all. */
59
+ export class ApiKeyStoreUnavailableError extends Error {
60
+ constructor() {
61
+ super("The credential store is unavailable.");
62
+ this.name = "ApiKeyStoreUnavailableError";
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Source for lifetime-usage deltas recorded but not yet flushed.
68
+ *
69
+ * Injected rather than imported: this module is on the auth path and must not
70
+ * depend on the persistence/write layer it would otherwise pull in. Set once at
71
+ * boot (`boot/dependencies.ts`); until then it reads as zero, which is the
72
+ * value the code has always used.
73
+ */
74
+ let pendingUsage: (apiKeyId: string) => number = () => 0;
75
+
76
+ /**
77
+ * Supplies the in-flight usage source. See {@link pendingUsage}.
78
+ *
79
+ * The persisted `lifetime_tokens_consumed` is behind by up to one flush
80
+ * interval, and the flush coalesces by design — so it is behind by the *most*
81
+ * exactly when a key is busiest. Adding the pending delta costs nothing and
82
+ * removes the gap.
83
+ */
84
+ export function setPendingUsageSource(source: (apiKeyId: string) => number): void {
85
+ pendingUsage = source;
86
+ }
87
+ /**
88
+ * Negative-result fast path. Unknown/revoked tokens previously hit the DB on
89
+ * every request; now a repeat miss within 1.5 s costs one HMAC + one map
90
+ * lookup and no query. Keyed by the full HMAC: a cheap pre-hash would let a
91
+ * crafted collision reject someone else's valid key, while the HMAC key can
92
+ * only match its own token. Bound below beside the positive cache.
93
+ */
94
+
95
+ /**
96
+ * Drops the oldest entries until `authCache` is within budget. Every removal
97
+ * must also unregister the hash from `authCacheByKeyId`, otherwise the reverse
98
+ * index keeps referencing hashes that no longer exist and grows without bound
99
+ * (one dead hash per eviction, forever).
100
+ */
101
+ function evictAuthCacheOldest(): void {
102
+ while (authCache.size > AUTH_CACHE_MAX_ENTRIES) {
103
+ const oldest = authCache.keys().next().value;
104
+ if (oldest === undefined) return;
105
+ const entry = authCache.get(oldest);
106
+ authCache.delete(oldest);
107
+ if (entry === undefined) continue;
108
+ const hashes = authCacheByKeyId.get(entry.keyId);
109
+ if (hashes === undefined) continue;
110
+ hashes.delete(oldest);
111
+ if (hashes.size === 0) authCacheByKeyId.delete(entry.keyId);
112
+ }
113
+ }
114
+
115
+ /** Registers a resolved hash in both the cache and the keyId reverse index. */
116
+ function indexAuthCache(hash: string, entry: AuthCacheEntry): void {
117
+ authCache.set(hash, entry);
118
+ authNegativeCache.delete(hash);
119
+ const hashes = authCacheByKeyId.get(entry.keyId) ?? new Set<string>();
120
+ hashes.add(hash);
121
+ authCacheByKeyId.set(entry.keyId, hashes);
122
+ }
123
+
124
+ /** Bounds the negative cache; same oldest-first rule as the positive cache. */
125
+ function evictAuthNegativeOldest(): void {
126
+ while (authNegativeCache.size > AUTH_CACHE_MAX_ENTRIES) {
127
+ const oldest = authNegativeCache.keys().next().value;
128
+ if (oldest === undefined) return;
129
+ authNegativeCache.delete(oldest);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Invalidates every cached authorization entry for one API key (or the entire
135
+ * cache when `keyId` is omitted). Called by the API-key domain after any
136
+ * mutation that changes a key's scopes, limits, or revoked state.
137
+ */
138
+ export function invalidateApiKeyCache(keyId?: string): void {
139
+ // A mutation may mint the previously-unknown token, so negative entries
140
+ // must go with the positive ones.
141
+ authNegativeCache.clear();
142
+ if (keyId === undefined) {
143
+ authCache.clear();
144
+ authCacheByKeyId.clear();
145
+ void deleteShared("auth");
146
+ publishInvalidation({ namespace: "auth" });
147
+ return;
148
+ }
149
+ const hashes = authCacheByKeyId.get(keyId);
150
+ if (hashes) {
151
+ for (const hash of hashes) authCache.delete(hash);
152
+ authCacheByKeyId.delete(keyId);
153
+ }
154
+ // Peer processes hold their own copy, and `reusePort` means the console
155
+ // write may have landed on a different one entirely. Dropping only locally
156
+ // leaves a revoked key valid for up to a full TTL everywhere else.
157
+ //
158
+ // Delivered by broadcast rather than by deleting a shared entry: a mutation
159
+ // names a key id, and the shared layer is keyed by token hash, which the
160
+ // mutating request does not have. The notice is at-most-once, so the TTL
161
+ // stays the backstop for a peer that misses it.
162
+ publishInvalidation({ namespace: "auth", key: keyId });
163
+ }
164
+
165
+ /**
166
+ * Resolves an inbound `/v1/*` bearer token to its persisted API key row and
167
+ * builds the immutable `ApiKeyAuthorizationSnapshot` consumed by
168
+ * `ApiKeyAdmissionService`. This is the only place a raw client bearer token
169
+ * is hashed and looked up against Postgres.
170
+ */
171
+ /** Immutable tenant-scoped authorization snapshot. */
172
+
173
+ export interface ApiKeyAuthorizationSnapshot {
174
+ readonly api_key_id: string;
175
+ readonly tenant_id: string;
176
+ /** Admission identity used as key for rolling counters; defaults to api_key_id when absent. */
177
+ readonly admission_identity?: string;
178
+ /** Empty or absent means unrestricted; values are normalized to frozen arrays. */
179
+ readonly model_access_mode?: ApiKeyModelAccessMode | null | undefined;
180
+ /** The one list `model_access_mode` interprets; see the schema for semantics. */
181
+ readonly model_list?: readonly string[] | ReadonlySet<string> | null | undefined;
182
+ /** Client-router ids this key refuses; see `client-router-fingerprint.ts`. */
183
+ readonly client_router_denylist?: readonly unknown[] | ReadonlySet<unknown> | null | undefined;
184
+ readonly rpm?: number | null | undefined;
185
+ readonly rpm_limit?: number | null | undefined;
186
+ readonly daily_tokens?: number | null | undefined;
187
+ readonly monthly_tokens?: number | null | undefined;
188
+ readonly lifetime_token_budget?: number | null | undefined;
189
+ readonly lifetime_tokens_consumed?: number | null | undefined;
190
+ readonly max_concurrent?: number | null | undefined;
191
+ readonly scopes?: readonly string[] | undefined;
192
+ }
193
+
194
+ function freezeList(
195
+ list: readonly unknown[] | ReadonlySet<unknown> | null | undefined,
196
+ ): readonly string[] | null | undefined {
197
+ if (list == null) return list as null | undefined;
198
+ const values = list instanceof Set ? [...list] : Array.isArray(list) ? list : [];
199
+ const arr = Object.freeze(values.filter((value): value is string => typeof value === "string"));
200
+ return arr;
201
+ }
202
+
203
+ export function freezeSnapshot(s: ApiKeyAuthorizationSnapshot): ApiKeyAuthorizationSnapshot {
204
+ const model_list = freezeList(s.model_list);
205
+ const model_access_mode: ApiKeyModelAccessMode =
206
+ s.model_access_mode === "blacklist" ? "blacklist" : "whitelist";
207
+ const client_router_denylist = freezeList(s.client_router_denylist);
208
+
209
+ const api_key_id = s.api_key_id ?? "";
210
+ const tenant_id = s.tenant_id ?? "";
211
+ const admission_identity = s.admission_identity ?? api_key_id;
212
+
213
+ const frozen: ApiKeyAuthorizationSnapshot = {
214
+ api_key_id,
215
+ tenant_id,
216
+ admission_identity,
217
+ model_access_mode,
218
+ model_list,
219
+ client_router_denylist,
220
+ rpm: s.rpm ?? s.rpm_limit ?? null,
221
+ daily_tokens: s.daily_tokens ?? null,
222
+ monthly_tokens: s.monthly_tokens ?? null,
223
+ lifetime_token_budget: s.lifetime_token_budget ?? null,
224
+ lifetime_tokens_consumed: s.lifetime_tokens_consumed ?? null,
225
+ max_concurrent: s.max_concurrent ?? null,
226
+ scopes: s.scopes ? Object.freeze([...s.scopes]) : undefined,
227
+ };
228
+ return Object.freeze(frozen) as ApiKeyAuthorizationSnapshot;
229
+ }
230
+
231
+ export function createAuthorizationSnapshot(input: {
232
+ readonly api_key_id: string;
233
+ readonly tenant_id: string;
234
+ readonly admission_identity?: string;
235
+ readonly model_access_mode?: ApiKeyModelAccessMode | null;
236
+ readonly model_list?: readonly string[] | ReadonlySet<string> | null;
237
+ readonly client_router_denylist?: readonly unknown[] | ReadonlySet<unknown> | null;
238
+ readonly rpm?: number | null;
239
+ readonly daily_tokens?: number | null;
240
+ readonly monthly_tokens?: number | null;
241
+ readonly lifetime_token_budget?: number | null;
242
+ readonly lifetime_tokens_consumed?: number | null;
243
+ readonly max_concurrent?: number | null;
244
+ readonly rpm_limit?: number | null;
245
+ readonly scopes?: readonly string[];
246
+ }): ApiKeyAuthorizationSnapshot {
247
+ return freezeSnapshot({
248
+ api_key_id: input.api_key_id,
249
+ tenant_id: input.tenant_id,
250
+ ...(input.admission_identity !== undefined
251
+ ? { admission_identity: input.admission_identity }
252
+ : {}),
253
+ ...(input.model_access_mode !== undefined && input.model_access_mode !== null
254
+ ? { model_access_mode: input.model_access_mode }
255
+ : {}),
256
+ ...(input.model_list !== undefined && input.model_list !== null
257
+ ? { model_list: input.model_list as readonly string[] }
258
+ : {}),
259
+ ...(input.client_router_denylist !== undefined && input.client_router_denylist !== null
260
+ ? { client_router_denylist: input.client_router_denylist }
261
+ : {}),
262
+ ...(input.rpm !== undefined && input.rpm !== null ? { rpm: input.rpm } : {}),
263
+ ...(input.daily_tokens !== undefined && input.daily_tokens !== null
264
+ ? { daily_tokens: input.daily_tokens }
265
+ : {}),
266
+ ...(input.monthly_tokens !== undefined && input.monthly_tokens !== null
267
+ ? { monthly_tokens: input.monthly_tokens }
268
+ : {}),
269
+ ...(input.lifetime_token_budget !== undefined && input.lifetime_token_budget !== null
270
+ ? { lifetime_token_budget: input.lifetime_token_budget }
271
+ : {}),
272
+ ...(input.lifetime_tokens_consumed !== undefined && input.lifetime_tokens_consumed !== null
273
+ ? { lifetime_tokens_consumed: input.lifetime_tokens_consumed }
274
+ : {}),
275
+ ...(input.max_concurrent !== undefined && input.max_concurrent !== null
276
+ ? { max_concurrent: input.max_concurrent }
277
+ : {}),
278
+ ...(input.scopes !== undefined ? { scopes: input.scopes } : {}),
279
+ });
280
+ }
281
+
282
+ /**
283
+ * The model-access rule lives in `model-access-rule.ts`, a pure module the
284
+ * dashboard can import too (this file pulls in Drizzle/Node). Re-exported here
285
+ * so existing callers keep importing from `api-key-auth`.
286
+ */
287
+ export {
288
+ isModelAllowed,
289
+ listIncludes,
290
+ modelRejectionReason,
291
+ type ModelRejectionReason,
292
+ } from "./model-access-rule";
293
+
294
+
295
+ export function getAdmissionIdentity(snapshot: ApiKeyAuthorizationSnapshot): string {
296
+ return snapshot.admission_identity ?? snapshot.api_key_id;
297
+ }
298
+
299
+ export interface ResolvedApiKey {
300
+ readonly id: string;
301
+ readonly tenantId: string;
302
+ readonly scopes: readonly AccessScope[];
303
+ readonly snapshot: ApiKeyAuthorizationSnapshot;
304
+ readonly modelPrefix?: string;
305
+ /** Share-template id whose remote CLI mappings are inherited by this child. */
306
+ readonly cliMappingOwnerId?: string;
307
+ }
308
+
309
+ /**
310
+ * The 401 a caller gets when a key resolves to nothing.
311
+ *
312
+ * One function, not a message repeated at each boundary: three surfaces refuse
313
+ * an unknown key, and any wording drift between them is a caller guessing which
314
+ * surface it hit. Unknown, revoked and expired stay deliberately
315
+ * indistinguishable — distinguishing them would let a caller confirm that a
316
+ * guessed token once existed — so there is no variant carrying a reason.
317
+ */
318
+ const INVALID_KEY_MESSAGE = "Invalid API key. Check the key you are sending, or create a new one in API Keys and send it as: Authorization: Bearer <key>.";
319
+
320
+ export function unknownApiKeyError(): GatewayError {
321
+ return new GatewayError("authentication_failed", 401, INVALID_KEY_MESSAGE);
322
+ }
323
+
324
+ /**
325
+ * The 401 for the public model surface specifically.
326
+ *
327
+ * Model Studio is where the seeded default key is normally used, and that key
328
+ * cannot be deleted — it only rotates. Naming rotation there is actionable. The
329
+ * same sentence on a proxy request was misleading instead: a revoked or mistyped
330
+ * personal key was answered with an instruction that could not apply to it,
331
+ * sending the operator to rotate a key they were never using.
332
+ */
333
+ export function modelsListApiKeyError(): GatewayError {
334
+ return new GatewayError(
335
+ "authentication_failed",
336
+ 401,
337
+ "Invalid API key. Rotate the default Cartethyia API key in API Keys and send the new key as: Authorization: Bearer <key>.",
338
+ );
339
+ }
340
+
341
+ /** Extracts exactly one supported credential source, preserving public auth statuses. */
342
+ export function requestToken(headers: Headers | Record<string, string>): string {
343
+ const get = (name: string): string | null =>
344
+ headers instanceof Headers ? headers.get(name) : (headers[name] ?? null);
345
+ const authorization = get("authorization")?.trim() ?? "";
346
+ const apiKey = get("x-api-key")?.trim() ?? "";
347
+ const bearerMatch = /^Bearer\s+(\S+)$/i.exec(authorization);
348
+ const hasAuthorization = authorization.length > 0;
349
+ const hasApiKey = apiKey.length > 0;
350
+ if (hasAuthorization && hasApiKey) {
351
+ // Both headers carrying the *same* credential is a compatibility idiom, not
352
+ // an error: an Anthropic-compatible client sends `x-api-key` and
353
+ // `Authorization: Bearer <same key>` together so a gateway reading either
354
+ // one works, and rejecting the pair rejects the whole client. Two
355
+ // *different* credentials stay a conflict — accepting whichever came first
356
+ // would silently decide the request's identity.
357
+ if (bearerMatch?.[1] === apiKey) return apiKey;
358
+ throw new GatewayError("invalid_request", 400, "The request sends two different credentials. Send exactly one key, either as Authorization: Bearer <key> or as the api-key header.");
359
+ }
360
+ if (hasApiKey) return apiKey;
361
+ const token = bearerMatch?.[1];
362
+ if (token === undefined)
363
+ throw new GatewayError("invalid_request", 401, "The request carries no usable credential. Send the key as: Authorization: Bearer <key>.");
364
+ return token;
365
+ }
366
+
367
+ /**
368
+ * Whether a key can possibly have children, and so whether summing them can
369
+ * return anything but zero.
370
+ *
371
+ * Children exist only for a `share` parent — the schema's own check constraint
372
+ * ties the mode to the parent-child shape (schema.ts:799-803), and no other
373
+ * mode mints recipients. Extracted and exported because the alternative is an
374
+ * untestable inline comparison guarding a database round-trip.
375
+ */
376
+ export function canHaveChildKeys(key: { readonly keyMode?: string | null }): boolean {
377
+ return key.keyMode === "share";
378
+ }
379
+
380
+ /**
381
+ * Looks up the API key by its hashed bearer token. Returns `undefined` for
382
+ * an unknown or revoked key — callers must reject with 401, never fall back
383
+ * to an implicit/default identity.
384
+ */
385
+ export async function resolveApiKeyAuthorization(
386
+ db: CartethyiaDatabase,
387
+ token: string,
388
+ ): Promise<ResolvedApiKey | undefined> {
389
+ const now = Date.now();
390
+ const hash = hashSecret(token);
391
+ const negativeAt = authNegativeCache.get(hash);
392
+ if (negativeAt !== undefined) {
393
+ if (now - negativeAt < AUTH_NEGATIVE_CACHE_TTL_MS) return undefined;
394
+ authNegativeCache.delete(hash);
395
+ }
396
+ const cached = authCache.get(hash);
397
+ if (cached && now - cached.at < AUTH_CACHE_TTL_MS) return cached.value;
398
+ // L2: a peer process may have resolved this token already. Reachable only
399
+ // after the L1 entry lapsed, so Redis is never consulted on a warm hit.
400
+ //
401
+ // Keyed by token hash, because that is what a lookup has — invalidation
402
+ // arrives as a key id and is delivered as a broadcast instead.
403
+ const shared = await readShared("auth", hash);
404
+ if (shared !== undefined) {
405
+ try {
406
+ const parsed = JSON.parse(shared) as ResolvedApiKey;
407
+ if (typeof parsed?.id === "string") {
408
+ indexAuthCache(hash, { at: now, value: parsed, keyId: parsed.id });
409
+ evictAuthCacheOldest();
410
+ return parsed;
411
+ }
412
+ } catch {
413
+ // A malformed or schema-drifted entry is ignored and re-resolved.
414
+ }
415
+ }
416
+ // Answered from the cooldown before any I/O: during a store outage the
417
+ // query is the expensive part, and it is also the part that fails.
418
+ if (now < authFailureUntil) throw new ApiKeyStoreUnavailableError();
419
+ const store = new DrizzleApiKeyStore(db);
420
+ let row: Awaited<ReturnType<typeof store.findActiveByHash>>;
421
+ try {
422
+ row = await store.findActiveByHash(hash);
423
+ } catch (error) {
424
+ // Any failure here is the store's, not the key's — a revoked key is a
425
+ // normal empty result, so this only runs on a real fault.
426
+ authFailureUntil = Date.now() + AUTH_FAILURE_COOLDOWN_MS;
427
+ throw error;
428
+ }
429
+ if (!row) {
430
+ authNegativeCache.set(hash, now);
431
+ evictAuthNegativeOldest();
432
+ return undefined;
433
+ }
434
+ // A child (shared) key consumes against its parent's quota, not just its
435
+ // own: the parent's budget is the operator's global cap across all
436
+ // recipients. Without this the parent's lifetime budget never moves no
437
+ // matter how much its children spend, so the limit silently never fires.
438
+ // One extra SELECT on the auth path, only for keys that have a parent.
439
+ const parent =
440
+ row.parentKeyId !== null ? await store.findActiveById(row.parentKeyId) : undefined;
441
+ // Share children authenticate as themselves but inherit the template's live
442
+ if (row.parentKeyId !== null && parent === undefined) return undefined;
443
+ // policy. Copy-on-issue alone went stale the moment an operator tightened a
444
+ // limit or blocked a client router on the parent — existing recipients kept
445
+ // the old row values forever. Reading the parent here makes denylist /
446
+ // one-time / recurring edits apply on the next cache miss.
447
+ const policy = parent ?? row;
448
+ // Only a `share` parent can have children, and children are the only thing
449
+ // the SUM below can find. Every other key ran an aggregate that was certain
450
+ // to return 0 — one extra Postgres round-trip on the auth path for nothing.
451
+ //
452
+ // `row.parentKeyId !== null` is the same test, kept as the mode check because
453
+ // the mode is what the share dialog and the schema's own check constraint
454
+ // key off (schema.ts:799-803), so the two cannot drift apart.
455
+ const canHaveChildren = canHaveChildKeys(parent ?? row);
456
+ const consumed =
457
+ ((policy.id === row.id ? row.lifetimeTokensConsumed : policy.lifetimeTokensConsumed) ?? 0) +
458
+ (canHaveChildren ? await store.sumChildrenConsumed(policy.id) : 0) +
459
+ // In-flight deltas: what has been spent but not yet persisted.
460
+ pendingUsage(policy.id);
461
+ const scopes = Array.isArray(policy.scopes) ? (policy.scopes as AccessScope[]) : [];
462
+ const snapshot = createAuthorizationSnapshot({
463
+ api_key_id: row.id,
464
+ tenant_id: row.tenantId,
465
+ // Family quota / concurrency share one admission counter namespace so two
466
+ // recipients cannot each burn a full lifetime/daily budget.
467
+ ...(parent ? { admission_identity: parent.id } : {}),
468
+ ...(policy.modelList ? { model_list: policy.modelList as string[] } : {}),
469
+ model_access_mode: policy.modelAccessMode ?? "whitelist",
470
+ ...(policy.clientRouterDenylist
471
+ ? { client_router_denylist: policy.clientRouterDenylist as string[] }
472
+ : {}),
473
+ ...(policy.requestsPerMinute != null ? { rpm: policy.requestsPerMinute } : {}),
474
+ ...(policy.dailyTokenLimit != null ? { daily_tokens: policy.dailyTokenLimit } : {}),
475
+ ...(policy.monthlyTokenLimit != null ? { monthly_tokens: policy.monthlyTokenLimit } : {}),
476
+ ...(policy.lifetimeTokenBudget != null ? { lifetime_token_budget: policy.lifetimeTokenBudget } : {}),
477
+ lifetime_tokens_consumed: consumed,
478
+ ...(policy.maxConcurrentRequests != null ? { max_concurrent: policy.maxConcurrentRequests } : {}),
479
+ scopes,
480
+ });
481
+
482
+ const value: ResolvedApiKey = {
483
+ id: row.id,
484
+ tenantId: row.tenantId,
485
+ scopes,
486
+ snapshot,
487
+ ...(policy.modelPrefix ? { modelPrefix: policy.modelPrefix } : {}),
488
+ ...(parent?.keyMode === "share" ? { cliMappingOwnerId: parent.id } : {}),
489
+ };
490
+ indexAuthCache(hash, { at: Date.now(), value, keyId: value.id });
491
+ evictAuthCacheOldest();
492
+ // Shared copy for peer processes, keyed by the token hash so a lookup can
493
+ // find it without resolving the key first. Invalidation travels by broadcast,
494
+ // since a console mutation names a key id and cannot enumerate the hashes
495
+ // that resolved to it in another process.
496
+ //
497
+ // The cached value deliberately carries `lifetime_tokens_consumed` as read.
498
+ // That figure moves constantly, and the module already documents the snapshot
499
+ // as advisory on that axis (spending is enforced by the admission counter),
500
+ // so a peer serving it stale for up to the TTL is the same tradeoff the
501
+ // in-process cache has always made.
502
+ void writeShared("auth", hash, JSON.stringify(value), AUTH_CACHE_TTL_MS);
503
+ return value;
504
+ }