cartethyia 2.4.6 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. package/README.md +45 -13
  2. package/dist/dashboard/assets/BannedPage-9igfctfZ.js +1 -0
  3. package/dist/dashboard/assets/BannedPage-9igfctfZ.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-Bcy150Jo.js → CliToolDetailPage-ZRr2K2sF.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-ZRr2K2sF.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-DyNXefph.js → CliToolsPage-D3xqZbN-.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-D3xqZbN-.js.gz +0 -0
  8. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js +1 -0
  9. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CATpAEOz.js → ConsoleLogPage-u6W1UXSc.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-D6jEU7nL.js → CustomizationPage-CTTpCb25.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-CTTpCb25.js.gz +0 -0
  14. package/dist/dashboard/assets/{HealthEventsModal-CZkpn0a7.js → HealthEventsModal-C5XpWlvy.js} +1 -1
  15. package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-BJlMVN4U.js → LoginPage-DHoB3Agn.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-DHoB3Agn.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-Br3CXEau.js → ModelPicker-oQ-SDXp8.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js +3 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +1 -0
  25. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  26. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js +1 -0
  27. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js.gz +0 -0
  28. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js +7 -0
  29. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js.gz +0 -0
  30. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js +1 -0
  31. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js.gz +0 -0
  32. package/dist/dashboard/assets/{SettingsPage-BHW4Zt06.js → SettingsPage-CvjZxHE4.js} +1 -1
  33. package/dist/dashboard/assets/SettingsPage-CvjZxHE4.js.gz +0 -0
  34. package/dist/dashboard/assets/{SetupPage-VOYC_lIa.js → SetupPage-D3nza7LH.js} +1 -1
  35. package/dist/dashboard/assets/SetupPage-D3nza7LH.js.gz +0 -0
  36. package/dist/dashboard/assets/SortableList-Dtt12WT1.js +1 -0
  37. package/dist/dashboard/assets/SortableList-Dtt12WT1.js.gz +0 -0
  38. package/dist/dashboard/assets/{StudioPage-BNIDLiU6.js → StudioPage-CbK3hXvU.js} +4 -4
  39. package/dist/dashboard/assets/StudioPage-CbK3hXvU.js.gz +0 -0
  40. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js +56 -0
  41. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js.gz +0 -0
  42. package/dist/dashboard/assets/{api-keys-BETfaMYZ.js → api-keys-BmDbsior.js} +1 -1
  43. package/dist/dashboard/assets/api-keys-BmDbsior.js.gz +0 -0
  44. package/dist/dashboard/assets/app-BbpSarVV.css +2 -0
  45. package/dist/dashboard/assets/app-BbpSarVV.css.gz +0 -0
  46. package/dist/dashboard/assets/assert-BHfaei-X.js +1 -0
  47. package/dist/dashboard/assets/assert-BHfaei-X.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-BTyGk_DL.js → badge-DpXT4Ixt.js} +1 -1
  49. package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-bujTWYM4.js → banner-CSAAWLJv.js} +1 -1
  51. package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-B0Bnx5CA.js → card-DEhRXXw4.js} +1 -1
  53. package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-DE2ug812.js → cli-tools-B0wBq-RL.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-R_rArH8S.js → clipboard-button-DOo6EU8O.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-BWIP_EvW.js → dialog-BvQeBMfP.js} +1 -1
  59. package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-BKcgVugF.js → drawer-CMwPXwQB.js} +1 -1
  61. package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-CcWkGR4a.js → entry-D4O2229X.js} +1 -1
  63. package/dist/dashboard/assets/{entry-CcWkGR4a.js.gz → entry-D4O2229X.js.gz} +0 -0
  64. package/dist/dashboard/assets/{entry-kTiTFrvh.js → entry-DKvr5Q9K.js} +1 -1
  65. package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-FQCejoDY.js +4 -0
  67. package/dist/dashboard/assets/entry-FQCejoDY.js.gz +0 -0
  68. package/dist/dashboard/assets/{github-badge-Booc5mNe.js → github-badge-zliLvBwr.js} +1 -1
  69. package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-D70pChfn.js → index-CXm3FIyt.js} +2 -2
  71. package/dist/dashboard/assets/index-CXm3FIyt.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-D1TCqM2D.js → inline-D1IlZZ8H.js} +1 -1
  73. package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-B9oyU93y.js → input-BpVZdrbc.js} +1 -1
  75. package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-BArzXirQ.js → layout-BioLW7gN.js} +1 -1
  77. package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-BVZzaoHF.js → list-row-CwVcGAIc.js} +1 -1
  79. package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-EmuIayqp.js → live-yfEJFF9T.js} +1 -1
  81. package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
  82. package/dist/dashboard/assets/{model-bans-CSPQ9xB0.js → model-bans-CT-TxSNX.js} +1 -1
  83. package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
  84. package/dist/dashboard/assets/providers/cursor.webp +0 -0
  85. package/dist/dashboard/assets/providers/trae.webp +0 -0
  86. package/dist/dashboard/assets/{routing-BExgxxBU.js → routing-Cs6jl49L.js} +1 -1
  87. package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
  88. package/dist/dashboard/assets/select-C0Rer3gF.js +41 -0
  89. package/dist/dashboard/assets/select-C0Rer3gF.js.gz +0 -0
  90. package/dist/dashboard/assets/settings-CfcTcOyU.js +1 -0
  91. package/dist/dashboard/assets/settings-CfcTcOyU.js.gz +0 -0
  92. package/dist/dashboard/assets/{stack-Ca5HxI5i.js → stack-C9_uUjaA.js} +1 -1
  93. package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
  94. package/dist/dashboard/assets/{state-CzUPfLXo.js → state-DA0B95UQ.js} +1 -1
  95. package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
  96. package/dist/dashboard/assets/{switch-DOUIky_i.js → switch-Cittxy-x.js} +1 -1
  97. package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
  98. package/dist/dashboard/assets/{toast-C76WrMhc.js → toast-CNt_jzO4.js} +1 -1
  99. package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
  100. package/dist/dashboard/assets/{useMutation-D87FZwH0.js → useMutation-BQKMcJ1H.js} +1 -1
  101. package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
  102. package/dist/dashboard/index.html +1 -1
  103. package/dist/dashboard/index.html.gz +0 -0
  104. package/dist/dashboard/precompressed-assets.json +51 -52
  105. package/migrations/full-postgres/0015_add_payload_stages.sql +18 -0
  106. package/migrations/full-postgres/0016_add_share_link_slug.sql +20 -0
  107. package/migrations/full-postgres/0017_add_models_pro_only.sql +18 -0
  108. package/migrations/full-postgres/0018_add_accounts_quota_plan.sql +18 -0
  109. package/migrations/full-postgres/0019_add_models_free_tier.sql +15 -0
  110. package/migrations/lite-sqlite3/0015_add_payload_stages.sql +18 -0
  111. package/migrations/lite-sqlite3/0016_add_share_link_slug.sql +20 -0
  112. package/migrations/lite-sqlite3/0017_add_models_pro_only.sql +18 -0
  113. package/migrations/lite-sqlite3/0018_add_accounts_quota_plan.sql +18 -0
  114. package/migrations/lite-sqlite3/0019_add_models_free_tier.sql +15 -0
  115. package/package.json +3 -2
  116. package/scripts/cli/command/console-menu.ts +30 -11
  117. package/scripts/cli/command/start.ts +77 -13
  118. package/scripts/cli/command/update.ts +1 -1
  119. package/scripts/cli/gateway/instance-lock.ts +105 -0
  120. package/scripts/cli/gateway/spawn.ts +173 -101
  121. package/scripts/cli/ui/background.ts +110 -49
  122. package/src/boot/app.ts +20 -6
  123. package/src/boot/dependencies.ts +19 -0
  124. package/src/boot/elysia-typebox-bridge.ts +37 -0
  125. package/src/boot/lifecycle.ts +2 -0
  126. package/src/config/cooldowns.ts +138 -138
  127. package/src/config/providers.ts +5 -5
  128. package/src/config/runtime.ts +19 -3
  129. package/src/config/spec.ts +9 -0
  130. package/src/console/api-keys/contracts.ts +414 -391
  131. package/src/console/api-keys/routes.ts +880 -870
  132. package/src/console/auth/account-reset-store.ts +14 -0
  133. package/src/console/auth/account-reset.ts +3 -2
  134. package/src/console/auth/setup-service.ts +6 -3
  135. package/src/console/domain-registration.ts +9 -2
  136. package/src/console/observability/contracts.ts +475 -455
  137. package/src/console/observability/store.ts +1015 -952
  138. package/src/console/providers/catalog/contracts.ts +31 -0
  139. package/src/console/providers/catalog/provider-operations.ts +8 -0
  140. package/src/console/providers/catalog/store.ts +1532 -1525
  141. package/src/console/providers/oauth/callback-listener.ts +18 -3
  142. package/src/console/providers/oauth/routes.ts +38 -15
  143. package/src/console/quota/refresh.ts +19 -0
  144. package/src/console/updates/routes.ts +162 -4
  145. package/src/main.ts +3 -0
  146. package/src/network/http2-bidi.ts +85 -0
  147. package/src/network/outbound-fetch.ts +1 -26
  148. package/src/network/pool/agent.ts +478 -460
  149. package/src/network/pool/resolver.ts +1104 -544
  150. package/src/network/pool-health-machine.ts +12 -5
  151. package/src/observability/payload-capture.ts +344 -324
  152. package/src/persistence/db-pg.ts +9 -3
  153. package/src/persistence/db-sqlite.ts +497 -495
  154. package/src/persistence/health-event-flush.ts +137 -0
  155. package/src/persistence/migrate.ts +181 -129
  156. package/src/persistence/schema.ts +1360 -1295
  157. package/src/persistence/settings-revision.ts +25 -0
  158. package/src/persistence/shared-cache.ts +217 -0
  159. package/src/persistence/stores/share-store.ts +556 -528
  160. package/src/persistence/usage-flush.ts +61 -1
  161. package/src/protocol/canonical-model.ts +23 -1
  162. package/src/protocol/response/chat.ts +143 -10
  163. package/src/protocol/response/omp-tool-call-markup.ts +221 -0
  164. package/src/providers/default-registry.ts +173 -1
  165. package/src/providers/discovery/openai-model-discovery.ts +39 -3
  166. package/src/providers/discovery/openrouter-free-roster.ts +98 -0
  167. package/src/providers/discovery/probing-service.ts +1022 -1001
  168. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +39 -0
  169. package/src/providers/integrations/buddy/buddy-chat-shared.ts +913 -914
  170. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +18 -5
  171. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +4 -0
  172. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -175
  173. package/src/providers/integrations/buddy/codebuddy/shared.ts +15 -6
  174. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -204
  175. package/src/providers/integrations/buddy/workbuddy/shared.ts +3 -3
  176. package/src/providers/integrations/claude/claude.ts +606 -582
  177. package/src/providers/integrations/cline/cline-quota.ts +18 -16
  178. package/src/providers/integrations/cline/cline.ts +539 -468
  179. package/src/providers/integrations/commandcode/commandcode.ts +675 -541
  180. package/src/providers/integrations/commandcode/discovery.ts +116 -0
  181. package/src/providers/integrations/commandcode/quota.ts +160 -0
  182. package/src/providers/integrations/cursor/cursor-auth.ts +418 -0
  183. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -0
  184. package/src/providers/integrations/cursor/cursor-http2.ts +300 -0
  185. package/src/providers/integrations/cursor/cursor-identity.ts +92 -0
  186. package/src/providers/integrations/cursor/cursor-request.ts +471 -0
  187. package/src/providers/integrations/cursor/cursor-session-manager.ts +281 -0
  188. package/src/providers/integrations/cursor/cursor-shared.ts +108 -0
  189. package/src/providers/integrations/cursor/cursor.ts +960 -0
  190. package/src/providers/integrations/cursor/protocol/framing.ts +129 -0
  191. package/src/providers/integrations/cursor/protocol/messages.ts +2355 -0
  192. package/src/providers/integrations/cursor/protocol/struct.ts +305 -0
  193. package/src/providers/integrations/github/github.ts +4 -4
  194. package/src/providers/integrations/hermes.ts +79 -8
  195. package/src/providers/integrations/kilo/kilo-discovery.ts +10 -1
  196. package/src/providers/integrations/kilo/kilo.ts +153 -146
  197. package/src/providers/integrations/opencode/provider.ts +532 -538
  198. package/src/providers/integrations/openrouter/provider.ts +139 -281
  199. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -0
  200. package/src/providers/integrations/qoder/qoder-oauth.ts +310 -257
  201. package/src/providers/integrations/qoder/qoder-realm.ts +106 -0
  202. package/src/providers/integrations/qoder/qoder-virtual.ts +77 -0
  203. package/src/providers/integrations/qoder/qoder.ts +865 -730
  204. package/src/providers/integrations/trae/trae-catalog.ts +190 -0
  205. package/src/providers/integrations/trae/trae-headers.ts +149 -0
  206. package/src/providers/integrations/trae/trae-oauth.ts +773 -0
  207. package/src/providers/integrations/trae/trae-protocol.ts +400 -0
  208. package/src/providers/integrations/trae/trae-realm.ts +220 -0
  209. package/src/providers/integrations/trae/trae.ts +458 -0
  210. package/src/providers/operations/account-health-service.ts +107 -10
  211. package/src/providers/operations/client-versions.ts +133 -6
  212. package/src/providers/operations/provider-catalog-seeder.ts +95 -86
  213. package/src/providers/operations/provider-credential-service.ts +46 -7
  214. package/src/providers/provider-contracts.ts +76 -0
  215. package/src/providers/provider-metadata.ts +78 -6
  216. package/src/providers/provider-model-definition.ts +373 -349
  217. package/src/security/api-key-auth.ts +126 -8
  218. package/src/transport/contracts.ts +3 -0
  219. package/src/transport/dispatch/attempt-finalize.ts +32 -1
  220. package/src/transport/dispatch/attempt-loop.ts +530 -515
  221. package/src/transport/dispatch/generation.ts +88 -21
  222. package/src/transport/dispatch/leases.ts +99 -2
  223. package/src/transport/dispatch/proxy-request.ts +60 -3
  224. package/src/transport/dispatch/streaming/finalize.ts +16 -1
  225. package/src/transport/dispatch/streaming/stream.ts +22 -2
  226. package/src/transport/dispatch/upstream.ts +19 -1
  227. package/src/transport/guards/auth-guard.ts +26 -1
  228. package/src/transport/guards/body-policy.ts +5 -4
  229. package/src/transport/request/preparer.ts +103 -0
  230. package/src/transport/request/state.ts +14 -0
  231. package/src/transport/routing/plan-gate.ts +45 -0
  232. package/src/transport/routing/route-catalog.ts +799 -777
  233. package/src/transport/routing/route-model.ts +52 -1
  234. package/src/transport/routing/router.ts +51 -0
  235. package/dist/dashboard/assets/BannedPage-QhvbJB9o.js +0 -1
  236. package/dist/dashboard/assets/BannedPage-QhvbJB9o.js.gz +0 -0
  237. package/dist/dashboard/assets/CliToolDetailPage-Bcy150Jo.js.gz +0 -0
  238. package/dist/dashboard/assets/CliToolsPage-DyNXefph.js.gz +0 -0
  239. package/dist/dashboard/assets/CombosPage-Cak0ljnO.js +0 -1
  240. package/dist/dashboard/assets/CombosPage-Cak0ljnO.js.gz +0 -0
  241. package/dist/dashboard/assets/ConsoleLogPage-CATpAEOz.js.gz +0 -0
  242. package/dist/dashboard/assets/CustomizationPage-D6jEU7nL.js.gz +0 -0
  243. package/dist/dashboard/assets/HealthEventsModal-CZkpn0a7.js.gz +0 -0
  244. package/dist/dashboard/assets/LoginPage-BJlMVN4U.js.gz +0 -0
  245. package/dist/dashboard/assets/ModelPicker-Br3CXEau.js.gz +0 -0
  246. package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js +0 -1
  247. package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js.gz +0 -0
  248. package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js +0 -3
  249. package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js.gz +0 -0
  250. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js +0 -1
  251. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js.gz +0 -0
  252. package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js +0 -1
  253. package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js.gz +0 -0
  254. package/dist/dashboard/assets/ProxyPage-CXiumA53.js +0 -7
  255. package/dist/dashboard/assets/ProxyPage-CXiumA53.js.gz +0 -0
  256. package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js +0 -1
  257. package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js.gz +0 -0
  258. package/dist/dashboard/assets/SettingsPage-BHW4Zt06.js.gz +0 -0
  259. package/dist/dashboard/assets/SetupPage-VOYC_lIa.js.gz +0 -0
  260. package/dist/dashboard/assets/SortableList-DpOTnGuH.js +0 -1
  261. package/dist/dashboard/assets/SortableList-DpOTnGuH.js.gz +0 -0
  262. package/dist/dashboard/assets/StudioPage-BNIDLiU6.js.gz +0 -0
  263. package/dist/dashboard/assets/UsagePage-BAFaSDQV.js +0 -56
  264. package/dist/dashboard/assets/UsagePage-BAFaSDQV.js.gz +0 -0
  265. package/dist/dashboard/assets/api-keys-BETfaMYZ.js.gz +0 -0
  266. package/dist/dashboard/assets/app-BLCvGdzb.css +0 -2
  267. package/dist/dashboard/assets/app-BLCvGdzb.css.gz +0 -0
  268. package/dist/dashboard/assets/assert-7J6_LVD3.js +0 -1
  269. package/dist/dashboard/assets/assert-7J6_LVD3.js.gz +0 -0
  270. package/dist/dashboard/assets/badge-BTyGk_DL.js.gz +0 -0
  271. package/dist/dashboard/assets/banner-bujTWYM4.js.gz +0 -0
  272. package/dist/dashboard/assets/card-B0Bnx5CA.js.gz +0 -0
  273. package/dist/dashboard/assets/cli-tools-DE2ug812.js.gz +0 -0
  274. package/dist/dashboard/assets/clipboard-button-R_rArH8S.js.gz +0 -0
  275. package/dist/dashboard/assets/dialog-BWIP_EvW.js.gz +0 -0
  276. package/dist/dashboard/assets/drawer-BKcgVugF.js.gz +0 -0
  277. package/dist/dashboard/assets/entry-7dpgvpJ3.js +0 -4
  278. package/dist/dashboard/assets/entry-7dpgvpJ3.js.gz +0 -0
  279. package/dist/dashboard/assets/entry-kTiTFrvh.js.gz +0 -0
  280. package/dist/dashboard/assets/github-badge-Booc5mNe.js.gz +0 -0
  281. package/dist/dashboard/assets/index-D70pChfn.js.gz +0 -0
  282. package/dist/dashboard/assets/inline-D1TCqM2D.js.gz +0 -0
  283. package/dist/dashboard/assets/input-B9oyU93y.js.gz +0 -0
  284. package/dist/dashboard/assets/layout-BArzXirQ.js.gz +0 -0
  285. package/dist/dashboard/assets/list-row-BVZzaoHF.js.gz +0 -0
  286. package/dist/dashboard/assets/live-EmuIayqp.js.gz +0 -0
  287. package/dist/dashboard/assets/model-bans-CSPQ9xB0.js.gz +0 -0
  288. package/dist/dashboard/assets/routing-BExgxxBU.js.gz +0 -0
  289. package/dist/dashboard/assets/select-C9mMoEpJ.js +0 -41
  290. package/dist/dashboard/assets/select-C9mMoEpJ.js.gz +0 -0
  291. package/dist/dashboard/assets/settings-C4_Gn_wq.js +0 -1
  292. package/dist/dashboard/assets/settings-C4_Gn_wq.js.gz +0 -0
  293. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js +0 -1
  294. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js.gz +0 -0
  295. package/dist/dashboard/assets/stack-Ca5HxI5i.js.gz +0 -0
  296. package/dist/dashboard/assets/state-CzUPfLXo.js.gz +0 -0
  297. package/dist/dashboard/assets/switch-DOUIky_i.js.gz +0 -0
  298. package/dist/dashboard/assets/toast-C76WrMhc.js.gz +0 -0
  299. package/dist/dashboard/assets/useMutation-D87FZwH0.js.gz +0 -0
  300. package/src/network/tls/bun-client-hello.ts +0 -112
  301. /package/dist/dashboard/assets/{app-Cjq7nrwu.js → app-C-Y4oZT0.js} +0 -0
  302. /package/dist/dashboard/assets/{app-Cjq7nrwu.js.gz → app-C-Y4oZT0.js.gz} +0 -0
