cartethyia 2.5.4 → 2.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (395) hide show
  1. package/.env.example +13 -4
  2. package/README.md +2 -2
  3. package/dist/dashboard/assets/{BannedPage-C2orBsI2.js → BannedPage-DE2607Tb.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-DE2607Tb.js.gz +0 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-Duv6mfrl.js +1 -0
  6. package/dist/dashboard/assets/CliToolDetailPage-Duv6mfrl.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-BV6pxVQo.js → CliToolsPage-C2PNYWzk.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-C2PNYWzk.js.gz +0 -0
  9. package/dist/dashboard/assets/CombosPage-TXP9OfAM.js +1 -0
  10. package/dist/dashboard/assets/CombosPage-TXP9OfAM.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-CW-wPmJ6.js → ConsoleLogPage-B-9xnAQN.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-B-9xnAQN.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-0p8uX7vB.js → CustomizationPage-xeax9nJt.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-xeax9nJt.js.gz +0 -0
  15. package/dist/dashboard/assets/{HealthEventsModal-BjqSMfko.js → HealthEventsModal-Df4jjaHJ.js} +1 -1
  16. package/dist/dashboard/assets/HealthEventsModal-Df4jjaHJ.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-BvzCx8Kr.js → LoginPage-Dyojsm84.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-Dyojsm84.js.gz +0 -0
  19. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
  20. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-BJfg4ow6.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-BJfg4ow6.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-ChCX1gmT.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-ChCX1gmT.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-Js9jUPSb.js → ProvidersPage-DeWSOEUO.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-DeWSOEUO.js.gz +0 -0
  27. package/dist/dashboard/assets/ProxyPage-CkpWln2S.js +7 -0
  28. package/dist/dashboard/assets/ProxyPage-CkpWln2S.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-B3VOgQmt.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-B3VOgQmt.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-C_klIZom.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-C_klIZom.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-DDbMQsoo.js → SetupPage-BYph-InK.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-BYph-InK.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-CB3Z7VTf.js → SortableList-CvyQCYWJ.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-CvyQCYWJ.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-D95UCPHI.js → StudioPage-Cm1JYilI.js} +3 -3
  38. package/dist/dashboard/assets/StudioPage-Cm1JYilI.js.gz +0 -0
  39. package/dist/dashboard/assets/UsagePage-DlFnc3_S.js +56 -0
  40. package/dist/dashboard/assets/UsagePage-DlFnc3_S.js.gz +0 -0
  41. package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
  42. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  43. package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
  44. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  45. package/dist/dashboard/assets/{assert-Chi7pCnt.js → assert-C9FD_JwJ.js} +1 -1
  46. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  47. package/dist/dashboard/assets/{badge-D4pQapKj.js → badge-CR72dasT.js} +1 -1
  48. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  49. package/dist/dashboard/assets/{banner-DH6cg-_B.js → banner-CI9_eSfO.js} +1 -1
  50. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  51. package/dist/dashboard/assets/{card-BKqlUOe4.js → card-DS7CQEsO.js} +1 -1
  52. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  53. package/dist/dashboard/assets/{cli-tools-fTgljTGA.js → cli-tools-CNqzrfdb.js} +1 -1
  54. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  55. package/dist/dashboard/assets/{clipboard-button-D5Ys_sM0.js → clipboard-button-611pSsaX.js} +1 -1
  56. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  57. package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
  58. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  59. package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
  60. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  61. package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
  62. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CQBr6U9c.js → entry-B9Xvtnfj.js} +1 -1
  64. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-Bog0p925.js +4 -0
  66. package/dist/dashboard/assets/entry-Bog0p925.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
  68. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  69. package/dist/dashboard/assets/{github-badge-BlptL8Uf.js → github-badge-sltk1qUi.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-js6TJvdr.js → index-CZQIrdoX.js} +2 -2
  72. package/dist/dashboard/assets/index-CZQIrdoX.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-DUWDSmDV.js → inline-BhY2sSnO.js} +1 -1
  74. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-LVjgZq4f.js → input-V1umFiBk.js} +1 -1
  76. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  77. package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
  78. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-DupvLfDx.js → list-row-1zzZYM0B.js} +1 -1
  80. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-C4pEJUqI.js → live-BivGfuUs.js} +1 -1
  82. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-BRiuufkB.js → model-bans-CmffjLuG.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-B4ROwk8L.js → mutation-46P0oWMe.js} +1 -1
  86. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  87. package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
  88. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-gmjzC3RS.js → query-keys-BFhGpwR1.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-C7tBT5o-.js → routing-CAb4F37R.js} +1 -1
  92. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-WUCXorXw.js → select-BPpSsBgL.js} +1 -1
  94. package/dist/dashboard/assets/select-BPpSsBgL.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-C_zKl1GS.js → settings-DNX9sydi.js} +1 -1
  96. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-_3O0F8Xn.js → stack-BHTfXdz_.js} +1 -1
  98. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-BgTxHcJS.js → state-mPDRlQkl.js} +1 -1
  100. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DEjTtOWf.js → switch-DOmJxg7H.js} +1 -1
  102. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  103. package/dist/dashboard/assets/tokens-COzario9.js +1 -0
  104. package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
  105. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
  106. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
  107. package/dist/dashboard/assets/{useMutation-BWSC0UAS.js → useMutation-Dem0lXQj.js} +1 -1
  108. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  109. package/dist/dashboard/index.html +1 -1
  110. package/dist/dashboard/index.html.gz +0 -0
  111. package/dist/dashboard/precompressed-assets.json +54 -54
  112. package/migrations/README.md +52 -52
  113. package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
  114. package/package.json +95 -95
  115. package/scripts/cli/admin.ts +125 -0
  116. package/scripts/cli/{ui/background.ts → background.ts} +1 -1
  117. package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -244
  118. package/scripts/cli/doctor.ts +37 -0
  119. package/scripts/cli/{support/env.ts → env.ts} +99 -99
  120. package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
  121. package/scripts/cli/instance.ts +224 -0
  122. package/scripts/cli/main.ts +61 -18
  123. package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
  124. package/scripts/cli/port.ts +100 -0
  125. package/scripts/cli/{support/services.ts → services.ts} +88 -88
  126. package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
  127. package/scripts/cli/{command/start.ts → start.ts} +10 -5
  128. package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
  129. package/scripts/cli/tray/index.ts +62 -12
  130. package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
  131. package/scripts/cli/{command/update.ts → update.ts} +39 -39
  132. package/scripts/cli/version.ts +67 -0
  133. package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
  134. package/src/boot/app.ts +697 -693
  135. package/src/boot/dependencies.ts +647 -646
  136. package/src/boot/timeout.ts +2 -13
  137. package/src/config/cooldowns.ts +186 -138
  138. package/src/config/network.ts +8 -14
  139. package/src/config/providers.ts +7 -10
  140. package/src/config/runtime.ts +76 -76
  141. package/src/config/security.ts +15 -0
  142. package/src/config/spec.ts +20 -30
  143. package/src/config/telemetry.ts +0 -20
  144. package/src/config/timeouts.ts +0 -6
  145. package/src/console/admin/model-abuse/contracts.ts +28 -6
  146. package/src/console/admin/model-abuse/routes.ts +56 -3
  147. package/src/console/api-keys/contracts.ts +410 -414
  148. package/src/console/api-keys/routes.ts +881 -880
  149. package/src/console/auth/credentials.ts +3 -3
  150. package/src/console/auth/setup-service.ts +2 -2
  151. package/src/console/backup/export-endpoint.ts +6 -2
  152. package/src/console/backup/nine-router.ts +2 -5
  153. package/src/console/backup/service.ts +4 -0
  154. package/src/console/backup/store.ts +192 -29
  155. package/src/console/backup/validate.ts +3 -45
  156. package/src/console/cli-tools/fs-ops.ts +487 -487
  157. package/src/console/console-router-guards.ts +1 -1
  158. package/src/console/data-management/contracts.ts +13 -6
  159. package/src/console/data-management/operations.ts +23 -18
  160. package/src/console/data-management/routes.ts +0 -1
  161. package/src/console/domain-registration.ts +10 -5
  162. package/src/console/observability/live.ts +22 -0
  163. package/src/console/observability/store.ts +39 -16
  164. package/src/console/observability/usage-dimensions.ts +4 -4
  165. package/src/console/providers/catalog/catalog-projections.ts +6 -7
  166. package/src/console/providers/catalog/model-operations.ts +5 -2
  167. package/src/console/providers/catalog/provider-operations.ts +4 -4
  168. package/src/console/providers/catalog/public-model-store.ts +8 -11
  169. package/src/console/providers/catalog/store.ts +1553 -1553
  170. package/src/console/providers/oauth/routes.ts +8 -12
  171. package/src/console/quota/account-quota-tenant.ts +6 -6
  172. package/src/console/quota/quota-view.ts +1 -1
  173. package/src/console/remote-support/contracts.ts +61 -0
  174. package/src/console/remote-support/routes.ts +65 -0
  175. package/src/console/remote-support/service.ts +207 -0
  176. package/src/console/response-compression.ts +0 -3
  177. package/src/console/routing/pools/probe-result.ts +3 -2
  178. package/src/console/routing/pools/probe-verdict.ts +132 -132
  179. package/src/console/routing/pools/store.ts +8 -8
  180. package/src/console/settings/contracts.ts +58 -2
  181. package/src/console/settings/store.ts +30 -3
  182. package/src/console/shared/cursor.ts +8 -8
  183. package/src/console/shared/errors.ts +1 -3
  184. package/src/network/http2-bidi.ts +2 -4
  185. package/src/network/pool/agent.ts +478 -478
  186. package/src/network/pool/selector.ts +5 -4
  187. package/src/network/retry.ts +1 -2
  188. package/src/network/ssrf.ts +5 -4
  189. package/src/observability/event-compaction.ts +80 -78
  190. package/src/observability/event-day-index.ts +13 -15
  191. package/src/observability/event-log.ts +4 -4
  192. package/src/observability/logger.ts +6 -6
  193. package/src/observability/payload-capture.ts +151 -33
  194. package/src/observability/payload-store.ts +397 -440
  195. package/src/observability/redaction.ts +5 -6
  196. package/src/observability/request-notify.ts +43 -0
  197. package/src/observability/telemetry-status.ts +8 -9
  198. package/src/observability/token-speed.ts +5 -4
  199. package/src/persistence/db-mode.ts +7 -7
  200. package/src/persistence/readiness.ts +1 -1
  201. package/src/persistence/schema.ts +1406 -1402
  202. package/src/persistence/shared-cache.ts +1 -2
  203. package/src/persistence/stores/api-key-store.ts +18 -6
  204. package/src/persistence/stores/share-store.ts +4 -3
  205. package/src/persistence/stores/telemetry-store.ts +236 -237
  206. package/src/protocol/canonical-model.ts +11 -3
  207. package/src/protocol/codecs.ts +138 -4
  208. package/src/protocol/gateway-error.ts +6 -4
  209. package/src/protocol/request/chat.ts +21 -1
  210. package/src/protocol/request/codex.ts +10 -1
  211. package/src/protocol/request/messages.ts +4 -5
  212. package/src/protocol/request/responses.ts +9 -1
  213. package/src/protocol/response/chat.ts +34 -4
  214. package/src/protocol/response/codex.ts +7 -4
  215. package/src/protocol/response/dsml.ts +140 -28
  216. package/src/protocol/response/responses.ts +11 -5
  217. package/src/protocol/stream-error-frames.ts +4 -5
  218. package/src/providers/authentication/jwt-validator.ts +9 -10
  219. package/src/providers/authentication/oauth-refresh-service.ts +9 -11
  220. package/src/providers/configured-openai-providers.ts +134 -136
  221. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  222. package/src/providers/discovery/catalog/catalog-format.ts +3 -6
  223. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  224. package/src/providers/discovery/probe-phases.ts +4 -4
  225. package/src/providers/discovery/probe-wire.ts +6 -13
  226. package/src/providers/discovery/probing-service.ts +1117 -1045
  227. package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
  228. package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
  231. package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
  232. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
  233. package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
  234. package/src/providers/integrations/buddy/buddy-media.ts +2 -2
  235. package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
  236. package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
  237. package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
  238. package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
  239. package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
  240. package/src/providers/integrations/claude/claude.ts +608 -608
  241. package/src/providers/integrations/cline/cline-quota.ts +132 -132
  242. package/src/providers/integrations/cline/cline.ts +537 -539
  243. package/src/providers/integrations/codex/codex-oauth.ts +1 -1
  244. package/src/providers/integrations/commandcode/commandcode.ts +674 -675
  245. package/src/providers/integrations/commandcode/discovery.ts +117 -116
  246. package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
  247. package/src/providers/integrations/cursor/cursor-quota.ts +309 -310
  248. package/src/providers/integrations/cursor/cursor.ts +11 -163
  249. package/src/providers/integrations/factory/factory-models.ts +3 -5
  250. package/src/providers/integrations/grok/grok-install-id.ts +2 -3
  251. package/src/providers/integrations/grok/grok.ts +6 -7
  252. package/src/providers/integrations/hermes.ts +14 -18
  253. package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
  254. package/src/providers/integrations/kiro/kiro.ts +1 -1
  255. package/src/providers/integrations/mistral.ts +6 -6
  256. package/src/providers/integrations/opencode/fingerprint.ts +3 -3
  257. package/src/providers/integrations/opencode/provider.ts +8 -8
  258. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
  259. package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
  260. package/src/providers/integrations/qoder/qoder.ts +884 -865
  261. package/src/providers/integrations/shared/connect.ts +6 -6
  262. package/src/providers/integrations/trae/trae-catalog.ts +1 -2
  263. package/src/providers/integrations/trae/trae.ts +2 -2
  264. package/src/providers/integrations/zcode/oauth.ts +6 -1
  265. package/src/providers/operations/account-health-service.ts +1528 -1523
  266. package/src/providers/operations/account-reset-service.ts +1 -1
  267. package/src/providers/operations/client-versions.ts +6 -6
  268. package/src/providers/operations/custom-cli-headers.ts +3 -4
  269. package/src/providers/operations/provider-catalog-seeder.ts +95 -95
  270. package/src/providers/operations/provider-catalog-service.ts +3 -4
  271. package/src/providers/provider-metadata.ts +4 -8
  272. package/src/providers/provider-registry.ts +6 -7
  273. package/src/providers/provider-usage.ts +1 -1
  274. package/src/security/abuse.ts +15 -10
  275. package/src/security/access-control.ts +144 -146
  276. package/src/security/admission/contracts.ts +11 -0
  277. package/src/security/admission/in-memory-store.ts +4 -3
  278. package/src/security/admission/lease-sweep.ts +6 -6
  279. package/src/security/admission/reasons.ts +5 -1
  280. package/src/security/admission/redis-store.ts +211 -210
  281. package/src/security/admission/service.ts +4 -0
  282. package/src/security/api-key-auth.ts +504 -504
  283. package/src/security/ip-boundary.ts +155 -15
  284. package/src/security/model-abuse.ts +366 -94
  285. package/src/security/outbound-headers.ts +3 -4
  286. package/src/shared/sleep.ts +4 -0
  287. package/src/transport/dispatch/attempt-finalize.ts +47 -5
  288. package/src/transport/dispatch/attempt-loop.ts +531 -530
  289. package/src/transport/dispatch/stream-bridge.ts +1 -3
  290. package/src/transport/dispatch/streaming/finalize.ts +1 -1
  291. package/src/transport/dispatch/streaming/stream.ts +5 -6
  292. package/src/transport/failure-policy.ts +162 -8
  293. package/src/transport/guards/auth-guard.ts +7 -4
  294. package/src/transport/guards/concurrency-guard.ts +0 -5
  295. package/src/transport/guards/error-lifecycle.ts +13 -6
  296. package/src/transport/guards/pipeline.ts +15 -3
  297. package/src/transport/guards/request-context.ts +8 -2
  298. package/src/transport/request/inflight.ts +5 -6
  299. package/src/transport/request/preparer.ts +2 -2
  300. package/src/transport/request/state.ts +12 -8
  301. package/src/transport/routing/admission.ts +4 -0
  302. package/src/transport/routing/route-catalog.ts +8 -1
  303. package/src/transport/routing/route-model.ts +46 -14
  304. package/src/transport/routing/router.ts +21 -14
  305. package/src/transport/surface/completion.ts +2 -3
  306. package/src/transport/surface/dialects.ts +5 -6
  307. package/src/transport/surface/responses/encode.ts +38 -0
  308. package/src/transport/surface/responses/parse.ts +4 -0
  309. package/src/transport/translation/capabilities.ts +2 -3
  310. package/src/transport/translation/quirks.ts +3 -3
  311. package/src/workers/buddy-growth-tasks.ts +5 -10
  312. package/src/workers/daily-checkin.ts +17 -23
  313. package/src/workers/quota-refresh-sweep.ts +26 -26
  314. package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
  315. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +0 -1
  316. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
  317. package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
  318. package/dist/dashboard/assets/CombosPage-Bsan4679.js +0 -1
  319. package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
  320. package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
  321. package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
  322. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
  323. package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
  324. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js +0 -1
  325. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
  326. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +0 -1
  327. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
  328. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +0 -3
  329. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
  330. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
  331. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +0 -7
  332. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
  333. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +0 -1
  334. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
  335. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +0 -1
  336. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
  337. package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
  338. package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
  339. package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
  340. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js +0 -56
  341. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
  342. package/dist/dashboard/assets/api-keys-VY68Mhd1.js +0 -1
  343. package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
  344. package/dist/dashboard/assets/app-COlK95oi.css +0 -2
  345. package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
  346. package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
  347. package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
  348. package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
  349. package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
  350. package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
  351. package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
  352. package/dist/dashboard/assets/dialog-BYj-8WuK.js +0 -1
  353. package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
  354. package/dist/dashboard/assets/drawer-CmXIZ6H1.js +0 -1
  355. package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
  356. package/dist/dashboard/assets/entry-CBg5zeu8.js +0 -1
  357. package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
  358. package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-CgdSAzEV.js +0 -4
  360. package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
  361. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
  363. package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
  365. package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-CPfxpsfy.js +0 -1
  367. package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
  368. package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
  369. package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
  370. package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
  371. package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
  372. package/dist/dashboard/assets/numbers-YNcHvRY7.js +0 -2
  373. package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
  374. package/dist/dashboard/assets/query-keys-gmjzC3RS.js.gz +0 -0
  375. package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
  376. package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
  377. package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
  378. package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
  379. package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
  380. package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
  381. package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
  382. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  383. package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
  384. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  385. package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
  386. package/scripts/cli/command/admin.ts +0 -65
  387. package/scripts/cli/command/doctor.ts +0 -257
  388. package/scripts/cli/gateway/drain-token.ts +0 -78
  389. package/scripts/cli/gateway/instance-lock.ts +0 -105
  390. package/scripts/cli/gateway/stop.ts +0 -51
  391. package/scripts/cli/support/bun.ts +0 -27
  392. package/scripts/cli/support/version.ts +0 -37
  393. /package/dist/dashboard/assets/{app-DCQZRmHY.js → app-BIt446yE.js} +0 -0
  394. /package/dist/dashboard/assets/{app-DCQZRmHY.js.gz → app-BIt446yE.js.gz} +0 -0
  395. /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
