cartethyia 2.5.4 → 2.5.7

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 (395) hide show
  1. package/.env.example +13 -4
  2. package/README.md +2 -2
  3. package/dist/dashboard/assets/{BannedPage-C2orBsI2.js → BannedPage-DE2607Tb.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-DE2607Tb.js.gz +0 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-Duv6mfrl.js +1 -0
  6. package/dist/dashboard/assets/CliToolDetailPage-Duv6mfrl.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-BV6pxVQo.js → CliToolsPage-C2PNYWzk.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-C2PNYWzk.js.gz +0 -0
  9. package/dist/dashboard/assets/CombosPage-TXP9OfAM.js +1 -0
  10. package/dist/dashboard/assets/CombosPage-TXP9OfAM.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-CW-wPmJ6.js → ConsoleLogPage-B-9xnAQN.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-B-9xnAQN.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-0p8uX7vB.js → CustomizationPage-xeax9nJt.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-xeax9nJt.js.gz +0 -0
  15. package/dist/dashboard/assets/{HealthEventsModal-BjqSMfko.js → HealthEventsModal-Df4jjaHJ.js} +1 -1
  16. package/dist/dashboard/assets/HealthEventsModal-Df4jjaHJ.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-BvzCx8Kr.js → LoginPage-Dyojsm84.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-Dyojsm84.js.gz +0 -0
  19. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
  20. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-BJfg4ow6.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-BJfg4ow6.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-ChCX1gmT.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-ChCX1gmT.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-Js9jUPSb.js → ProvidersPage-DeWSOEUO.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-DeWSOEUO.js.gz +0 -0
  27. package/dist/dashboard/assets/ProxyPage-CkpWln2S.js +7 -0
  28. package/dist/dashboard/assets/ProxyPage-CkpWln2S.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-B3VOgQmt.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-B3VOgQmt.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-C_klIZom.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-C_klIZom.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-DDbMQsoo.js → SetupPage-BYph-InK.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-BYph-InK.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-CB3Z7VTf.js → SortableList-CvyQCYWJ.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-CvyQCYWJ.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-D95UCPHI.js → StudioPage-Cm1JYilI.js} +3 -3
  38. package/dist/dashboard/assets/StudioPage-Cm1JYilI.js.gz +0 -0
  39. package/dist/dashboard/assets/UsagePage-DlFnc3_S.js +56 -0
  40. package/dist/dashboard/assets/UsagePage-DlFnc3_S.js.gz +0 -0
  41. package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
  42. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  43. package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
  44. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  45. package/dist/dashboard/assets/{assert-Chi7pCnt.js → assert-C9FD_JwJ.js} +1 -1
  46. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  47. package/dist/dashboard/assets/{badge-D4pQapKj.js → badge-CR72dasT.js} +1 -1
  48. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  49. package/dist/dashboard/assets/{banner-DH6cg-_B.js → banner-CI9_eSfO.js} +1 -1
  50. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  51. package/dist/dashboard/assets/{card-BKqlUOe4.js → card-DS7CQEsO.js} +1 -1
  52. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  53. package/dist/dashboard/assets/{cli-tools-fTgljTGA.js → cli-tools-CNqzrfdb.js} +1 -1
  54. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  55. package/dist/dashboard/assets/{clipboard-button-D5Ys_sM0.js → clipboard-button-611pSsaX.js} +1 -1
  56. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  57. package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
  58. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  59. package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
  60. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  61. package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
  62. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CQBr6U9c.js → entry-B9Xvtnfj.js} +1 -1
  64. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-Bog0p925.js +4 -0
  66. package/dist/dashboard/assets/entry-Bog0p925.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
  68. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  69. package/dist/dashboard/assets/{github-badge-BlptL8Uf.js → github-badge-sltk1qUi.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-js6TJvdr.js → index-CZQIrdoX.js} +2 -2
  72. package/dist/dashboard/assets/index-CZQIrdoX.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-DUWDSmDV.js → inline-BhY2sSnO.js} +1 -1
  74. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-LVjgZq4f.js → input-V1umFiBk.js} +1 -1
  76. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  77. package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
  78. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-DupvLfDx.js → list-row-1zzZYM0B.js} +1 -1
  80. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-C4pEJUqI.js → live-BivGfuUs.js} +1 -1
  82. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-BRiuufkB.js → model-bans-CmffjLuG.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-B4ROwk8L.js → mutation-46P0oWMe.js} +1 -1
  86. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  87. package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
  88. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-gmjzC3RS.js → query-keys-BFhGpwR1.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-C7tBT5o-.js → routing-CAb4F37R.js} +1 -1
  92. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-WUCXorXw.js → select-BPpSsBgL.js} +1 -1
  94. package/dist/dashboard/assets/select-BPpSsBgL.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-C_zKl1GS.js → settings-DNX9sydi.js} +1 -1
  96. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-_3O0F8Xn.js → stack-BHTfXdz_.js} +1 -1
  98. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-BgTxHcJS.js → state-mPDRlQkl.js} +1 -1
  100. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DEjTtOWf.js → switch-DOmJxg7H.js} +1 -1
  102. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  103. package/dist/dashboard/assets/tokens-COzario9.js +1 -0
  104. package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
  105. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
  106. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
  107. package/dist/dashboard/assets/{useMutation-BWSC0UAS.js → useMutation-Dem0lXQj.js} +1 -1
  108. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  109. package/dist/dashboard/index.html +1 -1
  110. package/dist/dashboard/index.html.gz +0 -0
  111. package/dist/dashboard/precompressed-assets.json +54 -54
  112. package/migrations/README.md +52 -52
  113. package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
  114. package/package.json +95 -95
  115. package/scripts/cli/admin.ts +125 -0
  116. package/scripts/cli/{ui/background.ts → background.ts} +1 -1
  117. package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -244
  118. package/scripts/cli/doctor.ts +37 -0
  119. package/scripts/cli/{support/env.ts → env.ts} +99 -99
  120. package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
  121. package/scripts/cli/instance.ts +224 -0
  122. package/scripts/cli/main.ts +61 -18
  123. package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
  124. package/scripts/cli/port.ts +100 -0
  125. package/scripts/cli/{support/services.ts → services.ts} +88 -88
  126. package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
  127. package/scripts/cli/{command/start.ts → start.ts} +10 -5
  128. package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
  129. package/scripts/cli/tray/index.ts +62 -12
  130. package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
  131. package/scripts/cli/{command/update.ts → update.ts} +39 -39
  132. package/scripts/cli/version.ts +67 -0
  133. package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
  134. package/src/boot/app.ts +697 -693
  135. package/src/boot/dependencies.ts +647 -646
  136. package/src/boot/timeout.ts +2 -13
  137. package/src/config/cooldowns.ts +186 -138
  138. package/src/config/network.ts +8 -14
  139. package/src/config/providers.ts +7 -10
  140. package/src/config/runtime.ts +76 -76
  141. package/src/config/security.ts +15 -0
  142. package/src/config/spec.ts +20 -30
  143. package/src/config/telemetry.ts +0 -20
  144. package/src/config/timeouts.ts +0 -6
  145. package/src/console/admin/model-abuse/contracts.ts +28 -6
  146. package/src/console/admin/model-abuse/routes.ts +56 -3
  147. package/src/console/api-keys/contracts.ts +410 -414
  148. package/src/console/api-keys/routes.ts +881 -880
  149. package/src/console/auth/credentials.ts +3 -3
  150. package/src/console/auth/setup-service.ts +2 -2
  151. package/src/console/backup/export-endpoint.ts +6 -2
  152. package/src/console/backup/nine-router.ts +2 -5
  153. package/src/console/backup/service.ts +4 -0
  154. package/src/console/backup/store.ts +192 -29
  155. package/src/console/backup/validate.ts +3 -45
  156. package/src/console/cli-tools/fs-ops.ts +487 -487
  157. package/src/console/console-router-guards.ts +1 -1
  158. package/src/console/data-management/contracts.ts +13 -6
  159. package/src/console/data-management/operations.ts +23 -18
  160. package/src/console/data-management/routes.ts +0 -1
  161. package/src/console/domain-registration.ts +10 -5
  162. package/src/console/observability/live.ts +22 -0
  163. package/src/console/observability/store.ts +39 -16
  164. package/src/console/observability/usage-dimensions.ts +4 -4
  165. package/src/console/providers/catalog/catalog-projections.ts +6 -7
  166. package/src/console/providers/catalog/model-operations.ts +5 -2
  167. package/src/console/providers/catalog/provider-operations.ts +4 -4
  168. package/src/console/providers/catalog/public-model-store.ts +8 -11
  169. package/src/console/providers/catalog/store.ts +1553 -1553
  170. package/src/console/providers/oauth/routes.ts +8 -12
  171. package/src/console/quota/account-quota-tenant.ts +6 -6
  172. package/src/console/quota/quota-view.ts +1 -1
  173. package/src/console/remote-support/contracts.ts +61 -0
  174. package/src/console/remote-support/routes.ts +65 -0
  175. package/src/console/remote-support/service.ts +207 -0
  176. package/src/console/response-compression.ts +0 -3
  177. package/src/console/routing/pools/probe-result.ts +3 -2
  178. package/src/console/routing/pools/probe-verdict.ts +132 -132
  179. package/src/console/routing/pools/store.ts +8 -8
  180. package/src/console/settings/contracts.ts +58 -2
  181. package/src/console/settings/store.ts +30 -3
  182. package/src/console/shared/cursor.ts +8 -8
  183. package/src/console/shared/errors.ts +1 -3
  184. package/src/network/http2-bidi.ts +2 -4
  185. package/src/network/pool/agent.ts +478 -478
  186. package/src/network/pool/selector.ts +5 -4
  187. package/src/network/retry.ts +1 -2
  188. package/src/network/ssrf.ts +5 -4
  189. package/src/observability/event-compaction.ts +80 -78
  190. package/src/observability/event-day-index.ts +13 -15
  191. package/src/observability/event-log.ts +4 -4
  192. package/src/observability/logger.ts +6 -6
  193. package/src/observability/payload-capture.ts +151 -33
  194. package/src/observability/payload-store.ts +397 -440
  195. package/src/observability/redaction.ts +5 -6
  196. package/src/observability/request-notify.ts +43 -0
  197. package/src/observability/telemetry-status.ts +8 -9
  198. package/src/observability/token-speed.ts +5 -4
  199. package/src/persistence/db-mode.ts +7 -7
  200. package/src/persistence/readiness.ts +1 -1
  201. package/src/persistence/schema.ts +1406 -1402
  202. package/src/persistence/shared-cache.ts +1 -2
  203. package/src/persistence/stores/api-key-store.ts +18 -6
  204. package/src/persistence/stores/share-store.ts +4 -3
  205. package/src/persistence/stores/telemetry-store.ts +236 -237
  206. package/src/protocol/canonical-model.ts +11 -3
  207. package/src/protocol/codecs.ts +138 -4
  208. package/src/protocol/gateway-error.ts +6 -4
  209. package/src/protocol/request/chat.ts +21 -1
  210. package/src/protocol/request/codex.ts +10 -1
  211. package/src/protocol/request/messages.ts +4 -5
  212. package/src/protocol/request/responses.ts +9 -1
  213. package/src/protocol/response/chat.ts +34 -4
  214. package/src/protocol/response/codex.ts +7 -4
  215. package/src/protocol/response/dsml.ts +140 -28
  216. package/src/protocol/response/responses.ts +11 -5
  217. package/src/protocol/stream-error-frames.ts +4 -5
  218. package/src/providers/authentication/jwt-validator.ts +9 -10
  219. package/src/providers/authentication/oauth-refresh-service.ts +9 -11
  220. package/src/providers/configured-openai-providers.ts +134 -136
  221. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  222. package/src/providers/discovery/catalog/catalog-format.ts +3 -6
  223. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  224. package/src/providers/discovery/probe-phases.ts +4 -4
  225. package/src/providers/discovery/probe-wire.ts +6 -13
  226. package/src/providers/discovery/probing-service.ts +1117 -1045
  227. package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
  228. package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
  231. package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
  232. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
  233. package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
  234. package/src/providers/integrations/buddy/buddy-media.ts +2 -2
  235. package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
  236. package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
  237. package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
  238. package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
  239. package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
  240. package/src/providers/integrations/claude/claude.ts +608 -608
  241. package/src/providers/integrations/cline/cline-quota.ts +132 -132
  242. package/src/providers/integrations/cline/cline.ts +537 -539
  243. package/src/providers/integrations/codex/codex-oauth.ts +1 -1
  244. package/src/providers/integrations/commandcode/commandcode.ts +674 -675
  245. package/src/providers/integrations/commandcode/discovery.ts +117 -116
  246. package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
  247. package/src/providers/integrations/cursor/cursor-quota.ts +309 -310
  248. package/src/providers/integrations/cursor/cursor.ts +11 -163
  249. package/src/providers/integrations/factory/factory-models.ts +3 -5
  250. package/src/providers/integrations/grok/grok-install-id.ts +2 -3
  251. package/src/providers/integrations/grok/grok.ts +6 -7
  252. package/src/providers/integrations/hermes.ts +14 -18
  253. package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
  254. package/src/providers/integrations/kiro/kiro.ts +1 -1
  255. package/src/providers/integrations/mistral.ts +6 -6
  256. package/src/providers/integrations/opencode/fingerprint.ts +3 -3
  257. package/src/providers/integrations/opencode/provider.ts +8 -8
  258. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
  259. package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
  260. package/src/providers/integrations/qoder/qoder.ts +884 -865
  261. package/src/providers/integrations/shared/connect.ts +6 -6
  262. package/src/providers/integrations/trae/trae-catalog.ts +1 -2
  263. package/src/providers/integrations/trae/trae.ts +2 -2
  264. package/src/providers/integrations/zcode/oauth.ts +6 -1
  265. package/src/providers/operations/account-health-service.ts +1528 -1523
  266. package/src/providers/operations/account-reset-service.ts +1 -1
  267. package/src/providers/operations/client-versions.ts +6 -6
  268. package/src/providers/operations/custom-cli-headers.ts +3 -4
  269. package/src/providers/operations/provider-catalog-seeder.ts +95 -95
  270. package/src/providers/operations/provider-catalog-service.ts +3 -4
  271. package/src/providers/provider-metadata.ts +4 -8
  272. package/src/providers/provider-registry.ts +6 -7
  273. package/src/providers/provider-usage.ts +1 -1
  274. package/src/security/abuse.ts +15 -10
  275. package/src/security/access-control.ts +144 -146
  276. package/src/security/admission/contracts.ts +11 -0
  277. package/src/security/admission/in-memory-store.ts +4 -3
  278. package/src/security/admission/lease-sweep.ts +6 -6
  279. package/src/security/admission/reasons.ts +5 -1
  280. package/src/security/admission/redis-store.ts +211 -210
  281. package/src/security/admission/service.ts +4 -0
  282. package/src/security/api-key-auth.ts +504 -504
  283. package/src/security/ip-boundary.ts +155 -15
  284. package/src/security/model-abuse.ts +366 -94
  285. package/src/security/outbound-headers.ts +3 -4
  286. package/src/shared/sleep.ts +4 -0
  287. package/src/transport/dispatch/attempt-finalize.ts +47 -5
  288. package/src/transport/dispatch/attempt-loop.ts +531 -530
  289. package/src/transport/dispatch/stream-bridge.ts +1 -3
  290. package/src/transport/dispatch/streaming/finalize.ts +1 -1
  291. package/src/transport/dispatch/streaming/stream.ts +5 -6
  292. package/src/transport/failure-policy.ts +162 -8
  293. package/src/transport/guards/auth-guard.ts +7 -4
  294. package/src/transport/guards/concurrency-guard.ts +0 -5
  295. package/src/transport/guards/error-lifecycle.ts +13 -6
  296. package/src/transport/guards/pipeline.ts +15 -3
  297. package/src/transport/guards/request-context.ts +8 -2
  298. package/src/transport/request/inflight.ts +5 -6
  299. package/src/transport/request/preparer.ts +2 -2
  300. package/src/transport/request/state.ts +12 -8
  301. package/src/transport/routing/admission.ts +4 -0
  302. package/src/transport/routing/route-catalog.ts +8 -1
  303. package/src/transport/routing/route-model.ts +46 -14
  304. package/src/transport/routing/router.ts +21 -14
  305. package/src/transport/surface/completion.ts +2 -3
  306. package/src/transport/surface/dialects.ts +5 -6
  307. package/src/transport/surface/responses/encode.ts +38 -0
  308. package/src/transport/surface/responses/parse.ts +4 -0
  309. package/src/transport/translation/capabilities.ts +2 -3
  310. package/src/transport/translation/quirks.ts +3 -3
  311. package/src/workers/buddy-growth-tasks.ts +5 -10
  312. package/src/workers/daily-checkin.ts +17 -23
  313. package/src/workers/quota-refresh-sweep.ts +26 -26
  314. package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
  315. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +0 -1
  316. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
  317. package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
  318. package/dist/dashboard/assets/CombosPage-Bsan4679.js +0 -1
  319. package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
  320. package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
  321. package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
  322. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
  323. package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
  324. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js +0 -1
  325. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
  326. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +0 -1
  327. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
  328. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +0 -3
  329. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
  330. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
  331. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +0 -7
  332. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
  333. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +0 -1
  334. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
  335. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +0 -1
  336. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
  337. package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
  338. package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
  339. package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
  340. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js +0 -56
  341. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
  342. package/dist/dashboard/assets/api-keys-VY68Mhd1.js +0 -1
  343. package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
  344. package/dist/dashboard/assets/app-COlK95oi.css +0 -2
  345. package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
  346. package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
  347. package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
  348. package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
  349. package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
  350. package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
  351. package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
  352. package/dist/dashboard/assets/dialog-BYj-8WuK.js +0 -1
  353. package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
  354. package/dist/dashboard/assets/drawer-CmXIZ6H1.js +0 -1
  355. package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
  356. package/dist/dashboard/assets/entry-CBg5zeu8.js +0 -1
  357. package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
  358. package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-CgdSAzEV.js +0 -4
  360. package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
  361. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
  363. package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
  365. package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-CPfxpsfy.js +0 -1
  367. package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
  368. package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
  369. package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
  370. package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
  371. package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
  372. package/dist/dashboard/assets/numbers-YNcHvRY7.js +0 -2
  373. package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
  374. package/dist/dashboard/assets/query-keys-gmjzC3RS.js.gz +0 -0
  375. package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
  376. package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
  377. package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
  378. package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
  379. package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
  380. package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
  381. package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
  382. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  383. package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
  384. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  385. package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
  386. package/scripts/cli/command/admin.ts +0 -65
  387. package/scripts/cli/command/doctor.ts +0 -257
  388. package/scripts/cli/gateway/drain-token.ts +0 -78
  389. package/scripts/cli/gateway/instance-lock.ts +0 -105
  390. package/scripts/cli/gateway/stop.ts +0 -51
  391. package/scripts/cli/support/bun.ts +0 -27
  392. package/scripts/cli/support/version.ts +0 -37
  393. /package/dist/dashboard/assets/{app-DCQZRmHY.js → app-BIt446yE.js} +0 -0
  394. /package/dist/dashboard/assets/{app-DCQZRmHY.js.gz → app-BIt446yE.js.gz} +0 -0
  395. /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
@@ -1,504 +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
- 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
- }
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 uses when no source is installed.
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. A repeat miss for an unknown/revoked token within
89
+ * 1.5 s costs one HMAC + one map lookup and no query. Keyed by the full HMAC: a
90
+ * cheap pre-hash would let a crafted collision reject someone else's valid key,
91
+ * while the HMAC key can only match its own token. Bound below beside the
92
+ * 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 a token the negative cache has already rejected, so
140
+ // negative entries 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 goes stale the moment an operator tightens a
444
+ // limit or blocks a client router on the parent — existing recipients would
445
+ // keep the issued 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 makes.
502
+ void writeShared("auth", hash, JSON.stringify(value), AUTH_CACHE_TTL_MS);
503
+ return value;
504
+ }