@@ -1,870 +1,880 @@
1
- // Console API-key domain: HTTP routes.
2
- //
3
- // Owns validation, CRUD operations, and route wiring through the ApiKeyStore.
4
-
5
- import { Elysia, t } from "elysia";
6
- import { randomBytes, randomUUID } from "node:crypto";
7
- import { decryptCredentialToString, encryptCredential } from "../../security/crypto";
8
- import { invalidateApiKeyCache } from "../../security/api-key-auth";
9
- import { hashShareToken } from "../../persistence/stores/share-store";
10
- import type { AccessDecision } from "../../security/access-control";
11
- import { ConsoleDomainError, errorResponse, requireTenantScope } from "../shared/errors";
12
- import { literalUnion } from "../shared/elysia-schema";
13
- import { API_KEY_MODES, API_KEY_MODEL_ACCESS_MODES, type ShareLinkKind } from "../../persistence/schema";
14
- import type { ApiKeyConfig, ApiKeyResponse, UpdateApiKeyResponse } from "./contracts";
15
- import type { ApiKeyPatch, ApiKeyRecord } from "../../persistence/stores/api-key-store";
16
- import {
17
- generateApiKeySecret,
18
- isDefaultGatewayApiKey,
19
- parseSharePopupImage,
20
- sanitizeApiKeyResponse,
21
- validateApiKeyRequest,
22
- parseRequest,
23
- finitePositive,
24
- type CreateApiKeyRequest,
25
- type CreateApiKeyResponse,
26
- resolveKeyPrefix,
27
- prepareCustomKey,
28
- mapShareLinkResponse,
29
- type ShareKeyResponse,
30
- type ShareLinkResponse,
31
- resolveShareKeyPrefix,
32
- } from "./contracts";
33
- import type { SharedKeyActivityDetail, SharedKeySummary } from "../share/share-usage";
34
- import { popupImageBytes } from "./share-popup-image";
35
-
36
- export function createApiKeyOperations(config: ApiKeyConfig) {
37
- const operations = {
38
- async listKeys(access: AccessDecision | undefined): Promise<ApiKeyResponse[]> {
39
- const authorized = requireTenantScope(access, "dashboard:read");
40
- const records = await config.store.list(authorized.tenantId);
41
- const topLevel = records.filter(
42
- (record) => record.revokedAt === undefined && record.parentKeyId === undefined,
43
- );
44
- // A share template's own counter never moves for child traffic, so
45
- // reporting it raw would show a template as untouched while the gateway
46
- // is refusing its recipients on the parent budget. Report the figure the
47
- // gateway actually enforces: the template plus every recipient it has
48
- // issued, revoked ones included (their spend is already spent).
49
- const sumChildren = config.store.sumChildrenConsumed?.bind(config.store);
50
- return Promise.all(
51
- topLevel.map(async (record) => {
52
- const response = sanitizeApiKeyResponse(record);
53
- if (record.keyMode !== "share" || !sumChildren) return response;
54
- const children = await sumChildren(record.id).catch(() => 0);
55
- return children > 0
56
- ? { ...response, tokensConsumed: response.tokensConsumed + children }
57
- : response;
58
- }),
59
- );
60
- },
61
- /** Persists a new credential order. `keyIds` must name every top-level key
62
- the tenant has, so a partial list cannot silently renumber rows. */
63
- async reorderKeys(access: AccessDecision | undefined, keyIds: readonly string[]): Promise<void> {
64
- const authorized = requireTenantScope(access, "dashboard:write");
65
- const records = await config.store.list(authorized.tenantId);
66
- const reorderable = records.filter(
67
- (record) => record.revokedAt === undefined && record.parentKeyId === undefined,
68
- );
69
- const known = new Set(reorderable.map((record) => record.id));
70
- const seen = new Set<string>();
71
- for (const keyId of keyIds) {
72
- if (!known.has(keyId))
73
- throw new ConsoleDomainError("key_not_found", 404, `Key not found: ${keyId}`);
74
- if (seen.has(keyId))
75
- throw new ConsoleDomainError("invalid_request", 422, `Duplicate key id: ${keyId}`);
76
- seen.add(keyId);
77
- }
78
- if (seen.size !== known.size)
79
- throw new ConsoleDomainError(
80
- "invalid_request",
81
- 422,
82
- "Reorder must list every key exactly once",
83
- );
84
- await config.store.reorder(authorized.tenantId, keyIds);
85
- },
86
- async getKeyDetail(access: AccessDecision | undefined, keyId: string): Promise<ApiKeyResponse> {
87
- const authorized = requireTenantScope(access, "dashboard:read");
88
- const record = await config.store.get(authorized.tenantId, keyId);
89
- if (!record) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
90
- return sanitizeApiKeyResponse(record);
91
- },
92
- async getSharePopupImage(
93
- access: AccessDecision | undefined,
94
- keyId: string,
95
- ): Promise<{ mime: string; image: Buffer }> {
96
- const authorized = requireTenantScope(access, "dashboard:read");
97
- const record = await config.store.get(authorized.tenantId, keyId);
98
- if (!record) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
99
- if (record.sharePopupImage === undefined || record.sharePopupImageMime === undefined)
100
- throw new ConsoleDomainError("key_not_found", 404, "This key has no popup image");
101
- return { mime: record.sharePopupImageMime, image: record.sharePopupImage };
102
- },
103
- async createKey(
104
- access: AccessDecision | undefined,
105
- request: CreateApiKeyRequest,
106
- ): Promise<CreateApiKeyResponse> {
107
- const authorized = requireTenantScope(access, "dashboard:write");
108
- const scopes = validateApiKeyRequest(request);
109
- const popupImage = parseSharePopupImage(request.sharePopupImage);
110
- const keyMode = request.keyMode ?? "personal";
111
- const keyPrefix =
112
- keyMode === "share"
113
- ? resolveShareKeyPrefix(request.keyPrefix)
114
- : resolveKeyPrefix(request.keyPrefix);
115
- const generated =
116
- keyMode === "personal"
117
- ? request.key === undefined
118
- ? generateApiKeySecret(keyPrefix)
119
- : { ...prepareCustomKey(request.key), prefix: keyPrefix }
120
- : undefined;
121
- const record: ApiKeyRecord = {
122
- id: randomUUID(),
123
- tenantId: authorized.tenantId,
124
- enabled: request.enabled ?? true,
125
- label: request.label?.trim() || (keyMode === "share" ? "Share template" : "API key"),
126
- keyHash: generated?.hash ?? null,
127
- keyMode,
128
- scopes,
129
- // Only first boot seeds the default; a key created from the console is
130
- // never it, so it stays renamable and revocable.
131
- isDefault: false,
132
- keyPrefix: generated?.prefix ?? keyPrefix,
133
- ...(generated ? { keyEncrypted: encryptCredential(generated.secret) } : {}),
134
- ...(request.notesTitle === undefined ? {} : { notesTitle: request.notesTitle }),
135
- ...(request.notesSubtitle === undefined ? {} : { notesSubtitle: request.notesSubtitle }),
136
- ...(request.notesBody === undefined ? {} : { notesBody: request.notesBody }),
137
- ...(request.sharePopupEnabled == null ? {} : { sharePopupEnabled: request.sharePopupEnabled }),
138
- ...(popupImage
139
- ? { sharePopupImage: popupImage.image, sharePopupImageMime: popupImage.mime }
140
- : {}),
141
- ...(request.sharePopupTitle?.trim() ? { sharePopupTitle: request.sharePopupTitle.trim() } : {}),
142
- ...(request.sharePopupBody?.trim() ? { sharePopupBody: request.sharePopupBody.trim() } : {}),
143
- ...(request.requestsPerMinute == null ? {} : { requestsPerMinute: request.requestsPerMinute }),
144
- ...(request.dailyTokenLimit == null ? {} : { dailyTokenLimit: request.dailyTokenLimit }),
145
- ...(request.monthlyTokenLimit == null ? {} : { monthlyTokenLimit: request.monthlyTokenLimit }),
146
- ...(request.lifetimeTokenBudget == null ? {} : { lifetimeTokenBudget: request.lifetimeTokenBudget }),
147
- ...(request.maxConcurrentRequests == null ? {} : { maxConcurrentRequests: request.maxConcurrentRequests }),
148
- ...(request.modelPrefix === undefined ? {} : { modelPrefix: request.modelPrefix }),
149
- modelAccessMode: request.modelAccessMode ?? "whitelist",
150
- ...(request.modelList === undefined ? {} : { modelList: request.modelList }),
151
- ...(request.clientRouterDenylist === undefined
152
- ? {}
153
- : { clientRouterDenylist: request.clientRouterDenylist }),
154
- createdAt: new Date(),
155
- tokensConsumed: 0,
156
- };
157
- await config.store.create(record);
158
- await config.auditSink?.record({
159
- access: authorized,
160
- action: "api_key.created",
161
- target: record.id,
162
- detail: { scopes, keyMode },
163
- });
164
- return {
165
- ...sanitizeApiKeyResponse(record),
166
- ...(generated ? { secret: generated.secret } : {}),
167
- };
168
- },
169
- async updateKey(
170
- access: AccessDecision | undefined,
171
- keyId: string,
172
- request: Partial<CreateApiKeyRequest>,
173
- ): Promise<UpdateApiKeyResponse> {
174
- const authorized = requireTenantScope(access, "dashboard:write");
175
- const patchRequest = parseRequest(request);
176
- const current = await config.store.get(authorized.tenantId, keyId);
177
- if (!current || current.revokedAt !== undefined)
178
- throw new ConsoleDomainError("key_not_found", 404, "Key not found");
179
- if (current.parentKeyId !== undefined)
180
- throw new ConsoleDomainError(
181
- "shared_key_managed_by_parent",
182
- 409,
183
- "Edit or revoke this key through its share parent",
184
- );
185
-
186
- validateApiKeyRequest(patchRequest);
187
- finitePositive(patchRequest.requestsPerMinute, "requestsPerMinute");
188
- finitePositive(patchRequest.dailyTokenLimit, "dailyTokenLimit");
189
- finitePositive(patchRequest.monthlyTokenLimit, "monthlyTokenLimit");
190
- finitePositive(patchRequest.lifetimeTokenBudget, "lifetimeTokenBudget");
191
- finitePositive(patchRequest.maxConcurrentRequests, "maxConcurrentRequests");
192
-
193
- const nextMode = patchRequest.keyMode ?? current.keyMode;
194
- const modeChanged = nextMode !== current.keyMode;
195
- const nextPrefix =
196
- nextMode === "share"
197
- ? resolveShareKeyPrefix(
198
- patchRequest.keyPrefix ?? (modeChanged ? undefined : current.keyPrefix),
199
- )
200
- : resolveKeyPrefix(patchRequest.keyPrefix ?? current.keyPrefix);
201
- const prefixChanged =
202
- patchRequest.keyPrefix !== undefined &&
203
- nextPrefix !== resolveKeyPrefix(current.keyPrefix);
204
-
205
- let secret: string | undefined;
206
- let credentialPatch: ApiKeyPatch = {};
207
- if (modeChanged && nextMode === "share") {
208
- credentialPatch = {
209
- keyMode: "share",
210
- keyHash: null,
211
- keyEncrypted: null,
212
- keyPrefix: nextPrefix,
213
- };
214
- } else if (
215
- nextMode === "personal" &&
216
- (modeChanged || patchRequest.key !== undefined || prefixChanged)
217
- ) {
218
- const generated = patchRequest.key === undefined
219
- ? generateApiKeySecret(
220
- patchRequest.keyPrefix === undefined
221
- ? undefined
222
- : resolveKeyPrefix(patchRequest.keyPrefix),
223
- )
224
- : {
225
- ...prepareCustomKey(patchRequest.key),
226
- prefix: resolveKeyPrefix(patchRequest.keyPrefix ?? current.keyPrefix),
227
- };
228
- secret = generated.secret;
229
- credentialPatch = {
230
- ...(modeChanged ? { keyMode: "personal" as const } : {}),
231
- keyHash: generated.hash,
232
- keyEncrypted: encryptCredential(generated.secret),
233
- keyPrefix: generated.prefix,
234
- };
235
- } else if (nextMode === "share" && nextPrefix !== current.keyPrefix) {
236
- credentialPatch = { keyPrefix: nextPrefix };
237
- }
238
-
239
- const scopes =
240
- patchRequest.scopes === undefined ? undefined : validateApiKeyRequest(patchRequest);
241
- const popupImage =
242
- patchRequest.sharePopupImage === undefined
243
- ? undefined
244
- : parseSharePopupImage(patchRequest.sharePopupImage);
245
- const updated = await config.store.update(authorized.tenantId, keyId, {
246
- ...(patchRequest.enabled === undefined ? {} : { enabled: patchRequest.enabled }),
247
- ...(isDefaultGatewayApiKey(current) ? { isDefault: true } : {}),
248
- ...credentialPatch,
249
- ...(scopes === undefined ? {} : { scopes }),
250
- // Rename. The label was accepted by the body schema and validated, but
251
- // never copied into the patch — so "save" silently kept the old name and
252
- // the key looked unrenamable. Blank falls back to the existing label
253
- // rather than wiping it, since `label` is NOT NULL. The default key is
254
- // renameable like any other (c2a93545); gating on `isDefault` dropped
255
- // exactly the one patch that changes a name, and left the store with an
256
- // empty `set(...)` that threw.
257
- ...(patchRequest.label === undefined
258
- ? {}
259
- : { label: patchRequest.label.trim() === "" ? current.label : patchRequest.label.trim() }),
260
- ...(patchRequest.notesTitle === undefined
261
- ? {}
262
- : { notesTitle: patchRequest.notesTitle.trim() === "" ? null : patchRequest.notesTitle }),
263
- ...(patchRequest.notesSubtitle === undefined
264
- ? {}
265
- : { notesSubtitle: patchRequest.notesSubtitle.trim() === "" ? null : patchRequest.notesSubtitle }),
266
- ...(patchRequest.notesBody === undefined
267
- ? {}
268
- : { notesBody: patchRequest.notesBody.trim() === "" ? null : patchRequest.notesBody }),
269
- ...(patchRequest.sharePopupEnabled === undefined ? {} : { sharePopupEnabled: patchRequest.sharePopupEnabled }),
270
- ...(popupImage === undefined
271
- ? {}
272
- : popupImage === null
273
- ? { sharePopupImage: null, sharePopupImageMime: null }
274
- : { sharePopupImage: popupImage.image, sharePopupImageMime: popupImage.mime }),
275
- ...(patchRequest.sharePopupTitle === undefined ? {} : { sharePopupTitle: patchRequest.sharePopupTitle.trim() || null }),
276
- ...(patchRequest.sharePopupBody === undefined ? {} : { sharePopupBody: patchRequest.sharePopupBody.trim() || null }),
277
- ...(patchRequest.requestsPerMinute === undefined ? {} : { requestsPerMinute: patchRequest.requestsPerMinute }),
278
- ...(patchRequest.dailyTokenLimit === undefined ? {} : { dailyTokenLimit: patchRequest.dailyTokenLimit }),
279
- ...(patchRequest.monthlyTokenLimit === undefined ? {} : { monthlyTokenLimit: patchRequest.monthlyTokenLimit }),
280
- ...(patchRequest.lifetimeTokenBudget === undefined ? {} : { lifetimeTokenBudget: patchRequest.lifetimeTokenBudget }),
281
- ...(patchRequest.maxConcurrentRequests === undefined ? {} : { maxConcurrentRequests: patchRequest.maxConcurrentRequests }),
282
- ...(patchRequest.modelPrefix === undefined ? {} : { modelPrefix: patchRequest.modelPrefix }),
283
- ...(patchRequest.modelAccessMode === undefined ? {} : { modelAccessMode: patchRequest.modelAccessMode }),
284
- ...(patchRequest.modelList === undefined ? {} : { modelList: patchRequest.modelList }),
285
- ...(patchRequest.clientRouterDenylist === undefined
286
- ? {}
287
- : { clientRouterDenylist: patchRequest.clientRouterDenylist }),
288
- });
289
- if (!updated) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
290
- const children = await config.store.listChildren(authorized.tenantId, keyId);
291
- // Parent policy is live-inherited by children at auth time, so their
292
- // cached snapshots must die with the parent — otherwise a denylist /
293
- // budget edit looks like a no-op for up to AUTH_CACHE_TTL_MS per child.
294
- invalidateApiKeyCache(keyId);
295
- for (const child of children) invalidateApiKeyCache(child.id);
296
-
297
- // Denylist / allowlist / scopes are auth-snapshot fields: cache
298
- // invalidation above is enough. Only quota/concurrency edits need an
299
- // admission purge — otherwise changing a client denylist would also wipe
300
- // RPM / daily counters for every recipient mid-day.
301
- //
302
- // And only a *tightened* limit needs it. The dashboard edit form always
303
- // sends all five limit fields, so a `!== undefined` test treated every
304
- // save — renaming a key, editing notes — as a limit change and dropped
305
- // the whole family's daily/monthly counters, restarting the budget
306
- // mid-bucket. Loosening a limit cannot be bypassed by a stale counter
307
- // (the counter only ever grows toward the new ceiling), so it needs no
308
- // purge either; only a lowered or newly-added ceiling does.
309
- const tightened = (
310
- next: number | null | undefined,
311
- previous: number | null | undefined,
312
- ): boolean => {
313
- if (next === undefined || next === null) return false;
314
- if (previous === null || previous === undefined) return true;
315
- return next < previous;
316
- };
317
- /** A ceiling that did not exist before this request. */
318
- const isNewLimit = (
319
- next: number | null | undefined,
320
- previous: number | null | undefined,
321
- ): boolean => next !== undefined && next !== null && (previous === null || previous === undefined);
322
- const limitsChanged =
323
- modeChanged ||
324
- secret !== undefined ||
325
- tightened(patchRequest.requestsPerMinute, current.requestsPerMinute) ||
326
- tightened(patchRequest.dailyTokenLimit, current.dailyTokenLimit) ||
327
- tightened(patchRequest.monthlyTokenLimit, current.monthlyTokenLimit) ||
328
- tightened(patchRequest.lifetimeTokenBudget, current.lifetimeTokenBudget) ||
329
- tightened(patchRequest.maxConcurrentRequests, current.maxConcurrentRequests);
330
- if (limitsChanged) {
331
- // Drop stale admission counters so a newly lowered one-time/recurring
332
- // budget cannot be bypassed by a counter seeded under the old limit.
333
- // Share children admit under the parent id, so purge the parent too.
334
- await Promise.all([
335
- config.admissionService.purgeKey(keyId),
336
- ...children.map((child) => config.admissionService.purgeKey(child.id)),
337
- ]);
338
- }
339
- // Adding a daily/monthly ceiling to a key that already spent in the
340
- // current bucket must not hand it a full fresh budget: seed the counter
341
- // from the spend already recorded, so the limit covers the whole bucket.
342
- // Only a *newly added* ceiling needs this — a lowered one was just
343
- // purged above, and re-seeding there would restore the old baseline.
344
- const dailyAdded = isNewLimit(patchRequest.dailyTokenLimit, current.dailyTokenLimit);
345
- const monthlyAdded = isNewLimit(patchRequest.monthlyTokenLimit, current.monthlyTokenLimit);
346
- if ((dailyAdded || monthlyAdded) && config.bucketSpend && config.admissionService.seedBuckets) {
347
- const now = new Date();
348
- const spend = await config.bucketSpend(keyId, now).catch(() => null);
349
- if (spend) {
350
- await config.admissionService.seedBuckets({
351
- apiKeyId: keyId,
352
- now: now.getTime(),
353
- ...(dailyAdded ? { daily: spend.daily } : {}),
354
- ...(monthlyAdded ? { monthly: spend.monthly } : {}),
355
- });
356
- }
357
- }
358
- await config.auditSink?.record({
359
- access: authorized,
360
- action: "api_key.updated",
361
- target: keyId,
362
- detail: { fields: Object.keys(request) },
363
- });
364
- return { ...sanitizeApiKeyResponse(updated), ...(secret === undefined ? {} : { secret }) };
365
- },
366
- async revokeKey(access: AccessDecision | undefined, keyId: string): Promise<{ success: true }> {
367
- const authorized = requireTenantScope(access, "dashboard:write");
368
- const target = await config.store.get(authorized.tenantId, keyId);
369
- if (!target || target.revokedAt !== undefined)
370
- throw new ConsoleDomainError("key_not_found", 404, "Key not found");
371
- // The gateway routes every request off this key and the CLI reads it for
372
- // the CLI, so removing it would strand the installation with no way to
373
- // authenticate. Rotation replaces the secret and keeps the key present.
374
- if (isDefaultGatewayApiKey(target))
375
- throw new ConsoleDomainError(
376
- "default_key_not_revocable",
377
- 409,
378
- "The default gateway API key cannot be deleted. Rotate it instead.",
379
- );
380
- const revoked = await config.store.revoke(authorized.tenantId, keyId, new Date());
381
- if (!revoked) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
382
- const children = await config.store.listChildren(authorized.tenantId, keyId);
383
- invalidateApiKeyCache(keyId);
384
- for (const child of children) invalidateApiKeyCache(child.id);
385
- await Promise.all([
386
- config.admissionService.purgeKey(keyId),
387
- ...children.map((child) => config.admissionService.purgeKey(child.id)),
388
- ]);
389
- await config.auditSink?.record({
390
- access: authorized,
391
- action: "api_key.revoked",
392
- target: keyId,
393
- });
394
- return { success: true };
395
- },
396
- /**
397
- * Establishes or rotates the key's single stable link.
398
- *
399
- * The console shows one link that never moves; `regenerate` replaces the
400
- * token in place so the previous URL stops resolving. A share template
401
- * hands out child keys; a personal key hands out the key itself.
402
- */
403
- async shareKey(
404
- access: AccessDecision | undefined,
405
- keyId: string,
406
- options: { expiresAt?: string | null; origin?: string; regenerate?: boolean } = {},
407
- ): Promise<ShareKeyResponse> {
408
- const authorized = requireTenantScope(access, "dashboard:write");
409
- const shareStore = config.shareStore;
410
- if (!shareStore)
411
- throw new ConsoleDomainError("capability_unsupported", 501, "key share not configured");
412
- const record = await config.store.get(authorized.tenantId, keyId);
413
- if (!record || record.revokedAt !== undefined)
414
- throw new ConsoleDomainError("key_not_found", 404, "Key not found");
415
- if (record.parentKeyId !== undefined)
416
- throw new ConsoleDomainError(
417
- "key_not_share_parent",
418
- 409,
419
- "Only a top-level key can carry a handoff link",
420
- );
421
- const kind: ShareLinkKind = record.keyMode === "share" ? "enroll" : "handoff";
422
- const origin = (options.origin ?? "").replace(/\/$/, "");
423
- // Re-issuing without regeneration must return the link that is actually
424
- // stored. Minting a fresh token here would hand back a URL that was never
425
- // persisted, so the link would be dead on arrival.
426
- if (options.regenerate !== true) {
427
- const existing = await shareStore.findTokenForApiKey(keyId);
428
- if (existing !== null && existing.tokenEncrypted !== null) {
429
- try {
430
- const token = decryptCredentialToString(existing.tokenEncrypted);
431
- return {
432
- id: existing.id,
433
- url: `${origin}/share/${token}`,
434
- token,
435
- kind: existing.kind,
436
- expiresAt: existing.expiresAt === null ? null : existing.expiresAt.toISOString(),
437
- };
438
- } catch {
439
- // A row written under a different encryption key cannot be shown;
440
- // fall through and establish a fresh link.
441
- }
442
- }
443
- }
444
- let expiresAt: Date | null = null;
445
- if (options.expiresAt !== undefined && options.expiresAt !== null) {
446
- const parsed = new Date(options.expiresAt);
447
- if (Number.isNaN(parsed.getTime()))
448
- throw new ConsoleDomainError(
449
- "invalid_share_expiry",
450
- 400,
451
- "expiresAt must be a valid ISO timestamp",
452
- );
453
- expiresAt = parsed;
454
- }
455
- const token = randomBytes(32).toString("base64url");
456
- const link = await shareStore.create({
457
- apiKeyId: keyId,
458
- tokenHash: hashShareToken(token),
459
- tokenEncrypted: encryptCredential(token),
460
- kind,
461
- expiresAt,
462
- rotate: options.regenerate === true,
463
- });
464
- await config.auditSink?.record({
465
- access: authorized,
466
- action: options.regenerate === true ? "api_key.share_regenerated" : "api_key.shared",
467
- target: keyId,
468
- detail: { shareId: link.id, kind },
469
- });
470
- return {
471
- id: link.id,
472
- url: `${origin}/share/${token}`,
473
- token,
474
- kind: link.kind,
475
- expiresAt: link.expiresAt === null ? null : link.expiresAt.toISOString(),
476
- };
477
- },
478
- /**
479
- * The key's stable link with its retained token, for the console to show
480
- * without rotating it. Returns null when the key has no link yet or the
481
- * link predates token retention.
482
- */
483
- async getShare(
484
- access: AccessDecision | undefined,
485
- keyId: string,
486
- options: { origin?: string } = {},
487
- ): Promise<ShareKeyResponse | null> {
488
- const authorized = requireTenantScope(access, "dashboard:read");
489
- const shareStore = config.shareStore;
490
- if (!shareStore)
491
- throw new ConsoleDomainError("capability_unsupported", 501, "key share not configured");
492
- const record = await config.store.get(authorized.tenantId, keyId);
493
- if (!record || record.revokedAt !== undefined)
494
- throw new ConsoleDomainError("key_not_found", 404, "Key not found");
495
- if (record.parentKeyId !== undefined)
496
- throw new ConsoleDomainError(
497
- "key_not_share_parent",
498
- 409,
499
- "Only a top-level key can carry a handoff link",
500
- );
501
- const link = await shareStore.findTokenForApiKey(keyId);
502
- if (link === null || link.tokenEncrypted === null) return null;
503
- let token: string;
504
- try {
505
- token = decryptCredentialToString(link.tokenEncrypted);
506
- } catch {
507
- // A row written under a different key cannot be shown; the operator
508
- // regenerates to establish a fresh link.
509
- return null;
510
- }
511
- const origin = (options.origin ?? "").replace(/\/$/, "");
512
- return {
513
- id: link.id,
514
- url: `${origin}/share/${token}`,
515
- token,
516
- kind: link.kind,
517
- expiresAt: link.expiresAt === null ? null : link.expiresAt.toISOString(),
518
- };
519
- },
520
- /**
521
- * Rotates a personal key's credential in place and re-points its handoff
522
- * link at the new secret. Share templates have no credential of their own,
523
- * so they are rejected here.
524
- */
525
- async regenerateKey(
526
- access: AccessDecision | undefined,
527
- keyId: string,
528
- options: { origin?: string } = {},
529
- ): Promise<{ secret: string; share: ShareKeyResponse | null }> {
530
- const authorized = requireTenantScope(access, "dashboard:write");
531
- const current = await config.store.get(authorized.tenantId, keyId);
532
- if (!current || current.revokedAt !== undefined)
533
- throw new ConsoleDomainError("key_not_found", 404, "Key not found");
534
- if (current.parentKeyId !== undefined)
535
- throw new ConsoleDomainError(
536
- "shared_key_managed_by_parent",
537
- 409,
538
- "Edit or revoke this key through its share parent",
539
- );
540
- if (current.keyMode !== "personal")
541
- throw new ConsoleDomainError(
542
- "key_not_personal",
543
- 409,
544
- "Only a personal key can be regenerated; rotate a share template's link instead",
545
- );
546
- const generated = generateApiKeySecret();
547
- const updated = await config.store.update(authorized.tenantId, keyId, {
548
- keyHash: generated.hash,
549
- keyEncrypted: encryptCredential(generated.secret),
550
- keyPrefix: generated.prefix,
551
- });
552
- if (!updated) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
553
- invalidateApiKeyCache(keyId);
554
- await config.admissionService.purgeKey(keyId);
555
- const shareStore = config.shareStore;
556
- let share: ShareKeyResponse | null = null;
557
- if (shareStore) {
558
- const token = randomBytes(32).toString("base64url");
559
- const link = await shareStore.create({
560
- apiKeyId: keyId,
561
- tokenHash: hashShareToken(token),
562
- tokenEncrypted: encryptCredential(token),
563
- kind: "handoff",
564
- expiresAt: null,
565
- rotate: true,
566
- });
567
- const origin = (options.origin ?? "").replace(/\/$/, "");
568
- share = {
569
- id: link.id,
570
- url: `${origin}/share/${token}`,
571
- token,
572
- kind: link.kind,
573
- expiresAt: null,
574
- };
575
- }
576
- await config.auditSink?.record({
577
- access: authorized,
578
- action: "api_key.regenerated",
579
- target: keyId,
580
- });
581
- return { secret: generated.secret, share };
582
- },
583
- /**
584
- * Returns a personal key's stored secret. The ciphertext exists exactly so
585
- * the owner can read it back; a share template has none, so it is refused.
586
- */
587
- async revealKey(
588
- access: AccessDecision | undefined,
589
- keyId: string,
590
- ): Promise<{ secret: string }> {
591
- const authorized = requireTenantScope(access, "dashboard:read");
592
- const current = await config.store.get(authorized.tenantId, keyId);
593
- if (!current || current.revokedAt !== undefined)
594
- throw new ConsoleDomainError("key_not_found", 404, "Key not found");
595
- if (current.keyMode !== "personal" || current.keyEncrypted === undefined)
596
- throw new ConsoleDomainError(
597
- "key_not_personal",
598
- 409,
599
- "Only a personal key keeps a recoverable secret",
600
- );
601
- await config.auditSink?.record({
602
- access: authorized,
603
- action: "api_key.revealed",
604
- target: keyId,
605
- });
606
- return { secret: decryptCredentialToString(current.keyEncrypted) };
607
- },
608
- async listShares(
609
- access: AccessDecision | undefined,
610
- keyId: string,
611
- ): Promise<ShareLinkResponse[]> {
612
- const authorized = requireTenantScope(access, "dashboard:read");
613
- if (!config.shareStore)
614
- throw new ConsoleDomainError("capability_unsupported", 501, "key share not configured");
615
- const record = await config.store.get(authorized.tenantId, keyId);
616
- if (!record || record.keyMode !== "share" || record.parentKeyId !== undefined)
617
- throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
618
- return (await config.shareStore.listForApiKey(keyId)).map(mapShareLinkResponse);
619
- },
620
- async listSharedKeys(
621
- access: AccessDecision | undefined,
622
- parentKeyId: string,
623
- ): Promise<readonly SharedKeySummary[]> {
624
- const authorized = requireTenantScope(access, "dashboard:read");
625
- const activity = config.shareActivity;
626
- if (!activity)
627
- throw new ConsoleDomainError("capability_unsupported", 501, "share telemetry not configured");
628
- const parent = await config.store.get(authorized.tenantId, parentKeyId);
629
- if (!parent || parent.revokedAt !== undefined ||
630
- parent.keyMode !== "share" || parent.parentKeyId !== undefined)
631
- throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
632
- const children = await config.store.listChildren(authorized.tenantId, parentKeyId);
633
- return activity.getSharedKeySummaries(authorized.tenantId, children);
634
- },
635
- async getSharedKeyActivity(
636
- access: AccessDecision | undefined,
637
- parentKeyId: string,
638
- childKeyId: string,
639
- ): Promise<SharedKeyActivityDetail> {
640
- const authorized = requireTenantScope(access, "dashboard:read");
641
- const activity = config.shareActivity;
642
- if (!activity)
643
- throw new ConsoleDomainError("capability_unsupported", 501, "share telemetry not configured");
644
- const parent = await config.store.get(authorized.tenantId, parentKeyId);
645
- if (!parent || parent.revokedAt !== undefined ||
646
- parent.keyMode !== "share" || parent.parentKeyId !== undefined)
647
- throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
648
- const children = await config.store.listChildren(authorized.tenantId, parentKeyId);
649
- if (!children.some((child) => child.id === childKeyId))
650
- throw new ConsoleDomainError("key_not_found", 404, "Shared API key not found");
651
- return activity.getSharedKeyDetail(authorized.tenantId, childKeyId);
652
- },
653
- async revokeShare(
654
- access: AccessDecision | undefined,
655
- keyId: string,
656
- shareId: string,
657
- ): Promise<{ success: true }> {
658
- const authorized = requireTenantScope(access, "dashboard:write");
659
- const shareStore = config.shareStore;
660
- if (!shareStore)
661
- throw new ConsoleDomainError("capability_unsupported", 501, "key share not configured");
662
- const record = await config.store.get(authorized.tenantId, keyId);
663
- if (!record || record.keyMode !== "share" || record.parentKeyId !== undefined)
664
- throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
665
- const revoked = await shareStore.revoke(keyId, shareId);
666
- if (!revoked) throw new ConsoleDomainError("share_not_found", 404, "Share link not found");
667
- await config.auditSink?.record({
668
- access: authorized,
669
- action: "api_key.share_revoked",
670
- target: shareId,
671
- detail: { keyId },
672
- });
673
- return { success: true };
674
- },
675
- };
676
- return operations;
677
- }
678
-
679
- const apiKeyBody = t.Object({
680
- enabled: t.Optional(t.Boolean()),
681
- label: t.Optional(t.String()),
682
- keyMode: t.Optional(literalUnion(API_KEY_MODES)),
683
- scopes: t.Optional(t.Array(t.String())),
684
- keyPrefix: t.Optional(t.String()),
685
- key: t.Optional(t.String()),
686
- // Limit fields accept `null` to clear a previously set limit ("unlimited");
687
- // `undefined` leaves it unchanged.
688
- requestsPerMinute: t.Optional(t.Union([t.Number(), t.Null()])),
689
- dailyTokenLimit: t.Optional(t.Union([t.Number(), t.Null()])),
690
- monthlyTokenLimit: t.Optional(t.Union([t.Number(), t.Null()])),
691
- lifetimeTokenBudget: t.Optional(t.Union([t.Number(), t.Null()])),
692
- maxConcurrentRequests: t.Optional(t.Union([t.Number(), t.Null()])),
693
- modelPrefix: t.Optional(t.String()),
694
- modelAccessMode: t.Optional(literalUnion(API_KEY_MODEL_ACCESS_MODES)),
695
- modelList: t.Optional(t.Array(t.String())),
696
- clientRouterDenylist: t.Optional(t.Array(t.String())),
697
- notesTitle: t.Optional(t.String()),
698
- notesSubtitle: t.Optional(t.String()),
699
- notesBody: t.Optional(t.String()),
700
- sharePopupEnabled: t.Optional(t.Union([t.Boolean(), t.Null()])),
701
- sharePopupImage: t.Optional(t.Union([t.String({ maxLength: 2_800_000 }), t.Null()])),
702
- sharePopupTitle: t.Optional(t.Union([t.String({ maxLength: 120 }), t.Null()])),
703
- sharePopupBody: t.Optional(t.Union([t.String({ maxLength: 1200 }), t.Null()])),
704
- });
705
-
706
- /** Full replacement order for the tenant's top-level credential list. */
707
- const reorderKeysBody = t.Object({ keyIds: t.Array(t.String(), { minItems: 1 }) });
708
-
709
- const apiKeyShareBody = t.Object({
710
- expiresAt: t.Optional(t.Union([t.String(), t.Null()])),
711
- /** Replaces the existing link's token so the previous URL stops resolving. */
712
- regenerate: t.Optional(t.Boolean()),
713
- });
714
-
715
- /**
716
- * Serializes an intentional `null` payload as the JSON literal.
717
- *
718
- * Elysia reads a bare `null` return as "no content" and emits `200` with an
719
- * empty body and no `content-type`, which a JSON client cannot parse. `null`
720
- * here is a real answer — "this key has no link yet" — and the response type
721
- * declares it, so it has to arrive as the bytes `null` rather than as nothing.
722
- */
723
- function jsonNull(): Response {
724
- return new Response("null", {
725
- status: 200,
726
- headers: { "content-type": "application/json; charset=utf-8" },
727
- });
728
- }
729
- /** Creates tenant-bound API-key routes with request-local authorization. */
730
- export function createApiKeyRoutes(config: ApiKeyConfig): Elysia {
731
- const factory = createApiKeyOperations(config);
732
- return new Elysia({ prefix: "/api-keys" })
733
- .get("/", async ({ request, set }) => {
734
- try {
735
- return await factory.listKeys(config.accessResolver(request));
736
- } catch (error) {
737
- return errorResponse(error, set, "API-key operation failed");
738
- }
739
- })
740
- .post("/reorder", { body: reorderKeysBody }, async ({ request, body, set }) => {
741
- try {
742
- await factory.reorderKeys(config.accessResolver(request), body.keyIds);
743
- return { success: true };
744
- } catch (error) {
745
- return errorResponse(error, set, "API-key operation failed");
746
- }
747
- })
748
- .get("/:keyId", async ({ request, params, set }) => {
749
- try {
750
- return await factory.getKeyDetail(config.accessResolver(request), params.keyId);
751
- } catch (error) {
752
- return errorResponse(error, set, "API-key operation failed");
753
- }
754
- })
755
- .get("/:keyId/share-popup-image", async ({ request, params, set }) => {
756
- try {
757
- const result = await factory.getSharePopupImage(config.accessResolver(request), params.keyId);
758
- return new Response(new Blob([popupImageBytes(result.image)], { type: result.mime }), {
759
- headers: {
760
- "content-length": String(result.image.byteLength),
761
- "cache-control": "private, max-age=300",
762
- "x-content-type-options": "nosniff",
763
- },
764
- });
765
- } catch (error) {
766
- return errorResponse(error, set, "API key operation failed");
767
- }
768
- })
769
- .get("/:keyId/shares", async ({ request, params, set }) => {
770
- try {
771
- return await factory.listShares(config.accessResolver(request), params.keyId);
772
- } catch (error) {
773
- return errorResponse(error, set, "API-key operation failed");
774
- }
775
- })
776
- .get("/:keyId/shared-keys", async ({ request, params, set }) => {
777
- try {
778
- return await factory.listSharedKeys(config.accessResolver(request), params.keyId);
779
- } catch (error) {
780
- return errorResponse(error, set, "API-key operation failed");
781
- }
782
- })
783
- .get("/:keyId/shared-keys/:childKeyId/activity", async ({ request, params, set }) => {
784
- try {
785
- return await factory.getSharedKeyActivity(
786
- config.accessResolver(request),
787
- params.keyId,
788
- params.childKeyId,
789
- );
790
- } catch (error) {
791
- return errorResponse(error, set, "API-key operation failed");
792
- }
793
- })
794
- .delete("/:keyId/shares/:shareId", async ({ request, params, set }) => {
795
- try {
796
- return await factory.revokeShare(
797
- config.accessResolver(request),
798
- params.keyId,
799
- params.shareId,
800
- );
801
- } catch (error) {
802
- return errorResponse(error, set, "API-key operation failed");
803
- }
804
- })
805
- .post("/", { body: apiKeyBody }, async ({ request, body, set }) => {
806
- try {
807
- set.status = 201;
808
- return await factory.createKey(config.accessResolver(request), parseRequest(body));
809
- } catch (error) {
810
- return errorResponse(error, set, "API-key operation failed");
811
- }
812
- })
813
- .patch("/:keyId", { body: apiKeyBody }, async ({ request, params, body, set }) => {
814
- try {
815
- return await factory.updateKey(
816
- config.accessResolver(request),
817
- params.keyId,
818
- parseRequest(body),
819
- );
820
- } catch (error) {
821
- return errorResponse(error, set, "API-key operation failed");
822
- }
823
- })
824
- .delete("/:keyId", async ({ request, params, set }) => {
825
- try {
826
- return await factory.revokeKey(config.accessResolver(request), params.keyId);
827
- } catch (error) {
828
- return errorResponse(error, set, "API-key operation failed");
829
- }
830
- })
831
- .get("/:keyId/share", async ({ request, params, set }) => {
832
- try {
833
- const share = await factory.getShare(config.accessResolver(request), params.keyId, {
834
- origin: new URL(request.url).origin,
835
- });
836
- return share ?? jsonNull();
837
- } catch (error) {
838
- return errorResponse(error, set, "API-key operation failed");
839
- }
840
- })
841
- .post("/:keyId/share", { body: t.Optional(apiKeyShareBody) }, async ({ request, params, body, set }) => {
842
- try {
843
- set.status = 201;
844
- const input = (body ?? {}) as { expiresAt?: string | null; regenerate?: boolean };
845
- return await factory.shareKey(config.accessResolver(request), params.keyId, {
846
- ...(input.expiresAt === undefined ? {} : { expiresAt: input.expiresAt }),
847
- ...(input.regenerate === undefined ? {} : { regenerate: input.regenerate }),
848
- origin: new URL(request.url).origin,
849
- });
850
- } catch (error) {
851
- return errorResponse(error, set, "API-key operation failed");
852
- }
853
- })
854
- .post("/:keyId/reveal", async ({ request, params, set }) => {
855
- try {
856
- return await factory.revealKey(config.accessResolver(request), params.keyId);
857
- } catch (error) {
858
- return errorResponse(error, set, "API-key operation failed");
859
- }
860
- })
861
- .post("/:keyId/regenerate", async ({ request, params, set }) => {
862
- try {
863
- return await factory.regenerateKey(config.accessResolver(request), params.keyId, {
864
- origin: new URL(request.url).origin,
865
- });
866
- } catch (error) {
867
- return errorResponse(error, set, "API-key operation failed");
868
- }
869
- }) as unknown as Elysia;
870
- }
1
+ // Console API-key domain: HTTP routes.
2
+ //
3
+ // Owns validation, CRUD operations, and route wiring through the ApiKeyStore.
4
+
5
+ import { Elysia, t } from "elysia";
6
+ import { randomBytes, randomUUID } from "node:crypto";
7
+ import { decryptCredentialToString, encryptCredential } from "../../security/crypto";
8
+ import { invalidateApiKeyCache } from "../../security/api-key-auth";
9
+ import { generateShareSlug, hashShareToken } from "../../persistence/stores/share-store";
10
+ import type { AccessDecision } from "../../security/access-control";
11
+ import { ConsoleDomainError, errorResponse, requireTenantScope } from "../shared/errors";
12
+ import { literalUnion } from "../shared/elysia-schema";
13
+ import { API_KEY_MODES, API_KEY_MODEL_ACCESS_MODES, type ShareLinkKind } from "../../persistence/schema";
14
+ import type { ApiKeyConfig, ApiKeyResponse, UpdateApiKeyResponse } from "./contracts";
15
+ import type { ApiKeyPatch, ApiKeyRecord } from "../../persistence/stores/api-key-store";
16
+ import {
17
+ generateApiKeySecret,
18
+ isDefaultGatewayApiKey,
19
+ parseSharePopupImage,
20
+ sanitizeApiKeyResponse,
21
+ validateApiKeyRequest,
22
+ parseRequest,
23
+ finitePositive,
24
+ type CreateApiKeyRequest,
25
+ type CreateApiKeyResponse,
26
+ resolveKeyPrefix,
27
+ prepareCustomKey,
28
+ mapShareLinkResponse,
29
+ type ShareKeyResponse,
30
+ type ShareLinkResponse,
31
+ resolveShareKeyPrefix,
32
+ } from "./contracts";
33
+ import type { SharedKeyActivityDetail, SharedKeySummary } from "../share/share-usage";
34
+ import { popupImageBytes } from "./share-popup-image";
35
+
36
+ export function createApiKeyOperations(config: ApiKeyConfig) {
37
+ const operations = {
38
+ async listKeys(access: AccessDecision | undefined): Promise<ApiKeyResponse[]> {
39
+ const authorized = requireTenantScope(access, "dashboard:read");
40
+ const records = await config.store.list(authorized.tenantId);
41
+ const topLevel = records.filter(
42
+ (record) => record.revokedAt === undefined && record.parentKeyId === undefined,
43
+ );
44
+ // A share template's own counter never moves for child traffic, so
45
+ // reporting it raw would show a template as untouched while the gateway
46
+ // is refusing its recipients on the parent budget. Report the figure the
47
+ // gateway actually enforces: the template plus every recipient it has
48
+ // issued, revoked ones included (their spend is already spent).
49
+ const sumChildren = config.store.sumChildrenConsumed?.bind(config.store);
50
+ return Promise.all(
51
+ topLevel.map(async (record) => {
52
+ const response = sanitizeApiKeyResponse(record);
53
+ if (record.keyMode !== "share" || !sumChildren) return response;
54
+ const children = await sumChildren(record.id).catch(() => 0);
55
+ return children > 0
56
+ ? { ...response, tokensConsumed: response.tokensConsumed + children }
57
+ : response;
58
+ }),
59
+ );
60
+ },
61
+ /** Persists a new credential order. `keyIds` must name every top-level key
62
+ the tenant has, so a partial list cannot silently renumber rows. */
63
+ async reorderKeys(access: AccessDecision | undefined, keyIds: readonly string[]): Promise<void> {
64
+ const authorized = requireTenantScope(access, "dashboard:write");
65
+ const records = await config.store.list(authorized.tenantId);
66
+ const reorderable = records.filter(
67
+ (record) => record.revokedAt === undefined && record.parentKeyId === undefined,
68
+ );
69
+ const known = new Set(reorderable.map((record) => record.id));
70
+ const seen = new Set<string>();
71
+ for (const keyId of keyIds) {
72
+ if (!known.has(keyId))
73
+ throw new ConsoleDomainError("key_not_found", 404, `Key not found: ${keyId}`);
74
+ if (seen.has(keyId))
75
+ throw new ConsoleDomainError("invalid_request", 422, `Duplicate key id: ${keyId}`);
76
+ seen.add(keyId);
77
+ }
78
+ if (seen.size !== known.size)
79
+ throw new ConsoleDomainError(
80
+ "invalid_request",
81
+ 422,
82
+ "Reorder must list every key exactly once",
83
+ );
84
+ await config.store.reorder(authorized.tenantId, keyIds);
85
+ },
86
+ async getKeyDetail(access: AccessDecision | undefined, keyId: string): Promise<ApiKeyResponse> {
87
+ const authorized = requireTenantScope(access, "dashboard:read");
88
+ const record = await config.store.get(authorized.tenantId, keyId);
89
+ if (!record) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
90
+ return sanitizeApiKeyResponse(record);
91
+ },
92
+ async getSharePopupImage(
93
+ access: AccessDecision | undefined,
94
+ keyId: string,
95
+ ): Promise<{ mime: string; image: Buffer }> {
96
+ const authorized = requireTenantScope(access, "dashboard:read");
97
+ const record = await config.store.get(authorized.tenantId, keyId);
98
+ if (!record) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
99
+ if (record.sharePopupImage === undefined || record.sharePopupImageMime === undefined)
100
+ throw new ConsoleDomainError("key_not_found", 404, "This key has no popup image");
101
+ return { mime: record.sharePopupImageMime, image: record.sharePopupImage };
102
+ },
103
+ async createKey(
104
+ access: AccessDecision | undefined,
105
+ request: CreateApiKeyRequest,
106
+ ): Promise<CreateApiKeyResponse> {
107
+ const authorized = requireTenantScope(access, "dashboard:write");
108
+ const scopes = validateApiKeyRequest(request);
109
+ const popupImage = parseSharePopupImage(request.sharePopupImage);
110
+ const keyMode = request.keyMode ?? "personal";
111
+ const keyPrefix =
112
+ keyMode === "share"
113
+ ? resolveShareKeyPrefix(request.keyPrefix)
114
+ : resolveKeyPrefix(request.keyPrefix);
115
+ const generated =
116
+ keyMode === "personal"
117
+ ? request.key === undefined
118
+ ? generateApiKeySecret(keyPrefix)
119
+ : { ...prepareCustomKey(request.key), prefix: keyPrefix }
120
+ : undefined;
121
+ const record: ApiKeyRecord = {
122
+ id: randomUUID(),
123
+ tenantId: authorized.tenantId,
124
+ enabled: request.enabled ?? true,
125
+ label: request.label?.trim() || (keyMode === "share" ? "Share template" : "API key"),
126
+ keyHash: generated?.hash ?? null,
127
+ keyMode,
128
+ scopes,
129
+ // Only first boot seeds the default; a key created from the console is
130
+ // never it, so it stays renamable and revocable.
131
+ isDefault: false,
132
+ keyPrefix: generated?.prefix ?? keyPrefix,
133
+ ...(generated ? { keyEncrypted: encryptCredential(generated.secret) } : {}),
134
+ ...(request.notesTitle === undefined ? {} : { notesTitle: request.notesTitle }),
135
+ ...(request.notesSubtitle === undefined ? {} : { notesSubtitle: request.notesSubtitle }),
136
+ ...(request.notesBody === undefined ? {} : { notesBody: request.notesBody }),
137
+ ...(request.sharePopupEnabled == null ? {} : { sharePopupEnabled: request.sharePopupEnabled }),
138
+ ...(popupImage
139
+ ? { sharePopupImage: popupImage.image, sharePopupImageMime: popupImage.mime }
140
+ : {}),
141
+ ...(request.sharePopupTitle?.trim() ? { sharePopupTitle: request.sharePopupTitle.trim() } : {}),
142
+ ...(request.sharePopupBody?.trim() ? { sharePopupBody: request.sharePopupBody.trim() } : {}),
143
+ ...(request.requestsPerMinute == null ? {} : { requestsPerMinute: request.requestsPerMinute }),
144
+ ...(request.dailyTokenLimit == null ? {} : { dailyTokenLimit: request.dailyTokenLimit }),
145
+ ...(request.monthlyTokenLimit == null ? {} : { monthlyTokenLimit: request.monthlyTokenLimit }),
146
+ ...(request.lifetimeTokenBudget == null ? {} : { lifetimeTokenBudget: request.lifetimeTokenBudget }),
147
+ ...(request.maxConcurrentRequests == null ? {} : { maxConcurrentRequests: request.maxConcurrentRequests }),
148
+ ...(request.modelPrefix === undefined ? {} : { modelPrefix: request.modelPrefix }),
149
+ modelAccessMode: request.modelAccessMode ?? "whitelist",
150
+ ...(request.modelList === undefined ? {} : { modelList: request.modelList }),
151
+ ...(request.clientRouterDenylist === undefined
152
+ ? {}
153
+ : { clientRouterDenylist: request.clientRouterDenylist }),
154
+ createdAt: new Date(),
155
+ tokensConsumed: 0,
156
+ };
157
+ await config.store.create(record);
158
+ await config.auditSink?.record({
159
+ access: authorized,
160
+ action: "api_key.created",
161
+ target: record.id,
162
+ detail: { scopes, keyMode },
163
+ });
164
+ return {
165
+ ...sanitizeApiKeyResponse(record),
166
+ ...(generated ? { secret: generated.secret } : {}),
167
+ };
168
+ },
169
+ async updateKey(
170
+ access: AccessDecision | undefined,
171
+ keyId: string,
172
+ request: Partial<CreateApiKeyRequest>,
173
+ ): Promise<UpdateApiKeyResponse> {
174
+ const authorized = requireTenantScope(access, "dashboard:write");
175
+ const patchRequest = parseRequest(request);
176
+ const current = await config.store.get(authorized.tenantId, keyId);
177
+ if (!current || current.revokedAt !== undefined)
178
+ throw new ConsoleDomainError("key_not_found", 404, "Key not found");
179
+ if (current.parentKeyId !== undefined)
180
+ throw new ConsoleDomainError(
181
+ "shared_key_managed_by_parent",
182
+ 409,
183
+ "Edit or revoke this key through its share parent",
184
+ );
185
+
186
+ validateApiKeyRequest(patchRequest);
187
+ finitePositive(patchRequest.requestsPerMinute, "requestsPerMinute");
188
+ finitePositive(patchRequest.dailyTokenLimit, "dailyTokenLimit");
189
+ finitePositive(patchRequest.monthlyTokenLimit, "monthlyTokenLimit");
190
+ finitePositive(patchRequest.lifetimeTokenBudget, "lifetimeTokenBudget");
191
+ finitePositive(patchRequest.maxConcurrentRequests, "maxConcurrentRequests");
192
+
193
+ const nextMode = patchRequest.keyMode ?? current.keyMode;
194
+ const modeChanged = nextMode !== current.keyMode;
195
+ const nextPrefix =
196
+ nextMode === "share"
197
+ ? resolveShareKeyPrefix(
198
+ patchRequest.keyPrefix ?? (modeChanged ? undefined : current.keyPrefix),
199
+ )
200
+ : resolveKeyPrefix(patchRequest.keyPrefix ?? current.keyPrefix);
201
+ const prefixChanged =
202
+ patchRequest.keyPrefix !== undefined &&
203
+ nextPrefix !== resolveKeyPrefix(current.keyPrefix);
204
+
205
+ let secret: string | undefined;
206
+ let credentialPatch: ApiKeyPatch = {};
207
+ if (modeChanged && nextMode === "share") {
208
+ credentialPatch = {
209
+ keyMode: "share",
210
+ keyHash: null,
211
+ keyEncrypted: null,
212
+ keyPrefix: nextPrefix,
213
+ };
214
+ } else if (
215
+ nextMode === "personal" &&
216
+ (modeChanged || patchRequest.key !== undefined || prefixChanged)
217
+ ) {
218
+ const generated = patchRequest.key === undefined
219
+ ? generateApiKeySecret(
220
+ patchRequest.keyPrefix === undefined
221
+ ? undefined
222
+ : resolveKeyPrefix(patchRequest.keyPrefix),
223
+ )
224
+ : {
225
+ ...prepareCustomKey(patchRequest.key),
226
+ prefix: resolveKeyPrefix(patchRequest.keyPrefix ?? current.keyPrefix),
227
+ };
228
+ secret = generated.secret;
229
+ credentialPatch = {
230
+ ...(modeChanged ? { keyMode: "personal" as const } : {}),
231
+ keyHash: generated.hash,
232
+ keyEncrypted: encryptCredential(generated.secret),
233
+ keyPrefix: generated.prefix,
234
+ };
235
+ } else if (nextMode === "share" && nextPrefix !== current.keyPrefix) {
236
+ credentialPatch = { keyPrefix: nextPrefix };
237
+ }
238
+
239
+ const scopes =
240
+ patchRequest.scopes === undefined ? undefined : validateApiKeyRequest(patchRequest);
241
+ const popupImage =
242
+ patchRequest.sharePopupImage === undefined
243
+ ? undefined
244
+ : parseSharePopupImage(patchRequest.sharePopupImage);
245
+ const updated = await config.store.update(authorized.tenantId, keyId, {
246
+ ...(patchRequest.enabled === undefined ? {} : { enabled: patchRequest.enabled }),
247
+ ...(isDefaultGatewayApiKey(current) ? { isDefault: true } : {}),
248
+ ...credentialPatch,
249
+ ...(scopes === undefined ? {} : { scopes }),
250
+ // Rename. The label was accepted by the body schema and validated, but
251
+ // never copied into the patch — so "save" silently kept the old name and
252
+ // the key looked unrenamable. Blank falls back to the existing label
253
+ // rather than wiping it, since `label` is NOT NULL. The default key is
254
+ // renameable like any other (c2a93545); gating on `isDefault` dropped
255
+ // exactly the one patch that changes a name, and left the store with an
256
+ // empty `set(...)` that threw.
257
+ ...(patchRequest.label === undefined
258
+ ? {}
259
+ : { label: patchRequest.label.trim() === "" ? current.label : patchRequest.label.trim() }),
260
+ ...(patchRequest.notesTitle === undefined
261
+ ? {}
262
+ : { notesTitle: patchRequest.notesTitle.trim() === "" ? null : patchRequest.notesTitle }),
263
+ ...(patchRequest.notesSubtitle === undefined
264
+ ? {}
265
+ : { notesSubtitle: patchRequest.notesSubtitle.trim() === "" ? null : patchRequest.notesSubtitle }),
266
+ ...(patchRequest.notesBody === undefined
267
+ ? {}
268
+ : { notesBody: patchRequest.notesBody.trim() === "" ? null : patchRequest.notesBody }),
269
+ ...(patchRequest.sharePopupEnabled === undefined ? {} : { sharePopupEnabled: patchRequest.sharePopupEnabled }),
270
+ ...(popupImage === undefined
271
+ ? {}
272
+ : popupImage === null
273
+ ? { sharePopupImage: null, sharePopupImageMime: null }
274
+ : { sharePopupImage: popupImage.image, sharePopupImageMime: popupImage.mime }),
275
+ ...(patchRequest.sharePopupTitle === undefined ? {} : { sharePopupTitle: patchRequest.sharePopupTitle.trim() || null }),
276
+ ...(patchRequest.sharePopupBody === undefined ? {} : { sharePopupBody: patchRequest.sharePopupBody.trim() || null }),
277
+ ...(patchRequest.requestsPerMinute === undefined ? {} : { requestsPerMinute: patchRequest.requestsPerMinute }),
278
+ ...(patchRequest.dailyTokenLimit === undefined ? {} : { dailyTokenLimit: patchRequest.dailyTokenLimit }),
279
+ ...(patchRequest.monthlyTokenLimit === undefined ? {} : { monthlyTokenLimit: patchRequest.monthlyTokenLimit }),
280
+ ...(patchRequest.lifetimeTokenBudget === undefined ? {} : { lifetimeTokenBudget: patchRequest.lifetimeTokenBudget }),
281
+ ...(patchRequest.maxConcurrentRequests === undefined ? {} : { maxConcurrentRequests: patchRequest.maxConcurrentRequests }),
282
+ ...(patchRequest.modelPrefix === undefined ? {} : { modelPrefix: patchRequest.modelPrefix }),
283
+ ...(patchRequest.modelAccessMode === undefined ? {} : { modelAccessMode: patchRequest.modelAccessMode }),
284
+ ...(patchRequest.modelList === undefined ? {} : { modelList: patchRequest.modelList }),
285
+ ...(patchRequest.clientRouterDenylist === undefined
286
+ ? {}
287
+ : { clientRouterDenylist: patchRequest.clientRouterDenylist }),
288
+ });
289
+ if (!updated) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
290
+ const children = await config.store.listChildren(authorized.tenantId, keyId);
291
+ // Parent policy is live-inherited by children at auth time, so their
292
+ // cached snapshots must die with the parent — otherwise a denylist /
293
+ // budget edit looks like a no-op for up to AUTH_CACHE_TTL_MS per child.
294
+ invalidateApiKeyCache(keyId);
295
+ for (const child of children) invalidateApiKeyCache(child.id);
296
+
297
+ // Denylist / allowlist / scopes are auth-snapshot fields: cache
298
+ // invalidation above is enough. Only quota/concurrency edits need an
299
+ // admission purge — otherwise changing a client denylist would also wipe
300
+ // RPM / daily counters for every recipient mid-day.
301
+ //
302
+ // And only a *tightened* limit needs it. The dashboard edit form always
303
+ // sends all five limit fields, so a `!== undefined` test treated every
304
+ // save — renaming a key, editing notes — as a limit change and dropped
305
+ // the whole family's daily/monthly counters, restarting the budget
306
+ // mid-bucket. Loosening a limit cannot be bypassed by a stale counter
307
+ // (the counter only ever grows toward the new ceiling), so it needs no
308
+ // purge either; only a lowered or newly-added ceiling does.
309
+ const tightened = (
310
+ next: number | null | undefined,
311
+ previous: number | null | undefined,
312
+ ): boolean => {
313
+ if (next === undefined || next === null) return false;
314
+ if (previous === null || previous === undefined) return true;
315
+ return next < previous;
316
+ };
317
+ /** A ceiling that did not exist before this request. */
318
+ const isNewLimit = (
319
+ next: number | null | undefined,
320
+ previous: number | null | undefined,
321
+ ): boolean => next !== undefined && next !== null && (previous === null || previous === undefined);
322
+ const limitsChanged =
323
+ modeChanged ||
324
+ secret !== undefined ||
325
+ tightened(patchRequest.requestsPerMinute, current.requestsPerMinute) ||
326
+ tightened(patchRequest.dailyTokenLimit, current.dailyTokenLimit) ||
327
+ tightened(patchRequest.monthlyTokenLimit, current.monthlyTokenLimit) ||
328
+ tightened(patchRequest.lifetimeTokenBudget, current.lifetimeTokenBudget) ||
329
+ tightened(patchRequest.maxConcurrentRequests, current.maxConcurrentRequests);
330
+ if (limitsChanged) {
331
+ // Drop stale admission counters so a newly lowered one-time/recurring
332
+ // budget cannot be bypassed by a counter seeded under the old limit.
333
+ // Share children admit under the parent id, so purge the parent too.
334
+ await Promise.all([
335
+ config.admissionService.purgeKey(keyId),
336
+ ...children.map((child) => config.admissionService.purgeKey(child.id)),
337
+ ]);
338
+ }
339
+ // Adding a daily/monthly ceiling to a key that already spent in the
340
+ // current bucket must not hand it a full fresh budget: seed the counter
341
+ // from the spend already recorded, so the limit covers the whole bucket.
342
+ // Only a *newly added* ceiling needs this — a lowered one was just
343
+ // purged above, and re-seeding there would restore the old baseline.
344
+ const dailyAdded = isNewLimit(patchRequest.dailyTokenLimit, current.dailyTokenLimit);
345
+ const monthlyAdded = isNewLimit(patchRequest.monthlyTokenLimit, current.monthlyTokenLimit);
346
+ if ((dailyAdded || monthlyAdded) && config.bucketSpend && config.admissionService.seedBuckets) {
347
+ const now = new Date();
348
+ const spend = await config.bucketSpend(keyId, now).catch(() => null);
349
+ if (spend) {
350
+ await config.admissionService.seedBuckets({
351
+ apiKeyId: keyId,
352
+ now: now.getTime(),
353
+ ...(dailyAdded ? { daily: spend.daily } : {}),
354
+ ...(monthlyAdded ? { monthly: spend.monthly } : {}),
355
+ });
356
+ }
357
+ }
358
+ await config.auditSink?.record({
359
+ access: authorized,
360
+ action: "api_key.updated",
361
+ target: keyId,
362
+ detail: { fields: Object.keys(request) },
363
+ });
364
+ return { ...sanitizeApiKeyResponse(updated), ...(secret === undefined ? {} : { secret }) };
365
+ },
366
+ async revokeKey(access: AccessDecision | undefined, keyId: string): Promise<{ success: true }> {
367
+ const authorized = requireTenantScope(access, "dashboard:write");
368
+ const target = await config.store.get(authorized.tenantId, keyId);
369
+ if (!target || target.revokedAt !== undefined)
370
+ throw new ConsoleDomainError("key_not_found", 404, "Key not found");
371
+ // The gateway routes every request off this key and the CLI reads it for
372
+ // the CLI, so removing it would strand the installation with no way to
373
+ // authenticate. Rotation replaces the secret and keeps the key present.
374
+ if (isDefaultGatewayApiKey(target))
375
+ throw new ConsoleDomainError(
376
+ "default_key_not_revocable",
377
+ 409,
378
+ "The default gateway API key cannot be deleted. Rotate it instead.",
379
+ );
380
+ const revoked = await config.store.revoke(authorized.tenantId, keyId, new Date());
381
+ if (!revoked) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
382
+ const children = await config.store.listChildren(authorized.tenantId, keyId);
383
+ invalidateApiKeyCache(keyId);
384
+ for (const child of children) invalidateApiKeyCache(child.id);
385
+ await Promise.all([
386
+ config.admissionService.purgeKey(keyId),
387
+ ...children.map((child) => config.admissionService.purgeKey(child.id)),
388
+ ]);
389
+ await config.auditSink?.record({
390
+ access: authorized,
391
+ action: "api_key.revoked",
392
+ target: keyId,
393
+ });
394
+ return { success: true };
395
+ },
396
+ /**
397
+ * Establishes or rotates the key's single stable link.
398
+ *
399
+ * The console shows one link that never moves; `regenerate` replaces the
400
+ * token in place so the previous URL stops resolving. A share template
401
+ * hands out child keys; a personal key hands out the key itself.
402
+ */
403
+ async shareKey(
404
+ access: AccessDecision | undefined,
405
+ keyId: string,
406
+ options: { expiresAt?: string | null; origin?: string; regenerate?: boolean } = {},
407
+ ): Promise<ShareKeyResponse> {
408
+ const authorized = requireTenantScope(access, "dashboard:write");
409
+ const shareStore = config.shareStore;
410
+ if (!shareStore)
411
+ throw new ConsoleDomainError("capability_unsupported", 501, "key share not configured");
412
+ const record = await config.store.get(authorized.tenantId, keyId);
413
+ if (!record || record.revokedAt !== undefined)
414
+ throw new ConsoleDomainError("key_not_found", 404, "Key not found");
415
+ if (record.parentKeyId !== undefined)
416
+ throw new ConsoleDomainError(
417
+ "key_not_share_parent",
418
+ 409,
419
+ "Only a top-level key can carry a handoff link",
420
+ );
421
+ const kind: ShareLinkKind = record.keyMode === "share" ? "enroll" : "handoff";
422
+ const origin = (options.origin ?? "").replace(/\/$/, "");
423
+ // Re-issuing without regeneration must return the link that is actually
424
+ // stored. Minting a fresh token here would hand back a URL that was never
425
+ // persisted, so the link would be dead on arrival.
426
+ if (options.regenerate !== true) {
427
+ const existing = await shareStore.findTokenForApiKey(keyId);
428
+ if (existing !== null && existing.tokenEncrypted !== null) {
429
+ try {
430
+ const token = decryptCredentialToString(existing.tokenEncrypted);
431
+ return {
432
+ id: existing.id,
433
+ url: `${origin}/share/${token}`,
434
+ token,
435
+ kind: existing.kind,
436
+ expiresAt: existing.expiresAt === null ? null : existing.expiresAt.toISOString(),
437
+ };
438
+ } catch {
439
+ // A row written under a different encryption key cannot be shown;
440
+ // fall through and establish a fresh link.
441
+ }
442
+ }
443
+ }
444
+ let expiresAt: Date | null = null;
445
+ if (options.expiresAt !== undefined && options.expiresAt !== null) {
446
+ const parsed = new Date(options.expiresAt);
447
+ if (Number.isNaN(parsed.getTime()))
448
+ throw new ConsoleDomainError(
449
+ "invalid_share_expiry",
450
+ 400,
451
+ "expiresAt must be a valid ISO timestamp",
452
+ );
453
+ expiresAt = parsed;
454
+ }
455
+ const token = randomBytes(32).toString("base64url");
456
+ const link = await shareStore.create({
457
+ apiKeyId: keyId,
458
+ tokenHash: hashShareToken(token),
459
+ tokenEncrypted: encryptCredential(token),
460
+ slug: generateShareSlug(),
461
+ kind,
462
+ expiresAt,
463
+ rotate: options.regenerate === true,
464
+ });
465
+ await config.auditSink?.record({
466
+ access: authorized,
467
+ action: options.regenerate === true ? "api_key.share_regenerated" : "api_key.shared",
468
+ target: keyId,
469
+ detail: { shareId: link.id, kind },
470
+ });
471
+ return {
472
+ id: link.id,
473
+ url: `${origin}/share/${token}`,
474
+ token,
475
+ kind: link.kind,
476
+ expiresAt: link.expiresAt === null ? null : link.expiresAt.toISOString(),
477
+ };
478
+ },
479
+ /**
480
+ * The key's stable link with its retained token, for the console to show
481
+ * without rotating it. Returns null when the key has no link yet or the
482
+ * link predates token retention.
483
+ */
484
+ async getShare(
485
+ access: AccessDecision | undefined,
486
+ keyId: string,
487
+ options: { origin?: string } = {},
488
+ ): Promise<ShareKeyResponse | null> {
489
+ const authorized = requireTenantScope(access, "dashboard:read");
490
+ const shareStore = config.shareStore;
491
+ if (!shareStore)
492
+ throw new ConsoleDomainError("capability_unsupported", 501, "key share not configured");
493
+ const record = await config.store.get(authorized.tenantId, keyId);
494
+ if (!record || record.revokedAt !== undefined)
495
+ throw new ConsoleDomainError("key_not_found", 404, "Key not found");
496
+ if (record.parentKeyId !== undefined)
497
+ throw new ConsoleDomainError(
498
+ "key_not_share_parent",
499
+ 409,
500
+ "Only a top-level key can carry a handoff link",
501
+ );
502
+ const link = await shareStore.findTokenForApiKey(keyId);
503
+ if (link === null) return null;
504
+ let token: string | null = null;
505
+ if (link.tokenEncrypted !== null) {
506
+ try {
507
+ token = decryptCredentialToString(link.tokenEncrypted);
508
+ } catch {
509
+ // A row written under a different encryption key cannot be shown.
510
+ // The page itself survives: the slug is held in the clear, so the
511
+ // address stays valid and the operator only loses the bearer half.
512
+ token = null;
513
+ }
514
+ }
515
+ // Nothing to point at: neither the address nor the secret survived.
516
+ if (token === null && link.slug === null) return null;
517
+ const origin = (options.origin ?? "").replace(/\/$/, "");
518
+ return {
519
+ id: link.id,
520
+ // The slug is the address; the token is the bearer half. Preferring the
521
+ // slug keeps a restored page reachable under the same URL even when the
522
+ // encryption key that sealed its token is gone.
523
+ url: `${origin}/share/${link.slug ?? token}`,
524
+ token,
525
+ kind: link.kind,
526
+ expiresAt: link.expiresAt === null ? null : link.expiresAt.toISOString(),
527
+ };
528
+ },
529
+ /**
530
+ * Rotates a personal key's credential in place and re-points its handoff
531
+ * link at the new secret. Share templates have no credential of their own,
532
+ * so they are rejected here.
533
+ */
534
+ async regenerateKey(
535
+ access: AccessDecision | undefined,
536
+ keyId: string,
537
+ options: { origin?: string } = {},
538
+ ): Promise<{ secret: string; share: ShareKeyResponse | null }> {
539
+ const authorized = requireTenantScope(access, "dashboard:write");
540
+ const current = await config.store.get(authorized.tenantId, keyId);
541
+ if (!current || current.revokedAt !== undefined)
542
+ throw new ConsoleDomainError("key_not_found", 404, "Key not found");
543
+ if (current.parentKeyId !== undefined)
544
+ throw new ConsoleDomainError(
545
+ "shared_key_managed_by_parent",
546
+ 409,
547
+ "Edit or revoke this key through its share parent",
548
+ );
549
+ if (current.keyMode !== "personal")
550
+ throw new ConsoleDomainError(
551
+ "key_not_personal",
552
+ 409,
553
+ "Only a personal key can be regenerated; rotate a share template's link instead",
554
+ );
555
+ const generated = generateApiKeySecret();
556
+ const updated = await config.store.update(authorized.tenantId, keyId, {
557
+ keyHash: generated.hash,
558
+ keyEncrypted: encryptCredential(generated.secret),
559
+ keyPrefix: generated.prefix,
560
+ });
561
+ if (!updated) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
562
+ invalidateApiKeyCache(keyId);
563
+ await config.admissionService.purgeKey(keyId);
564
+ const shareStore = config.shareStore;
565
+ let share: ShareKeyResponse | null = null;
566
+ if (shareStore) {
567
+ const token = randomBytes(32).toString("base64url");
568
+ const link = await shareStore.create({
569
+ apiKeyId: keyId,
570
+ tokenHash: hashShareToken(token),
571
+ tokenEncrypted: encryptCredential(token),
572
+ slug: generateShareSlug(),
573
+ kind: "handoff",
574
+ expiresAt: null,
575
+ rotate: true,
576
+ });
577
+ const origin = (options.origin ?? "").replace(/\/$/, "");
578
+ share = {
579
+ id: link.id,
580
+ url: `${origin}/share/${token}`,
581
+ token,
582
+ kind: link.kind,
583
+ expiresAt: null,
584
+ };
585
+ }
586
+ await config.auditSink?.record({
587
+ access: authorized,
588
+ action: "api_key.regenerated",
589
+ target: keyId,
590
+ });
591
+ return { secret: generated.secret, share };
592
+ },
593
+ /**
594
+ * Returns a personal key's stored secret. The ciphertext exists exactly so
595
+ * the owner can read it back; a share template has none, so it is refused.
596
+ */
597
+ async revealKey(
598
+ access: AccessDecision | undefined,
599
+ keyId: string,
600
+ ): Promise<{ secret: string }> {
601
+ const authorized = requireTenantScope(access, "dashboard:read");
602
+ const current = await config.store.get(authorized.tenantId, keyId);
603
+ if (!current || current.revokedAt !== undefined)
604
+ throw new ConsoleDomainError("key_not_found", 404, "Key not found");
605
+ if (current.keyMode !== "personal" || current.keyEncrypted === undefined)
606
+ throw new ConsoleDomainError(
607
+ "key_not_personal",
608
+ 409,
609
+ "Only a personal key keeps a recoverable secret",
610
+ );
611
+ await config.auditSink?.record({
612
+ access: authorized,
613
+ action: "api_key.revealed",
614
+ target: keyId,
615
+ });
616
+ return { secret: decryptCredentialToString(current.keyEncrypted) };
617
+ },
618
+ async listShares(
619
+ access: AccessDecision | undefined,
620
+ keyId: string,
621
+ ): Promise<ShareLinkResponse[]> {
622
+ const authorized = requireTenantScope(access, "dashboard:read");
623
+ if (!config.shareStore)
624
+ throw new ConsoleDomainError("capability_unsupported", 501, "key share not configured");
625
+ const record = await config.store.get(authorized.tenantId, keyId);
626
+ if (!record || record.keyMode !== "share" || record.parentKeyId !== undefined)
627
+ throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
628
+ return (await config.shareStore.listForApiKey(keyId)).map(mapShareLinkResponse);
629
+ },
630
+ async listSharedKeys(
631
+ access: AccessDecision | undefined,
632
+ parentKeyId: string,
633
+ ): Promise<readonly SharedKeySummary[]> {
634
+ const authorized = requireTenantScope(access, "dashboard:read");
635
+ const activity = config.shareActivity;
636
+ if (!activity)
637
+ throw new ConsoleDomainError("capability_unsupported", 501, "share telemetry not configured");
638
+ const parent = await config.store.get(authorized.tenantId, parentKeyId);
639
+ if (!parent || parent.revokedAt !== undefined ||
640
+ parent.keyMode !== "share" || parent.parentKeyId !== undefined)
641
+ throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
642
+ const children = await config.store.listChildren(authorized.tenantId, parentKeyId);
643
+ return activity.getSharedKeySummaries(authorized.tenantId, children);
644
+ },
645
+ async getSharedKeyActivity(
646
+ access: AccessDecision | undefined,
647
+ parentKeyId: string,
648
+ childKeyId: string,
649
+ ): Promise<SharedKeyActivityDetail> {
650
+ const authorized = requireTenantScope(access, "dashboard:read");
651
+ const activity = config.shareActivity;
652
+ if (!activity)
653
+ throw new ConsoleDomainError("capability_unsupported", 501, "share telemetry not configured");
654
+ const parent = await config.store.get(authorized.tenantId, parentKeyId);
655
+ if (!parent || parent.revokedAt !== undefined ||
656
+ parent.keyMode !== "share" || parent.parentKeyId !== undefined)
657
+ throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
658
+ const children = await config.store.listChildren(authorized.tenantId, parentKeyId);
659
+ if (!children.some((child) => child.id === childKeyId))
660
+ throw new ConsoleDomainError("key_not_found", 404, "Shared API key not found");
661
+ return activity.getSharedKeyDetail(authorized.tenantId, childKeyId);
662
+ },
663
+ async revokeShare(
664
+ access: AccessDecision | undefined,
665
+ keyId: string,
666
+ shareId: string,
667
+ ): Promise<{ success: true }> {
668
+ const authorized = requireTenantScope(access, "dashboard:write");
669
+ const shareStore = config.shareStore;
670
+ if (!shareStore)
671
+ throw new ConsoleDomainError("capability_unsupported", 501, "key share not configured");
672
+ const record = await config.store.get(authorized.tenantId, keyId);
673
+ if (!record || record.keyMode !== "share" || record.parentKeyId !== undefined)
674
+ throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
675
+ const revoked = await shareStore.revoke(keyId, shareId);
676
+ if (!revoked) throw new ConsoleDomainError("share_not_found", 404, "Share link not found");
677
+ await config.auditSink?.record({
678
+ access: authorized,
679
+ action: "api_key.share_revoked",
680
+ target: shareId,
681
+ detail: { keyId },
682
+ });
683
+ return { success: true };
684
+ },
685
+ };
686
+ return operations;
687
+ }
688
+
689
+ const apiKeyBody = t.Object({
690
+ enabled: t.Optional(t.Boolean()),
691
+ label: t.Optional(t.String()),
692
+ keyMode: t.Optional(literalUnion(API_KEY_MODES)),
693
+ scopes: t.Optional(t.Array(t.String())),
694
+ keyPrefix: t.Optional(t.String()),
695
+ key: t.Optional(t.String()),
696
+ // Limit fields accept `null` to clear a previously set limit ("unlimited");
697
+ // `undefined` leaves it unchanged.
698
+ requestsPerMinute: t.Optional(t.Union([t.Number(), t.Null()])),
699
+ dailyTokenLimit: t.Optional(t.Union([t.Number(), t.Null()])),
700
+ monthlyTokenLimit: t.Optional(t.Union([t.Number(), t.Null()])),
701
+ lifetimeTokenBudget: t.Optional(t.Union([t.Number(), t.Null()])),
702
+ maxConcurrentRequests: t.Optional(t.Union([t.Number(), t.Null()])),
703
+ modelPrefix: t.Optional(t.String()),
704
+ modelAccessMode: t.Optional(literalUnion(API_KEY_MODEL_ACCESS_MODES)),
705
+ modelList: t.Optional(t.Array(t.String())),
706
+ clientRouterDenylist: t.Optional(t.Array(t.String())),
707
+ notesTitle: t.Optional(t.String()),
708
+ notesSubtitle: t.Optional(t.String()),
709
+ notesBody: t.Optional(t.String()),
710
+ sharePopupEnabled: t.Optional(t.Union([t.Boolean(), t.Null()])),
711
+ sharePopupImage: t.Optional(t.Union([t.String({ maxLength: 2_800_000 }), t.Null()])),
712
+ sharePopupTitle: t.Optional(t.Union([t.String({ maxLength: 120 }), t.Null()])),
713
+ sharePopupBody: t.Optional(t.Union([t.String({ maxLength: 1200 }), t.Null()])),
714
+ });
715
+
716
+ /** Full replacement order for the tenant's top-level credential list. */
717
+ const reorderKeysBody = t.Object({ keyIds: t.Array(t.String(), { minItems: 1 }) });
718
+
719
+ const apiKeyShareBody = t.Object({
720
+ expiresAt: t.Optional(t.Union([t.String(), t.Null()])),
721
+ /** Replaces the existing link's token so the previous URL stops resolving. */
722
+ regenerate: t.Optional(t.Boolean()),
723
+ });
724
+
725
+ /**
726
+ * Serializes an intentional `null` payload as the JSON literal.
727
+ *
728
+ * Elysia reads a bare `null` return as "no content" and emits `200` with an
729
+ * empty body and no `content-type`, which a JSON client cannot parse. `null`
730
+ * here is a real answer — "this key has no link yet" — and the response type
731
+ * declares it, so it has to arrive as the bytes `null` rather than as nothing.
732
+ */
733
+ function jsonNull(): Response {
734
+ return new Response("null", {
735
+ status: 200,
736
+ headers: { "content-type": "application/json; charset=utf-8" },
737
+ });
738
+ }
739
+ /** Creates tenant-bound API-key routes with request-local authorization. */
740
+ export function createApiKeyRoutes(config: ApiKeyConfig): Elysia {
741
+ const factory = createApiKeyOperations(config);
742
+ return new Elysia({ prefix: "/api-keys" })
743
+ .get("/", async ({ request, set }) => {
744
+ try {
745
+ return await factory.listKeys(config.accessResolver(request));
746
+ } catch (error) {
747
+ return errorResponse(error, set, "API-key operation failed");
748
+ }
749
+ })
750
+ .post("/reorder", { body: reorderKeysBody }, async ({ request, body, set }) => {
751
+ try {
752
+ await factory.reorderKeys(config.accessResolver(request), body.keyIds);
753
+ return { success: true };
754
+ } catch (error) {
755
+ return errorResponse(error, set, "API-key operation failed");
756
+ }
757
+ })
758
+ .get("/:keyId", async ({ request, params, set }) => {
759
+ try {
760
+ return await factory.getKeyDetail(config.accessResolver(request), params.keyId);
761
+ } catch (error) {
762
+ return errorResponse(error, set, "API-key operation failed");
763
+ }
764
+ })
765
+ .get("/:keyId/share-popup-image", async ({ request, params, set }) => {
766
+ try {
767
+ const result = await factory.getSharePopupImage(config.accessResolver(request), params.keyId);
768
+ return new Response(new Blob([popupImageBytes(result.image)], { type: result.mime }), {
769
+ headers: {
770
+ "content-length": String(result.image.byteLength),
771
+ "cache-control": "private, max-age=300",
772
+ "x-content-type-options": "nosniff",
773
+ },
774
+ });
775
+ } catch (error) {
776
+ return errorResponse(error, set, "API key operation failed");
777
+ }
778
+ })
779
+ .get("/:keyId/shares", async ({ request, params, set }) => {
780
+ try {
781
+ return await factory.listShares(config.accessResolver(request), params.keyId);
782
+ } catch (error) {
783
+ return errorResponse(error, set, "API-key operation failed");
784
+ }
785
+ })
786
+ .get("/:keyId/shared-keys", async ({ request, params, set }) => {
787
+ try {
788
+ return await factory.listSharedKeys(config.accessResolver(request), params.keyId);
789
+ } catch (error) {
790
+ return errorResponse(error, set, "API-key operation failed");
791
+ }
792
+ })
793
+ .get("/:keyId/shared-keys/:childKeyId/activity", async ({ request, params, set }) => {
794
+ try {
795
+ return await factory.getSharedKeyActivity(
796
+ config.accessResolver(request),
797
+ params.keyId,
798
+ params.childKeyId,
799
+ );
800
+ } catch (error) {
801
+ return errorResponse(error, set, "API-key operation failed");
802
+ }
803
+ })
804
+ .delete("/:keyId/shares/:shareId", async ({ request, params, set }) => {
805
+ try {
806
+ return await factory.revokeShare(
807
+ config.accessResolver(request),
808
+ params.keyId,
809
+ params.shareId,
810
+ );
811
+ } catch (error) {
812
+ return errorResponse(error, set, "API-key operation failed");
813
+ }
814
+ })
815
+ .post("/", { body: apiKeyBody }, async ({ request, body, set }) => {
816
+ try {
817
+ set.status = 201;
818
+ return await factory.createKey(config.accessResolver(request), parseRequest(body));
819
+ } catch (error) {
820
+ return errorResponse(error, set, "API-key operation failed");
821
+ }
822
+ })
823
+ .patch("/:keyId", { body: apiKeyBody }, async ({ request, params, body, set }) => {
824
+ try {
825
+ return await factory.updateKey(
826
+ config.accessResolver(request),
827
+ params.keyId,
828
+ parseRequest(body),
829
+ );
830
+ } catch (error) {
831
+ return errorResponse(error, set, "API-key operation failed");
832
+ }
833
+ })
834
+ .delete("/:keyId", async ({ request, params, set }) => {
835
+ try {
836
+ return await factory.revokeKey(config.accessResolver(request), params.keyId);
837
+ } catch (error) {
838
+ return errorResponse(error, set, "API-key operation failed");
839
+ }
840
+ })
841
+ .get("/:keyId/share", async ({ request, params, set }) => {
842
+ try {
843
+ const share = await factory.getShare(config.accessResolver(request), params.keyId, {
844
+ origin: new URL(request.url).origin,
845
+ });
846
+ return share ?? jsonNull();
847
+ } catch (error) {
848
+ return errorResponse(error, set, "API-key operation failed");
849
+ }
850
+ })
851
+ .post("/:keyId/share", { body: t.Optional(apiKeyShareBody) }, async ({ request, params, body, set }) => {
852
+ try {
853
+ set.status = 201;
854
+ const input = (body ?? {}) as { expiresAt?: string | null; regenerate?: boolean };
855
+ return await factory.shareKey(config.accessResolver(request), params.keyId, {
856
+ ...(input.expiresAt === undefined ? {} : { expiresAt: input.expiresAt }),
857
+ ...(input.regenerate === undefined ? {} : { regenerate: input.regenerate }),
858
+ origin: new URL(request.url).origin,
859
+ });
860
+ } catch (error) {
861
+ return errorResponse(error, set, "API-key operation failed");
862
+ }
863
+ })
864
+ .post("/:keyId/reveal", async ({ request, params, set }) => {
865
+ try {
866
+ return await factory.revealKey(config.accessResolver(request), params.keyId);
867
+ } catch (error) {
868
+ return errorResponse(error, set, "API-key operation failed");
869
+ }
870
+ })
871
+ .post("/:keyId/regenerate", async ({ request, params, set }) => {
872
+ try {
873
+ return await factory.regenerateKey(config.accessResolver(request), params.keyId, {
874
+ origin: new URL(request.url).origin,
875
+ });
876
+ } catch (error) {
877
+ return errorResponse(error, set, "API-key operation failed");
878
+ }
879
+ }) as unknown as Elysia;
880
+ }