@@ -1,880 +1,881 @@
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
- }
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 must be copied into the patch explicitly — it is
251
+ // accepted and validated by the body schema but is not the credential,
252
+ // so omitting it here leaves the name unchanged. Blank falls back to the
253
+ // existing label rather than wiping it, since `label` is NOT NULL. The
254
+ // default key is renameable like any other (c2a93545); gating on
255
+ // `isDefault` would drop exactly the one patch that changes a name and
256
+ // leave the store with an empty `set(...)` that throws.
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 looser 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 baseline the purge
344
+ // removed.
345
+ const dailyAdded = isNewLimit(patchRequest.dailyTokenLimit, current.dailyTokenLimit);
346
+ const monthlyAdded = isNewLimit(patchRequest.monthlyTokenLimit, current.monthlyTokenLimit);
347
+ if ((dailyAdded || monthlyAdded) && config.bucketSpend && config.admissionService.seedBuckets) {
348
+ const now = new Date();
349
+ const spend = await config.bucketSpend(keyId, now).catch(() => null);
350
+ if (spend) {
351
+ await config.admissionService.seedBuckets({
352
+ apiKeyId: keyId,
353
+ now: now.getTime(),
354
+ ...(dailyAdded ? { daily: spend.daily } : {}),
355
+ ...(monthlyAdded ? { monthly: spend.monthly } : {}),
356
+ });
357
+ }
358
+ }
359
+ await config.auditSink?.record({
360
+ access: authorized,
361
+ action: "api_key.updated",
362
+ target: keyId,
363
+ detail: { fields: Object.keys(request) },
364
+ });
365
+ return { ...sanitizeApiKeyResponse(updated), ...(secret === undefined ? {} : { secret }) };
366
+ },
367
+ async revokeKey(access: AccessDecision | undefined, keyId: string): Promise<{ success: true }> {
368
+ const authorized = requireTenantScope(access, "dashboard:write");
369
+ const target = await config.store.get(authorized.tenantId, keyId);
370
+ if (!target)
371
+ throw new ConsoleDomainError("key_not_found", 404, "Key not found");
372
+ // The gateway routes every request off this key and the CLI reads it for
373
+ // the CLI, so removing it would strand the installation with no way to
374
+ // authenticate. Rotation replaces the secret and keeps the key present.
375
+ if (isDefaultGatewayApiKey(target))
376
+ throw new ConsoleDomainError(
377
+ "default_key_not_revocable",
378
+ 409,
379
+ "The default gateway API key cannot be deleted. Rotate it instead.",
380
+ );
381
+ const children = await config.store.listChildren(authorized.tenantId, keyId);
382
+ const removed = await config.store.remove(authorized.tenantId, keyId);
383
+ if (!removed) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
384
+ invalidateApiKeyCache(keyId);
385
+ for (const child of children) invalidateApiKeyCache(child.id);
386
+ await Promise.all([
387
+ config.admissionService.purgeKey(keyId),
388
+ ...children.map((child) => config.admissionService.purgeKey(child.id)),
389
+ ]);
390
+ await config.auditSink?.record({
391
+ access: authorized,
392
+ action: "api_key.revoked",
393
+ target: keyId,
394
+ });
395
+ return { success: true };
396
+ },
397
+ /**
398
+ * Establishes or rotates the key's single stable link.
399
+ *
400
+ * The console shows one link that never moves; `regenerate` replaces the
401
+ * token in place so the previous URL stops resolving. A share template
402
+ * hands out child keys; a personal key hands out the key itself.
403
+ */
404
+ async shareKey(
405
+ access: AccessDecision | undefined,
406
+ keyId: string,
407
+ options: { expiresAt?: string | null; origin?: string; regenerate?: boolean } = {},
408
+ ): Promise<ShareKeyResponse> {
409
+ const authorized = requireTenantScope(access, "dashboard:write");
410
+ const shareStore = config.shareStore;
411
+ if (!shareStore)
412
+ throw new ConsoleDomainError("capability_unsupported", 501, "key share not configured");
413
+ const record = await config.store.get(authorized.tenantId, keyId);
414
+ if (!record || record.revokedAt !== undefined)
415
+ throw new ConsoleDomainError("key_not_found", 404, "Key not found");
416
+ if (record.parentKeyId !== undefined)
417
+ throw new ConsoleDomainError(
418
+ "key_not_share_parent",
419
+ 409,
420
+ "Only a top-level key can carry a handoff link",
421
+ );
422
+ const kind: ShareLinkKind = record.keyMode === "share" ? "enroll" : "handoff";
423
+ const origin = (options.origin ?? "").replace(/\/$/, "");
424
+ // Re-issuing without regeneration must return the link that is actually
425
+ // stored. Minting a fresh token here would hand back a URL that was never
426
+ // persisted, so the link would be dead on arrival.
427
+ if (options.regenerate !== true) {
428
+ const existing = await shareStore.findTokenForApiKey(keyId);
429
+ if (existing !== null && existing.tokenEncrypted !== null) {
430
+ try {
431
+ const token = decryptCredentialToString(existing.tokenEncrypted);
432
+ return {
433
+ id: existing.id,
434
+ url: `${origin}/share/${token}`,
435
+ token,
436
+ kind: existing.kind,
437
+ expiresAt: existing.expiresAt === null ? null : existing.expiresAt.toISOString(),
438
+ };
439
+ } catch {
440
+ // A row written under a different encryption key cannot be shown;
441
+ // fall through and establish a fresh link.
442
+ }
443
+ }
444
+ }
445
+ let expiresAt: Date | null = null;
446
+ if (options.expiresAt !== undefined && options.expiresAt !== null) {
447
+ const parsed = new Date(options.expiresAt);
448
+ if (Number.isNaN(parsed.getTime()))
449
+ throw new ConsoleDomainError(
450
+ "invalid_share_expiry",
451
+ 400,
452
+ "expiresAt must be a valid ISO timestamp",
453
+ );
454
+ expiresAt = parsed;
455
+ }
456
+ const token = randomBytes(32).toString("base64url");
457
+ const link = await shareStore.create({
458
+ apiKeyId: keyId,
459
+ tokenHash: hashShareToken(token),
460
+ tokenEncrypted: encryptCredential(token),
461
+ slug: generateShareSlug(),
462
+ kind,
463
+ expiresAt,
464
+ rotate: options.regenerate === true,
465
+ });
466
+ await config.auditSink?.record({
467
+ access: authorized,
468
+ action: options.regenerate === true ? "api_key.share_regenerated" : "api_key.shared",
469
+ target: keyId,
470
+ detail: { shareId: link.id, kind },
471
+ });
472
+ return {
473
+ id: link.id,
474
+ url: `${origin}/share/${token}`,
475
+ token,
476
+ kind: link.kind,
477
+ expiresAt: link.expiresAt === null ? null : link.expiresAt.toISOString(),
478
+ };
479
+ },
480
+ /**
481
+ * The key's stable link with its retained token, for the console to show
482
+ * without rotating it. Returns null when the key has no link yet or the
483
+ * link predates token retention.
484
+ */
485
+ async getShare(
486
+ access: AccessDecision | undefined,
487
+ keyId: string,
488
+ options: { origin?: string } = {},
489
+ ): Promise<ShareKeyResponse | null> {
490
+ const authorized = requireTenantScope(access, "dashboard:read");
491
+ const shareStore = config.shareStore;
492
+ if (!shareStore)
493
+ throw new ConsoleDomainError("capability_unsupported", 501, "key share not configured");
494
+ const record = await config.store.get(authorized.tenantId, keyId);
495
+ if (!record || record.revokedAt !== undefined)
496
+ throw new ConsoleDomainError("key_not_found", 404, "Key not found");
497
+ if (record.parentKeyId !== undefined)
498
+ throw new ConsoleDomainError(
499
+ "key_not_share_parent",
500
+ 409,
501
+ "Only a top-level key can carry a handoff link",
502
+ );
503
+ const link = await shareStore.findTokenForApiKey(keyId);
504
+ if (link === null) return null;
505
+ let token: string | null = null;
506
+ if (link.tokenEncrypted !== null) {
507
+ try {
508
+ token = decryptCredentialToString(link.tokenEncrypted);
509
+ } catch {
510
+ // A row written under a different encryption key cannot be shown.
511
+ // The page itself survives: the slug is held in the clear, so the
512
+ // address stays valid and the operator only loses the bearer half.
513
+ token = null;
514
+ }
515
+ }
516
+ // Nothing to point at: neither the address nor the secret survived.
517
+ if (token === null && link.slug === null) return null;
518
+ const origin = (options.origin ?? "").replace(/\/$/, "");
519
+ return {
520
+ id: link.id,
521
+ // The slug is the address; the token is the bearer half. Preferring the
522
+ // slug keeps a restored page reachable under the same URL even when the
523
+ // encryption key that sealed its token is gone.
524
+ url: `${origin}/share/${link.slug ?? token}`,
525
+ token,
526
+ kind: link.kind,
527
+ expiresAt: link.expiresAt === null ? null : link.expiresAt.toISOString(),
528
+ };
529
+ },
530
+ /**
531
+ * Rotates a personal key's credential in place and re-points its handoff
532
+ * link at the new secret. Share templates have no credential of their own,
533
+ * so they are rejected here.
534
+ */
535
+ async regenerateKey(
536
+ access: AccessDecision | undefined,
537
+ keyId: string,
538
+ options: { origin?: string } = {},
539
+ ): Promise<{ secret: string; share: ShareKeyResponse | null }> {
540
+ const authorized = requireTenantScope(access, "dashboard:write");
541
+ const current = await config.store.get(authorized.tenantId, keyId);
542
+ if (!current || current.revokedAt !== undefined)
543
+ throw new ConsoleDomainError("key_not_found", 404, "Key not found");
544
+ if (current.parentKeyId !== undefined)
545
+ throw new ConsoleDomainError(
546
+ "shared_key_managed_by_parent",
547
+ 409,
548
+ "Edit or revoke this key through its share parent",
549
+ );
550
+ if (current.keyMode !== "personal")
551
+ throw new ConsoleDomainError(
552
+ "key_not_personal",
553
+ 409,
554
+ "Only a personal key can be regenerated; rotate a share template's link instead",
555
+ );
556
+ const generated = generateApiKeySecret();
557
+ const updated = await config.store.update(authorized.tenantId, keyId, {
558
+ keyHash: generated.hash,
559
+ keyEncrypted: encryptCredential(generated.secret),
560
+ keyPrefix: generated.prefix,
561
+ });
562
+ if (!updated) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
563
+ invalidateApiKeyCache(keyId);
564
+ await config.admissionService.purgeKey(keyId);
565
+ const shareStore = config.shareStore;
566
+ let share: ShareKeyResponse | null = null;
567
+ if (shareStore) {
568
+ const token = randomBytes(32).toString("base64url");
569
+ const link = await shareStore.create({
570
+ apiKeyId: keyId,
571
+ tokenHash: hashShareToken(token),
572
+ tokenEncrypted: encryptCredential(token),
573
+ slug: generateShareSlug(),
574
+ kind: "handoff",
575
+ expiresAt: null,
576
+ rotate: true,
577
+ });
578
+ const origin = (options.origin ?? "").replace(/\/$/, "");
579
+ share = {
580
+ id: link.id,
581
+ url: `${origin}/share/${token}`,
582
+ token,
583
+ kind: link.kind,
584
+ expiresAt: null,
585
+ };
586
+ }
587
+ await config.auditSink?.record({
588
+ access: authorized,
589
+ action: "api_key.regenerated",
590
+ target: keyId,
591
+ });
592
+ return { secret: generated.secret, share };
593
+ },
594
+ /**
595
+ * Returns a personal key's stored secret. The ciphertext exists exactly so
596
+ * the owner can read it back; a share template has none, so it is refused.
597
+ */
598
+ async revealKey(
599
+ access: AccessDecision | undefined,
600
+ keyId: string,
601
+ ): Promise<{ secret: string }> {
602
+ const authorized = requireTenantScope(access, "dashboard:read");
603
+ const current = await config.store.get(authorized.tenantId, keyId);
604
+ if (!current || current.revokedAt !== undefined)
605
+ throw new ConsoleDomainError("key_not_found", 404, "Key not found");
606
+ if (current.keyMode !== "personal" || current.keyEncrypted === undefined)
607
+ throw new ConsoleDomainError(
608
+ "key_not_personal",
609
+ 409,
610
+ "Only a personal key keeps a recoverable secret",
611
+ );
612
+ await config.auditSink?.record({
613
+ access: authorized,
614
+ action: "api_key.revealed",
615
+ target: keyId,
616
+ });
617
+ return { secret: decryptCredentialToString(current.keyEncrypted) };
618
+ },
619
+ async listShares(
620
+ access: AccessDecision | undefined,
621
+ keyId: string,
622
+ ): Promise<ShareLinkResponse[]> {
623
+ const authorized = requireTenantScope(access, "dashboard:read");
624
+ if (!config.shareStore)
625
+ throw new ConsoleDomainError("capability_unsupported", 501, "key share not configured");
626
+ const record = await config.store.get(authorized.tenantId, keyId);
627
+ if (!record || record.keyMode !== "share" || record.parentKeyId !== undefined)
628
+ throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
629
+ return (await config.shareStore.listForApiKey(keyId)).map(mapShareLinkResponse);
630
+ },
631
+ async listSharedKeys(
632
+ access: AccessDecision | undefined,
633
+ parentKeyId: string,
634
+ ): Promise<readonly SharedKeySummary[]> {
635
+ const authorized = requireTenantScope(access, "dashboard:read");
636
+ const activity = config.shareActivity;
637
+ if (!activity)
638
+ throw new ConsoleDomainError("capability_unsupported", 501, "share telemetry not configured");
639
+ const parent = await config.store.get(authorized.tenantId, parentKeyId);
640
+ if (!parent || parent.revokedAt !== undefined ||
641
+ parent.keyMode !== "share" || parent.parentKeyId !== undefined)
642
+ throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
643
+ const children = await config.store.listChildren(authorized.tenantId, parentKeyId);
644
+ return activity.getSharedKeySummaries(authorized.tenantId, children);
645
+ },
646
+ async getSharedKeyActivity(
647
+ access: AccessDecision | undefined,
648
+ parentKeyId: string,
649
+ childKeyId: string,
650
+ ): Promise<SharedKeyActivityDetail> {
651
+ const authorized = requireTenantScope(access, "dashboard:read");
652
+ const activity = config.shareActivity;
653
+ if (!activity)
654
+ throw new ConsoleDomainError("capability_unsupported", 501, "share telemetry not configured");
655
+ const parent = await config.store.get(authorized.tenantId, parentKeyId);
656
+ if (!parent || parent.revokedAt !== undefined ||
657
+ parent.keyMode !== "share" || parent.parentKeyId !== undefined)
658
+ throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
659
+ const children = await config.store.listChildren(authorized.tenantId, parentKeyId);
660
+ if (!children.some((child) => child.id === childKeyId))
661
+ throw new ConsoleDomainError("key_not_found", 404, "Shared API key not found");
662
+ return activity.getSharedKeyDetail(authorized.tenantId, childKeyId);
663
+ },
664
+ async revokeShare(
665
+ access: AccessDecision | undefined,
666
+ keyId: string,
667
+ shareId: string,
668
+ ): Promise<{ success: true }> {
669
+ const authorized = requireTenantScope(access, "dashboard:write");
670
+ const shareStore = config.shareStore;
671
+ if (!shareStore)
672
+ throw new ConsoleDomainError("capability_unsupported", 501, "key share not configured");
673
+ const record = await config.store.get(authorized.tenantId, keyId);
674
+ if (!record || record.keyMode !== "share" || record.parentKeyId !== undefined)
675
+ throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
676
+ const revoked = await shareStore.revoke(keyId, shareId);
677
+ if (!revoked) throw new ConsoleDomainError("share_not_found", 404, "Share link not found");
678
+ await config.auditSink?.record({
679
+ access: authorized,
680
+ action: "api_key.share_revoked",
681
+ target: shareId,
682
+ detail: { keyId },
683
+ });
684
+ return { success: true };
685
+ },
686
+ };
687
+ return operations;
688
+ }
689
+
690
+ const apiKeyBody = t.Object({
691
+ enabled: t.Optional(t.Boolean()),
692
+ label: t.Optional(t.String()),
693
+ keyMode: t.Optional(literalUnion(API_KEY_MODES)),
694
+ scopes: t.Optional(t.Array(t.String())),
695
+ keyPrefix: t.Optional(t.String()),
696
+ key: t.Optional(t.String()),
697
+ // Limit fields accept `null` to clear a previously set limit ("unlimited");
698
+ // `undefined` leaves it unchanged.
699
+ requestsPerMinute: t.Optional(t.Union([t.Number(), t.Null()])),
700
+ dailyTokenLimit: t.Optional(t.Union([t.Number(), t.Null()])),
701
+ monthlyTokenLimit: t.Optional(t.Union([t.Number(), t.Null()])),
702
+ lifetimeTokenBudget: t.Optional(t.Union([t.Number(), t.Null()])),
703
+ maxConcurrentRequests: t.Optional(t.Union([t.Number(), t.Null()])),
704
+ modelPrefix: t.Optional(t.String()),
705
+ modelAccessMode: t.Optional(literalUnion(API_KEY_MODEL_ACCESS_MODES)),
706
+ modelList: t.Optional(t.Array(t.String())),
707
+ clientRouterDenylist: t.Optional(t.Array(t.String())),
708
+ notesTitle: t.Optional(t.String()),
709
+ notesSubtitle: t.Optional(t.String()),
710
+ notesBody: t.Optional(t.String()),
711
+ sharePopupEnabled: t.Optional(t.Union([t.Boolean(), t.Null()])),
712
+ sharePopupImage: t.Optional(t.Union([t.String({ maxLength: 2_800_000 }), t.Null()])),
713
+ sharePopupTitle: t.Optional(t.Union([t.String({ maxLength: 120 }), t.Null()])),
714
+ sharePopupBody: t.Optional(t.Union([t.String({ maxLength: 1200 }), t.Null()])),
715
+ });
716
+
717
+ /** Full replacement order for the tenant's top-level credential list. */
718
+ const reorderKeysBody = t.Object({ keyIds: t.Array(t.String(), { minItems: 1 }) });
719
+
720
+ const apiKeyShareBody = t.Object({
721
+ expiresAt: t.Optional(t.Union([t.String(), t.Null()])),
722
+ /** Replaces the existing link's token so the previous URL stops resolving. */
723
+ regenerate: t.Optional(t.Boolean()),
724
+ });
725
+
726
+ /**
727
+ * Serializes an intentional `null` payload as the JSON literal.
728
+ *
729
+ * Elysia reads a bare `null` return as "no content" and emits `200` with an
730
+ * empty body and no `content-type`, which a JSON client cannot parse. `null`
731
+ * here is a real answer — "this key has no link yet" — and the response type
732
+ * declares it, so it has to arrive as the bytes `null` rather than as nothing.
733
+ */
734
+ function jsonNull(): Response {
735
+ return new Response("null", {
736
+ status: 200,
737
+ headers: { "content-type": "application/json; charset=utf-8" },
738
+ });
739
+ }
740
+ /** Creates tenant-bound API-key routes with request-local authorization. */
741
+ export function createApiKeyRoutes(config: ApiKeyConfig): Elysia {
742
+ const factory = createApiKeyOperations(config);
743
+ return new Elysia({ prefix: "/api-keys" })
744
+ .get("/", async ({ request, set }) => {
745
+ try {
746
+ return await factory.listKeys(config.accessResolver(request));
747
+ } catch (error) {
748
+ return errorResponse(error, set, "API-key operation failed");
749
+ }
750
+ })
751
+ .post("/reorder", { body: reorderKeysBody }, async ({ request, body, set }) => {
752
+ try {
753
+ await factory.reorderKeys(config.accessResolver(request), body.keyIds);
754
+ return { success: true };
755
+ } catch (error) {
756
+ return errorResponse(error, set, "API-key operation failed");
757
+ }
758
+ })
759
+ .get("/:keyId", async ({ request, params, set }) => {
760
+ try {
761
+ return await factory.getKeyDetail(config.accessResolver(request), params.keyId);
762
+ } catch (error) {
763
+ return errorResponse(error, set, "API-key operation failed");
764
+ }
765
+ })
766
+ .get("/:keyId/share-popup-image", async ({ request, params, set }) => {
767
+ try {
768
+ const result = await factory.getSharePopupImage(config.accessResolver(request), params.keyId);
769
+ return new Response(new Blob([popupImageBytes(result.image)], { type: result.mime }), {
770
+ headers: {
771
+ "content-length": String(result.image.byteLength),
772
+ "cache-control": "private, max-age=300",
773
+ "x-content-type-options": "nosniff",
774
+ },
775
+ });
776
+ } catch (error) {
777
+ return errorResponse(error, set, "API key operation failed");
778
+ }
779
+ })
780
+ .get("/:keyId/shares", async ({ request, params, set }) => {
781
+ try {
782
+ return await factory.listShares(config.accessResolver(request), params.keyId);
783
+ } catch (error) {
784
+ return errorResponse(error, set, "API-key operation failed");
785
+ }
786
+ })
787
+ .get("/:keyId/shared-keys", async ({ request, params, set }) => {
788
+ try {
789
+ return await factory.listSharedKeys(config.accessResolver(request), params.keyId);
790
+ } catch (error) {
791
+ return errorResponse(error, set, "API-key operation failed");
792
+ }
793
+ })
794
+ .get("/:keyId/shared-keys/:childKeyId/activity", async ({ request, params, set }) => {
795
+ try {
796
+ return await factory.getSharedKeyActivity(
797
+ config.accessResolver(request),
798
+ params.keyId,
799
+ params.childKeyId,
800
+ );
801
+ } catch (error) {
802
+ return errorResponse(error, set, "API-key operation failed");
803
+ }
804
+ })
805
+ .delete("/:keyId/shares/:shareId", async ({ request, params, set }) => {
806
+ try {
807
+ return await factory.revokeShare(
808
+ config.accessResolver(request),
809
+ params.keyId,
810
+ params.shareId,
811
+ );
812
+ } catch (error) {
813
+ return errorResponse(error, set, "API-key operation failed");
814
+ }
815
+ })
816
+ .post("/", { body: apiKeyBody }, async ({ request, body, set }) => {
817
+ try {
818
+ set.status = 201;
819
+ return await factory.createKey(config.accessResolver(request), parseRequest(body));
820
+ } catch (error) {
821
+ return errorResponse(error, set, "API-key operation failed");
822
+ }
823
+ })
824
+ .patch("/:keyId", { body: apiKeyBody }, async ({ request, params, body, set }) => {
825
+ try {
826
+ return await factory.updateKey(
827
+ config.accessResolver(request),
828
+ params.keyId,
829
+ parseRequest(body),
830
+ );
831
+ } catch (error) {
832
+ return errorResponse(error, set, "API-key operation failed");
833
+ }
834
+ })
835
+ .delete("/:keyId", async ({ request, params, set }) => {
836
+ try {
837
+ return await factory.revokeKey(config.accessResolver(request), params.keyId);
838
+ } catch (error) {
839
+ return errorResponse(error, set, "API-key operation failed");
840
+ }
841
+ })
842
+ .get("/:keyId/share", async ({ request, params, set }) => {
843
+ try {
844
+ const share = await factory.getShare(config.accessResolver(request), params.keyId, {
845
+ origin: new URL(request.url).origin,
846
+ });
847
+ return share ?? jsonNull();
848
+ } catch (error) {
849
+ return errorResponse(error, set, "API-key operation failed");
850
+ }
851
+ })
852
+ .post("/:keyId/share", { body: t.Optional(apiKeyShareBody) }, async ({ request, params, body, set }) => {
853
+ try {
854
+ set.status = 201;
855
+ const input = (body ?? {}) as { expiresAt?: string | null; regenerate?: boolean };
856
+ return await factory.shareKey(config.accessResolver(request), params.keyId, {
857
+ ...(input.expiresAt === undefined ? {} : { expiresAt: input.expiresAt }),
858
+ ...(input.regenerate === undefined ? {} : { regenerate: input.regenerate }),
859
+ origin: new URL(request.url).origin,
860
+ });
861
+ } catch (error) {
862
+ return errorResponse(error, set, "API-key operation failed");
863
+ }
864
+ })
865
+ .post("/:keyId/reveal", async ({ request, params, set }) => {
866
+ try {
867
+ return await factory.revealKey(config.accessResolver(request), params.keyId);
868
+ } catch (error) {
869
+ return errorResponse(error, set, "API-key operation failed");
870
+ }
871
+ })
872
+ .post("/:keyId/regenerate", async ({ request, params, set }) => {
873
+ try {
874
+ return await factory.regenerateKey(config.accessResolver(request), params.keyId, {
875
+ origin: new URL(request.url).origin,
876
+ });
877
+ } catch (error) {
878
+ return errorResponse(error, set, "API-key operation failed");
879
+ }
880
+ }) as unknown as Elysia;
881
+ }