cartethyia 2.7.2 → 2.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (405) hide show
  1. package/.env.example +2 -0
  2. package/dist/dashboard/assets/{BannedPage-XaUmiBrC.js → BannedPage-ChG5svFb.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-ChG5svFb.js.gz +0 -0
  4. package/dist/dashboard/assets/CliToolDetailPage-BIcWQun7.js +1 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-BIcWQun7.js.gz +0 -0
  6. package/dist/dashboard/assets/CliToolsPage-Bf2VelDe.js +1 -0
  7. package/dist/dashboard/assets/CliToolsPage-Bf2VelDe.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-CXGS_Ct-.js → CombosPage-DCgm5g6g.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-DCgm5g6g.js.gz +0 -0
  10. package/dist/dashboard/assets/ConsoleLogPage-a8mD0QF1.js +1 -0
  11. package/dist/dashboard/assets/ConsoleLogPage-a8mD0QF1.js.gz +0 -0
  12. package/dist/dashboard/assets/CustomizationPage-ggeMSjte.js +1 -0
  13. package/dist/dashboard/assets/CustomizationPage-ggeMSjte.js.gz +0 -0
  14. package/dist/dashboard/assets/{HealthEventsModal-BKtAPYVF.js → HealthEventsModal-DzSWzE_j.js} +1 -1
  15. package/dist/dashboard/assets/HealthEventsModal-DzSWzE_j.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-BdmRLnq2.js → LoginPage-U6AKaJcF.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-U6AKaJcF.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-BH8Tehdi.js → ModelPicker-BMS2t8BA.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-BMS2t8BA.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-D2D4IiHl.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-D2D4IiHl.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-CrNf7j_X.js +5 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-CrNf7j_X.js.gz +0 -0
  24. package/dist/dashboard/assets/ProvidersPage-BSDbquc1.js +1 -0
  25. package/dist/dashboard/assets/ProvidersPage-BSDbquc1.js.gz +0 -0
  26. package/dist/dashboard/assets/ProxyPage-lsrx7NYg.js +9 -0
  27. package/dist/dashboard/assets/ProxyPage-lsrx7NYg.js.gz +0 -0
  28. package/dist/dashboard/assets/QuotaPage-L0skzML8.js +1 -0
  29. package/dist/dashboard/assets/QuotaPage-L0skzML8.js.gz +0 -0
  30. package/dist/dashboard/assets/SettingsPage-CmAbjRHW.js +1 -0
  31. package/dist/dashboard/assets/SettingsPage-CmAbjRHW.js.gz +0 -0
  32. package/dist/dashboard/assets/SetupPage-f0DE1h0-.js +1 -0
  33. package/dist/dashboard/assets/SetupPage-f0DE1h0-.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-Dqf4zpS-.js → SortableList-ClzpwyET.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-ClzpwyET.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-Byj2lAEm.js → StudioPage-Cq5tW7hW.js} +3 -3
  37. package/dist/dashboard/assets/StudioPage-Cq5tW7hW.js.gz +0 -0
  38. package/dist/dashboard/assets/UsagePage-Dfc9rWQn.js +56 -0
  39. package/dist/dashboard/assets/UsagePage-Dfc9rWQn.js.gz +0 -0
  40. package/dist/dashboard/assets/api-keys-C38iXoO4.js +1 -0
  41. package/dist/dashboard/assets/api-keys-C38iXoO4.js.gz +0 -0
  42. package/dist/dashboard/assets/app-a159k0ub.css +2 -0
  43. package/dist/dashboard/assets/app-a159k0ub.css.gz +0 -0
  44. package/dist/dashboard/assets/arrow-up-59ms1iiT.js +1 -0
  45. package/dist/dashboard/assets/arrow-up-59ms1iiT.js.gz +0 -0
  46. package/dist/dashboard/assets/assert-DbRA8EG2.js +15 -0
  47. package/dist/dashboard/assets/assert-DbRA8EG2.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-DC2L43Mj.js → badge-D9CS6dZk.js} +1 -1
  49. package/dist/dashboard/assets/badge-D9CS6dZk.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-BBVcb0J3.js → banner-DympBNq4.js} +1 -1
  51. package/dist/dashboard/assets/banner-DympBNq4.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-Cca1XC6G.js → card-ibRWU_uT.js} +1 -1
  53. package/dist/dashboard/assets/card-ibRWU_uT.js.gz +0 -0
  54. package/dist/dashboard/assets/cli-tools-63qBGaRw.js +1 -0
  55. package/dist/dashboard/assets/cli-tools-63qBGaRw.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-DUzEbynq.js → clipboard-button-DJm-9pz7.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-DJm-9pz7.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-DFvWhwTa.js → dialog-8fiE6tec.js} +1 -1
  59. package/dist/dashboard/assets/dialog-8fiE6tec.js.gz +0 -0
  60. package/dist/dashboard/assets/drawer-CqGYeOrd.js +1 -0
  61. package/dist/dashboard/assets/drawer-CqGYeOrd.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-Bqy8RFvM.js → entry-BSAsYsmV.js} +1 -1
  63. package/dist/dashboard/assets/entry-BSAsYsmV.js.gz +0 -0
  64. package/dist/dashboard/assets/entry-CiIQULZD.js +4 -0
  65. package/dist/dashboard/assets/entry-CiIQULZD.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-Daz0lJ-W.js +1 -0
  67. package/dist/dashboard/assets/entry-Daz0lJ-W.js.gz +0 -0
  68. package/dist/dashboard/assets/{concurrency-BDF0ok7y.js → fingerprint-pattern-BWuHtbW4.js} +1 -1
  69. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  70. package/dist/dashboard/assets/{formatters-CWWZ-uCZ.js → formatters-D-MTJxMr.js} +1 -1
  71. package/dist/dashboard/assets/formatters-D-MTJxMr.js.gz +0 -0
  72. package/dist/dashboard/assets/{github-badge-C9e8Ts7e.js → github-badge-CPK47xQx.js} +1 -1
  73. package/dist/dashboard/assets/github-badge-CPK47xQx.js.gz +0 -0
  74. package/dist/dashboard/assets/{index-DFVFrnWW.js → index-DBNPOfhV.js} +2 -2
  75. package/dist/dashboard/assets/index-DBNPOfhV.js.gz +0 -0
  76. package/dist/dashboard/assets/{inline-BFUcvllj.js → inline-BT0zntEr.js} +1 -1
  77. package/dist/dashboard/assets/inline-BT0zntEr.js.gz +0 -0
  78. package/dist/dashboard/assets/{input-C_ILAvBb.js → input-Da2ZViWg.js} +1 -1
  79. package/dist/dashboard/assets/input-Da2ZViWg.js.gz +0 -0
  80. package/dist/dashboard/assets/{layout-CBIyT6Rt.js → layout-BXNeLw6M.js} +1 -1
  81. package/dist/dashboard/assets/layout-BXNeLw6M.js.gz +0 -0
  82. package/dist/dashboard/assets/{list-row-ebJ_-Nsb.js → list-row-BycOrrIV.js} +1 -1
  83. package/dist/dashboard/assets/list-row-BycOrrIV.js.gz +0 -0
  84. package/dist/dashboard/assets/live-DCQNZpF7.js +1 -0
  85. package/dist/dashboard/assets/live-DCQNZpF7.js.gz +0 -0
  86. package/dist/dashboard/assets/minimize-2-BTmMTK4o.js +1 -0
  87. package/dist/dashboard/assets/minimize-2-BTmMTK4o.js.gz +0 -0
  88. package/dist/dashboard/assets/{mutation-sMD965oO.js → mutation-Rr0TIvcj.js} +1 -1
  89. package/dist/dashboard/assets/mutation-Rr0TIvcj.js.gz +0 -0
  90. package/dist/dashboard/assets/network-9Yj2ZyLo.js +1 -0
  91. package/dist/dashboard/assets/network-9Yj2ZyLo.js.gz +0 -0
  92. package/dist/dashboard/assets/{numbers-BO9dKaFr.js → numbers-CEagj6ZZ.js} +2 -2
  93. package/dist/dashboard/assets/numbers-CEagj6ZZ.js.gz +0 -0
  94. package/dist/dashboard/assets/{query-keys-CfL_Zdoh.js → query-keys-BloKtaXJ.js} +1 -1
  95. package/dist/dashboard/assets/query-keys-BloKtaXJ.js.gz +0 -0
  96. package/dist/dashboard/assets/quota-Caw7wqci.js +1 -0
  97. package/dist/dashboard/assets/quota-Caw7wqci.js.gz +0 -0
  98. package/dist/dashboard/assets/routing-CO9zCUus.js +1 -0
  99. package/dist/dashboard/assets/routing-CO9zCUus.js.gz +0 -0
  100. package/dist/dashboard/assets/{select-DBNVLdUu.js → select-BIYIBkMU.js} +2 -2
  101. package/dist/dashboard/assets/select-BIYIBkMU.js.gz +0 -0
  102. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js +1 -0
  103. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js.gz +0 -0
  104. package/dist/dashboard/assets/settings-jS3-PNSA.js +1 -0
  105. package/dist/dashboard/assets/settings-jS3-PNSA.js.gz +0 -0
  106. package/dist/dashboard/assets/{stack-EMrOMt4I.js → stack-DfgnFDWY.js} +1 -1
  107. package/dist/dashboard/assets/stack-DfgnFDWY.js.gz +0 -0
  108. package/dist/dashboard/assets/{state-CWoo7ty5.js → state-CkfdYWel.js} +1 -1
  109. package/dist/dashboard/assets/state-CkfdYWel.js.gz +0 -0
  110. package/dist/dashboard/assets/{switch-CCjT6IkF.js → switch-SYm237HG.js} +1 -1
  111. package/dist/dashboard/assets/switch-SYm237HG.js.gz +0 -0
  112. package/dist/dashboard/assets/{useMutation-DjkLHUcE.js → useMutation-BQqCRNIi.js} +1 -1
  113. package/dist/dashboard/assets/useMutation-BQqCRNIi.js.gz +0 -0
  114. package/dist/dashboard/index.html +3 -2
  115. package/dist/dashboard/index.html.gz +0 -0
  116. package/dist/dashboard/precompressed-assets.json +118 -115
  117. package/migrations/full-postgres/0026_add_qoder_native_identity.sql +20 -0
  118. package/migrations/full-postgres/0027_qoder_identity_genuine_seed.sql +21 -0
  119. package/migrations/full-postgres/0028_add_family_template_key_telemetry.sql +40 -0
  120. package/migrations/full-postgres/0029_null_share_child_policy_columns.sql +38 -0
  121. package/migrations/full-postgres/0030_add_api_key_max_children.sql +10 -0
  122. package/migrations/full-postgres/0031_tenant_system_prompts.sql +16 -0
  123. package/migrations/full-postgres/0032_add_provider_pool_accounts.sql +53 -0
  124. package/migrations/full-postgres/0033_add_api_key_ban.sql +9 -0
  125. package/migrations/full-postgres/0034_add_api_key_ban_until.sql +7 -0
  126. package/migrations/full-postgres/0035_align_pool_account_status_enum.sql +21 -0
  127. package/migrations/lite-sqlite3/0026_add_qoder_native_identity.sql +15 -0
  128. package/migrations/lite-sqlite3/0027_qoder_identity_genuine_seed.sql +21 -0
  129. package/migrations/lite-sqlite3/0028_add_family_template_key_telemetry.sql +40 -0
  130. package/migrations/lite-sqlite3/0029_null_share_child_policy_columns.sql +38 -0
  131. package/migrations/lite-sqlite3/0030_add_api_key_max_children.sql +10 -0
  132. package/migrations/lite-sqlite3/0031_tenant_system_prompts.sql +10 -0
  133. package/migrations/lite-sqlite3/0032_add_provider_pool_accounts.sql +44 -0
  134. package/migrations/lite-sqlite3/0033_add_api_key_ban.sql +8 -0
  135. package/migrations/lite-sqlite3/0034_add_api_key_ban_until.sql +5 -0
  136. package/migrations/lite-sqlite3/0035_align_pool_account_status_enum.sql +6 -0
  137. package/package.json +2 -1
  138. package/src/boot/app.ts +9 -8
  139. package/src/config/network.ts +7 -3
  140. package/src/console/api-keys/bucket-spend.ts +7 -7
  141. package/src/console/api-keys/contracts.ts +475 -410
  142. package/src/console/api-keys/routes.ts +211 -23
  143. package/src/console/audit/routes.ts +3 -3
  144. package/src/console/auth/account-reset.ts +4 -4
  145. package/src/console/auth/session.ts +3 -3
  146. package/src/console/backup/contracts.ts +2 -0
  147. package/src/console/backup/export-endpoint.ts +14 -17
  148. package/src/console/backup/routes.ts +43 -11
  149. package/src/console/backup/service.ts +11 -6
  150. package/src/console/backup/store.ts +384 -88
  151. package/src/console/cli-tools/routes.ts +3 -3
  152. package/src/console/console-router-guards.ts +2 -2
  153. package/src/console/console-router.ts +4 -4
  154. package/src/console/dashboard-assets.ts +16 -1
  155. package/src/console/data-management/routes.ts +3 -3
  156. package/src/console/domain-registration.ts +4 -2
  157. package/src/console/observability/contracts.ts +4 -0
  158. package/src/console/observability/live.ts +3 -3
  159. package/src/console/observability/logs.ts +3 -3
  160. package/src/console/observability/performance.ts +3 -3
  161. package/src/console/observability/routes.ts +3 -3
  162. package/src/console/observability/store.ts +16 -16
  163. package/src/console/providers/catalog/contracts.ts +43 -22
  164. package/src/console/providers/catalog/provider-operations.ts +17 -2
  165. package/src/console/providers/catalog/routes.ts +3 -3
  166. package/src/console/providers/catalog/store-helpers.ts +349 -326
  167. package/src/console/providers/catalog/store.ts +1523 -1292
  168. package/src/console/providers/detail/routes.ts +3 -3
  169. package/src/console/providers/oauth/routes.ts +3 -3
  170. package/src/console/quota/account-quota-tenant.ts +34 -2
  171. package/src/console/quota/account-quota.ts +4 -1
  172. package/src/console/quota/account-test-egress.ts +4 -4
  173. package/src/console/quota/native-identity.ts +189 -0
  174. package/src/console/remote-support/contracts.ts +12 -6
  175. package/src/console/remote-support/service.ts +2 -2
  176. package/src/console/routing/model/routes.ts +3 -3
  177. package/src/console/routing/pools/contracts.ts +11 -8
  178. package/src/console/routing/pools/operations.ts +14 -7
  179. package/src/console/routing/pools/probe-verdict.ts +8 -3
  180. package/src/console/routing/pools/relay-deploy.ts +9 -9
  181. package/src/console/routing/pools/routes.ts +3 -3
  182. package/src/console/routing/pools/store.ts +21 -1
  183. package/src/console/settings/routes.ts +3 -3
  184. package/src/console/share/share-router.ts +51 -18
  185. package/src/console/share/share-stats.ts +158 -49
  186. package/src/console/share/share-usage.ts +24 -7
  187. package/src/console/shared/read-through-cache.ts +38 -2
  188. package/src/console/system-prompt/contracts.ts +102 -0
  189. package/src/console/system-prompt/routes.ts +165 -0
  190. package/src/console/updates/routes.ts +7 -4
  191. package/src/main.ts +11 -0
  192. package/src/network/outbound-fetch.ts +75 -4
  193. package/src/network/pool/resolver.ts +39 -39
  194. package/src/network/pool-cooldown-store.ts +71 -16
  195. package/src/network/pool-health-machine.ts +21 -1
  196. package/src/network/rate-limit-verdict.ts +68 -0
  197. package/src/network/ssrf.ts +4 -4
  198. package/src/observability/event-query.ts +437 -22
  199. package/src/observability/telemetry-status.ts +1 -1
  200. package/src/persistence/db-pg.ts +42 -0
  201. package/src/persistence/db-sqlite.ts +7 -2
  202. package/src/persistence/readiness.ts +18 -6
  203. package/src/persistence/schema.ts +162 -1
  204. package/src/persistence/stores/api-key-store.ts +849 -581
  205. package/src/persistence/stores/share-store.ts +65 -13
  206. package/src/persistence/stores/telemetry-store.ts +58 -2
  207. package/src/persistence/system-prompt-store.ts +104 -0
  208. package/src/protocol/codecs.ts +6 -6
  209. package/src/protocol/gateway-error.ts +6 -3
  210. package/src/protocol/image-generation.ts +2 -2
  211. package/src/protocol/reasoning.ts +65 -1
  212. package/src/protocol/request/chat.ts +1 -0
  213. package/src/protocol/response/chat.ts +2 -2
  214. package/src/protocol/response/codex.ts +1 -1
  215. package/src/protocol/response/gemini.ts +1 -1
  216. package/src/protocol/response/messages.ts +5 -5
  217. package/src/protocol/response/responses.ts +2 -2
  218. package/src/protocol/sse-decoder.ts +14 -3
  219. package/src/protocol/translation/capabilities.ts +2 -2
  220. package/src/protocol/upstream/send-claude-messages.ts +3 -3
  221. package/src/providers/adapters/base-adapter.ts +1 -1
  222. package/src/providers/adapters/compatible-adapter.ts +2 -2
  223. package/src/providers/authentication/oauth-refresh-service.ts +2 -2
  224. package/src/providers/configured-provider.ts +1 -1
  225. package/src/providers/discovery/probe-phases.ts +2 -2
  226. package/src/providers/discovery/probing-service.ts +12 -12
  227. package/src/providers/integrations/agentrouter/agentrouter.ts +2 -3
  228. package/src/providers/integrations/alibabacp/alibabacp.ts +2 -2
  229. package/src/providers/integrations/anthropic.ts +4 -11
  230. package/src/providers/integrations/antigravity/antigravity.ts +33 -47
  231. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -1
  232. package/src/providers/integrations/buddy/buddy-chat-shared.ts +30 -2
  233. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +37 -6
  234. package/src/providers/integrations/buddy/buddy-identity.ts +22 -1
  235. package/src/providers/integrations/buddy/buddy-media.ts +21 -17
  236. package/src/providers/integrations/buddy/codebuddy/provider.ts +6 -2
  237. package/src/providers/integrations/buddy/workbuddy/provider.ts +6 -2
  238. package/src/providers/integrations/claude/claude-betas.ts +1 -1
  239. package/src/providers/integrations/claude/claude-credentials.ts +1 -1
  240. package/src/providers/integrations/claude/claude-slow-lane-retry.ts +1 -1
  241. package/src/providers/integrations/claude/claude.ts +3 -10
  242. package/src/providers/integrations/cline/cline.ts +8 -1
  243. package/src/providers/integrations/cloudflareai/cloudflareai.ts +4 -7
  244. package/src/providers/integrations/codex/codex-errors.ts +1 -1
  245. package/src/providers/integrations/codex/codex.ts +14 -15
  246. package/src/providers/integrations/commandcode/commandcode.ts +4 -4
  247. package/src/providers/integrations/commandcode/quota.ts +5 -1
  248. package/src/providers/integrations/cursor/cursor-http2.ts +11 -11
  249. package/src/providers/integrations/cursor/cursor.ts +15 -15
  250. package/src/providers/integrations/factory/factory.ts +4 -4
  251. package/src/providers/integrations/gemini.ts +11 -11
  252. package/src/providers/integrations/github/github.ts +26 -7
  253. package/src/providers/integrations/grok/grok.ts +2 -6
  254. package/src/providers/integrations/hermes.ts +5 -1
  255. package/src/providers/integrations/inferhub.ts +4 -11
  256. package/src/providers/integrations/kilo/kilo.ts +4 -0
  257. package/src/providers/integrations/kimi/kimi.ts +4 -11
  258. package/src/providers/integrations/kiro/kiro.ts +10 -10
  259. package/src/providers/integrations/muse/muse.ts +6 -9
  260. package/src/providers/integrations/openrouter/provider.ts +4 -0
  261. package/src/providers/integrations/perplexity.ts +2 -2
  262. package/src/providers/integrations/qoder/qoder-catalog.ts +47 -9
  263. package/src/providers/integrations/qoder/qoder-checkin.ts +242 -51
  264. package/src/providers/integrations/qoder/qoder-native-identity-store.ts +157 -0
  265. package/src/providers/integrations/qoder/qoder-oauth.ts +12 -3
  266. package/src/providers/integrations/qoder/qoder-realm.ts +15 -0
  267. package/src/providers/integrations/qoder/qoder-umid.ts +287 -0
  268. package/src/providers/integrations/qoder/qoder-virtual.ts +2 -2
  269. package/src/providers/integrations/qoder/qoder.ts +192 -24
  270. package/src/providers/integrations/tokenharbor.ts +2 -2
  271. package/src/providers/integrations/trae/trae.ts +6 -9
  272. package/src/providers/integrations/zcode/oauth.ts +1 -1
  273. package/src/providers/operations/account-error-classification.ts +6 -8
  274. package/src/providers/operations/account-health-service.ts +14 -16
  275. package/src/providers/operations/client-versions.ts +37 -22
  276. package/src/providers/operations/provider-catalog-service.ts +4 -4
  277. package/src/providers/operations/provider-credential-service.ts +16 -4
  278. package/src/providers/operations/upstream-deadline.ts +3 -3
  279. package/src/providers/provider-contracts.ts +55 -3
  280. package/src/providers/provider-credential-resolver.ts +1 -1
  281. package/src/providers/provider-registry.ts +4 -4
  282. package/src/providers/quota/quota-contracts.ts +24 -10
  283. package/src/providers/quota/refresh.ts +2 -2
  284. package/src/providers/search/search-provider.ts +5 -5
  285. package/src/security/abuse.ts +4 -4
  286. package/src/security/access-control.ts +19 -0
  287. package/src/security/admission/reasons.ts +17 -17
  288. package/src/security/api-key-auth.ts +18 -5
  289. package/src/shared/coerce.ts +19 -0
  290. package/src/transport/dispatch/attempt-finalize.ts +4 -3
  291. package/src/transport/dispatch/attempt-loop.ts +10 -7
  292. package/src/transport/dispatch/empty-stream.ts +6 -6
  293. package/src/transport/dispatch/generation.ts +9 -9
  294. package/src/transport/dispatch/leases.ts +8 -8
  295. package/src/transport/dispatch/proxy-request.ts +38 -9
  296. package/src/transport/dispatch/responses-compact.ts +11 -11
  297. package/src/transport/dispatch/retry-policy.ts +4 -67
  298. package/src/transport/dispatch/stream-bridge.ts +1 -1
  299. package/src/transport/dispatch/streaming/finalize.ts +9 -8
  300. package/src/transport/dispatch/streaming/stream.ts +16 -1
  301. package/src/transport/dispatch/systemone.ts +8 -8
  302. package/src/transport/dispatch/token-count.ts +9 -9
  303. package/src/transport/dispatch/upstream.ts +10 -0
  304. package/src/transport/dispatch/websearch.ts +6 -6
  305. package/src/transport/drain-endpoint.ts +2 -2
  306. package/src/transport/failure-policy.ts +13 -6
  307. package/src/transport/guards/auth-guard.ts +6 -5
  308. package/src/transport/guards/body-policy.ts +4 -4
  309. package/src/transport/guards/error-lifecycle.ts +18 -3
  310. package/src/transport/guards/guard-errors.ts +7 -3
  311. package/src/transport/guards/headroom-guard.ts +1 -1
  312. package/src/transport/guards/pipeline.ts +6 -6
  313. package/src/transport/guards/readiness-guard.ts +16 -2
  314. package/src/transport/guards/request-context.ts +3 -3
  315. package/src/transport/request/preparer.ts +37 -21
  316. package/src/transport/request/state.ts +4 -7
  317. package/src/transport/request/system-prompt-injection.ts +89 -0
  318. package/src/transport/request/tenant-preferences.ts +43 -0
  319. package/src/transport/routing/admission.ts +13 -3
  320. package/src/transport/routing/route-catalog.ts +104 -2
  321. package/src/transport/routing/route-model.ts +60 -7
  322. package/src/transport/routing/router.ts +306 -256
  323. package/src/transport/shutdown-notice.ts +19 -7
  324. package/src/transport/surface/completion.ts +1 -1
  325. package/src/transport/surface/messages/encode.ts +1 -1
  326. package/src/workers/daily-checkin.ts +2 -0
  327. package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
  328. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js +0 -1
  329. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
  330. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js +0 -1
  331. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
  332. package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
  333. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js +0 -1
  334. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
  335. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js +0 -1
  336. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
  337. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
  338. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js.gz +0 -0
  339. package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
  340. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js +0 -1
  341. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
  342. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +0 -4
  343. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js.gz +0 -0
  344. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js +0 -1
  345. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
  346. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js +0 -9
  347. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js.gz +0 -0
  348. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js +0 -1
  349. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
  350. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js +0 -1
  351. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
  352. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js +0 -1
  353. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
  354. package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
  355. package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
  356. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js +0 -56
  357. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js.gz +0 -0
  358. package/dist/dashboard/assets/api-keys-CUi6UXsR.js +0 -1
  359. package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
  360. package/dist/dashboard/assets/app-KvsUJUMF.css +0 -2
  361. package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
  362. package/dist/dashboard/assets/assert-67xXH5rf.js +0 -1
  363. package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
  364. package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
  365. package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
  366. package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
  367. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js +0 -1
  368. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
  369. package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
  370. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  371. package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
  372. package/dist/dashboard/assets/drawer-CFM2sMX1.js +0 -1
  373. package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
  374. package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
  375. package/dist/dashboard/assets/entry-ClCMCWWQ.js +0 -4
  376. package/dist/dashboard/assets/entry-ClCMCWWQ.js.gz +0 -0
  377. package/dist/dashboard/assets/entry-_xHo5x6r.js +0 -1
  378. package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
  379. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js.gz +0 -0
  380. package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
  381. package/dist/dashboard/assets/index-DFVFrnWW.js.gz +0 -0
  382. package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
  383. package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
  384. package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
  385. package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
  386. package/dist/dashboard/assets/live-D0mm--ME.js +0 -1
  387. package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
  388. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js +0 -1
  389. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js.gz +0 -0
  390. package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
  391. package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
  392. package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
  393. package/dist/dashboard/assets/quota-DZGyp0N5.js +0 -1
  394. package/dist/dashboard/assets/quota-DZGyp0N5.js.gz +0 -0
  395. package/dist/dashboard/assets/routing-BnIpi1IJ.js +0 -1
  396. package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
  397. package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
  398. package/dist/dashboard/assets/settings-BAFhDgX8.js +0 -1
  399. package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
  400. package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
  401. package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
  402. package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
  403. package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
  404. /package/dist/dashboard/assets/{app-BaWd0puM.js → app-BayTTy01.js} +0 -0
  405. /package/dist/dashboard/assets/{app-BaWd0puM.js.gz → app-BayTTy01.js.gz} +0 -0
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Owns validation, CRUD operations, and route wiring through the ApiKeyStore.
4
4
 
5
- import { Elysia, t } from "elysia";
5
+ import { Elysia, t, type AnyElysia } from "elysia";
6
6
  import { randomBytes, randomUUID } from "node:crypto";
7
7
  import { decryptCredentialToString, encryptCredential } from "../../security/crypto";
8
8
  import { invalidateApiKeyCache } from "../../security/api-key-auth";
@@ -12,9 +12,16 @@ import type { AccessDecision } from "../../security/access-control";
12
12
  import { ConsoleDomainError, errorResponse, requireTenantScope } from "../shared/errors";
13
13
  import { literalUnion } from "../shared/elysia-schema";
14
14
  import { API_KEY_MODES, API_KEY_MODEL_ACCESS_MODES, type ShareLinkKind } from "../../persistence/schema";
15
- import type { ApiKeyConfig, ApiKeyResponse, UpdateApiKeyResponse } from "./contracts";
16
- import type { ApiKeyPatch, ApiKeyRecord } from "../../persistence/stores/api-key-store";
15
+ import type { ApiKeyConfig, ApiKeyResponse, BanApiKeyRequest, UpdateApiKeyResponse } from "./contracts";
16
+ import type {
17
+ ApiKeyPatch,
18
+ ApiKeyRecord,
19
+ SharedChildSortColumn,
20
+ SharedChildrenPageOptions,
21
+ } from "../../persistence/stores/api-key-store";
17
22
  import {
23
+ BAN_DURATION_CHOICES,
24
+ DEFAULT_BAN_MINUTES,
18
25
  generateApiKeySecret,
19
26
  isDefaultGatewayApiKey,
20
27
  parseSharePopupImage,
@@ -34,6 +41,39 @@ import {
34
41
  import type { SharedKeyActivityDetail, SharedKeySummary } from "../share/share-usage";
35
42
  import { popupImageBytes } from "./share-popup-image";
36
43
 
44
+ const SHARED_CHILD_SORT_COLUMNS = new Set<SharedChildSortColumn>([
45
+ "createdAt",
46
+ "label",
47
+ "keyPrefix",
48
+ "issuedClientIp",
49
+ "revokedAt",
50
+ "enabled",
51
+ ]);
52
+
53
+ /** Parses and bounds the paginated share-child list query. */
54
+ function parseSharedChildrenQuery(query: URLSearchParams): SharedChildrenPageOptions {
55
+ const limitRaw = query.get("limit");
56
+ const offsetRaw = query.get("offset");
57
+ const sortRaw = query.get("sort");
58
+ const orderRaw = query.get("order");
59
+ const statusRaw = query.get("status");
60
+ const limit = limitRaw === null ? 50 : Number(limitRaw);
61
+ const offset = offsetRaw === null ? 0 : Number(offsetRaw);
62
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
63
+ throw new ConsoleDomainError("invalid_request", 400, "limit must be an integer between 1 and 500");
64
+ }
65
+ if (!Number.isInteger(offset) || offset < 0) {
66
+ throw new ConsoleDomainError("invalid_request", 400, "offset must be a non-negative integer");
67
+ }
68
+ const sortBy = (sortRaw ?? "createdAt") as SharedChildSortColumn;
69
+ if (!SHARED_CHILD_SORT_COLUMNS.has(sortBy)) {
70
+ throw new ConsoleDomainError("invalid_request", 400, `unsupported sort column: ${String(sortBy)}`);
71
+ }
72
+ const order = orderRaw === "asc" ? "asc" : "desc";
73
+ const status = statusRaw === "active" || statusRaw === "revoked" ? statusRaw : "all";
74
+ return { limit, offset, sortBy, order, status };
75
+ }
76
+
37
77
  export function createApiKeyOperations(config: ApiKeyConfig) {
38
78
  const operations = {
39
79
  async listKeys(access: AccessDecision | undefined): Promise<ApiKeyResponse[]> {
@@ -44,18 +84,19 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
44
84
  );
45
85
  // A share template's own counter never moves for child traffic, so
46
86
  // reporting it raw would show a template as untouched while the gateway
47
- // is refusing its recipients on the parent budget. Report the figure the
48
- // gateway actually enforces: the template plus every recipient it has
49
- // issued, revoked ones included (their spend is already spent).
50
- const sumChildren = config.store.sumChildrenConsumed?.bind(config.store);
87
+ // is refusing its recipients on the parent budget. Report the figure
88
+ // the gateway actually enforces: the template's counter plus every
89
+ // telemetry total the family ever recorded — revoked/deleted children
90
+ // included (their spend is already spent), so the number survives
91
+ // cleanup and matches the share page.
51
92
  return Promise.all(
52
93
  topLevel.map(async (record) => {
53
94
  const response = sanitizeApiKeyResponse(record);
54
- if (record.keyMode !== "share" || !sumChildren) return response;
55
- const children = await sumChildren(record.id).catch(() => 0);
56
- return children > 0
57
- ? { ...response, tokensConsumed: response.tokensConsumed + children }
58
- : response;
95
+ if (record.keyMode !== "share") return response;
96
+ const family = await config.store
97
+ .sumFamilyTelemetryConsumed(authorized.tenantId, record.id)
98
+ .catch(() => 0);
99
+ return { ...response, tokensConsumed: response.tokensConsumed + family };
59
100
  }),
60
101
  );
61
102
  },
@@ -146,6 +187,7 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
146
187
  ...(request.monthlyTokenLimit == null ? {} : { monthlyTokenLimit: request.monthlyTokenLimit }),
147
188
  ...(request.lifetimeTokenBudget == null ? {} : { lifetimeTokenBudget: request.lifetimeTokenBudget }),
148
189
  ...(request.maxConcurrentRequests == null ? {} : { maxConcurrentRequests: request.maxConcurrentRequests }),
190
+ ...(request.maxChildren == null ? {} : { maxChildren: request.maxChildren }),
149
191
  ...(request.modelPrefix === undefined ? {} : { modelPrefix: request.modelPrefix }),
150
192
  modelAccessMode: request.modelAccessMode ?? "whitelist",
151
193
  ...(request.modelList === undefined ? {} : { modelList: request.modelList }),
@@ -280,6 +322,7 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
280
322
  ...(patchRequest.monthlyTokenLimit === undefined ? {} : { monthlyTokenLimit: patchRequest.monthlyTokenLimit }),
281
323
  ...(patchRequest.lifetimeTokenBudget === undefined ? {} : { lifetimeTokenBudget: patchRequest.lifetimeTokenBudget }),
282
324
  ...(patchRequest.maxConcurrentRequests === undefined ? {} : { maxConcurrentRequests: patchRequest.maxConcurrentRequests }),
325
+ ...(patchRequest.maxChildren === undefined ? {} : { maxChildren: patchRequest.maxChildren }),
283
326
  ...(patchRequest.modelPrefix === undefined ? {} : { modelPrefix: patchRequest.modelPrefix }),
284
327
  ...(patchRequest.modelAccessMode === undefined ? {} : { modelAccessMode: patchRequest.modelAccessMode }),
285
328
  ...(patchRequest.modelList === undefined ? {} : { modelList: patchRequest.modelList }),
@@ -376,7 +419,7 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
376
419
  });
377
420
  return { ...sanitizeApiKeyResponse(updated), ...(secret === undefined ? {} : { secret }) };
378
421
  },
379
- async revokeKey(access: AccessDecision | undefined, keyId: string): Promise<{ success: true }> {
422
+ async deleteKey(access: AccessDecision | undefined, keyId: string): Promise<{ success: true }> {
380
423
  const authorized = requireTenantScope(access, "dashboard:write");
381
424
  const target = await config.store.get(authorized.tenantId, keyId);
382
425
  if (!target)
@@ -401,7 +444,7 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
401
444
  ]);
402
445
  await config.auditSink?.record({
403
446
  access: authorized,
404
- action: "api_key.revoked",
447
+ action: "api_key.deleted",
405
448
  target: keyId,
406
449
  });
407
450
  return { success: true };
@@ -643,7 +686,14 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
643
686
  async listSharedKeys(
644
687
  access: AccessDecision | undefined,
645
688
  parentKeyId: string,
646
- ): Promise<readonly SharedKeySummary[]> {
689
+ query: URLSearchParams,
690
+ ): Promise<{
691
+ rows: readonly SharedKeySummary[];
692
+ total: number;
693
+ active: number;
694
+ revoked: number;
695
+ consumed: number;
696
+ }> {
647
697
  const authorized = requireTenantScope(access, "dashboard:read");
648
698
  const activity = config.shareActivity;
649
699
  if (!activity)
@@ -652,8 +702,14 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
652
702
  if (!parent || parent.revokedAt !== undefined ||
653
703
  parent.keyMode !== "share" || parent.parentKeyId !== undefined)
654
704
  throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
655
- const children = await config.store.listChildren(authorized.tenantId, parentKeyId);
656
- return activity.getSharedKeySummaries(authorized.tenantId, children);
705
+ const options = parseSharedChildrenQuery(query);
706
+ const page = await config.store.listChildrenPage(authorized.tenantId, parentKeyId, options);
707
+ const rows = await activity.getSharedKeySummaries(
708
+ authorized.tenantId,
709
+ page.rows,
710
+ parentKeyId,
711
+ );
712
+ return { rows, total: page.total, active: page.active, revoked: page.revoked, consumed: page.consumed };
657
713
  },
658
714
  async getSharedKeyActivity(
659
715
  access: AccessDecision | undefined,
@@ -668,11 +724,37 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
668
724
  if (!parent || parent.revokedAt !== undefined ||
669
725
  parent.keyMode !== "share" || parent.parentKeyId !== undefined)
670
726
  throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
671
- const children = await config.store.listChildren(authorized.tenantId, parentKeyId);
672
- if (!children.some((child) => child.id === childKeyId))
727
+ // Membership is one indexed row read, not a scan of the whole family.
728
+ const child = await config.store.get(authorized.tenantId, childKeyId);
729
+ if (!child || child.parentKeyId !== parentKeyId)
673
730
  throw new ConsoleDomainError("key_not_found", 404, "Shared API key not found");
674
731
  return activity.getSharedKeyDetail(authorized.tenantId, childKeyId);
675
732
  },
733
+ async revokeSharedScope(
734
+ access: AccessDecision | undefined,
735
+ parentKeyId: string,
736
+ scope: "active" | "unused",
737
+ ): Promise<{ success: true; deleted: number }> {
738
+ const authorized = requireTenantScope(access, "dashboard:write");
739
+ const parent = await config.store.get(authorized.tenantId, parentKeyId);
740
+ if (!parent || parent.keyMode !== "share" || parent.parentKeyId !== undefined)
741
+ throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
742
+ const deleted = await config.store.revokeChildrenByScope(
743
+ authorized.tenantId,
744
+ parentKeyId,
745
+ scope,
746
+ );
747
+ // Raw batched deletes bypass the per-key purge path; drop the whole auth
748
+ // cache so no resolved identity survives for a removed recipient.
749
+ invalidateApiKeyCache();
750
+ await config.auditSink?.record({
751
+ access: authorized,
752
+ action: "api_key.share_revoked_scope",
753
+ target: parentKeyId,
754
+ detail: { scope, deleted },
755
+ });
756
+ return { success: true, deleted };
757
+ },
676
758
  async revokeShare(
677
759
  access: AccessDecision | undefined,
678
760
  keyId: string,
@@ -695,6 +777,69 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
695
777
  });
696
778
  return { success: true };
697
779
  },
780
+ async banKey(
781
+ access: AccessDecision | undefined,
782
+ keyId: string,
783
+ request: BanApiKeyRequest,
784
+ ): Promise<ApiKeyResponse> {
785
+ const authorized = requireTenantScope(access, "dashboard:write");
786
+ const reason = request.reason.trim();
787
+ if (reason.length === 0)
788
+ throw new ConsoleDomainError("invalid_request", 400, "reason is required");
789
+ // Parse the duration choice. `null` is permanent; absent defaults to one
790
+ // hour. Anything not on the offered list is rejected rather than clamped —
791
+ // clamping would answer a request for 90 days with a week and report
792
+ // success.
793
+ const chosenMinutes = request.minutes === undefined ? DEFAULT_BAN_MINUTES : request.minutes;
794
+ const allowed = new Set(BAN_DURATION_CHOICES.map((choice) => choice.minutes));
795
+ if (!allowed.has(chosenMinutes)) {
796
+ throw new ConsoleDomainError(
797
+ "invalid_request",
798
+ 400,
799
+ "Unsupported ban duration. Choose one of the offered durations.",
800
+ );
801
+ }
802
+ const bannedUntil = chosenMinutes === null ? null : new Date(Date.now() + chosenMinutes * 60_000);
803
+ const current = await config.store.get(authorized.tenantId, keyId);
804
+ if (!current || current.revokedAt !== undefined)
805
+ throw new ConsoleDomainError("key_not_found", 404, "Key not found");
806
+ const banned = await config.store.ban(authorized.tenantId, keyId, reason, bannedUntil);
807
+ if (!banned) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
808
+ // A banned child key does not affect its share template or sibling keys:
809
+ // `store.ban` only turns off the one row named by `keyId`, and the auth
810
+ // path rejects per key hash. The template keeps issuing working children.
811
+ // A banned key must leave the auth cache for every process at once.
812
+ invalidateApiKeyCache();
813
+ await config.auditSink?.record({
814
+ access: authorized,
815
+ action: "api_key.banned",
816
+ target: keyId,
817
+ detail: { reason, ...(bannedUntil === null ? { permanent: true } : { bannedUntil: bannedUntil.toISOString() }) },
818
+ });
819
+ const record = await config.store.get(authorized.tenantId, keyId);
820
+ if (!record) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
821
+ return sanitizeApiKeyResponse(record);
822
+ },
823
+ async unbanKey(
824
+ access: AccessDecision | undefined,
825
+ keyId: string,
826
+ ): Promise<ApiKeyResponse> {
827
+ const authorized = requireTenantScope(access, "dashboard:write");
828
+ const current = await config.store.get(authorized.tenantId, keyId);
829
+ if (!current || current.revokedAt !== undefined)
830
+ throw new ConsoleDomainError("key_not_found", 404, "Key not found");
831
+ const unbanned = await config.store.unban(authorized.tenantId, keyId);
832
+ if (!unbanned) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
833
+ invalidateApiKeyCache();
834
+ await config.auditSink?.record({
835
+ access: authorized,
836
+ action: "api_key.unbanned",
837
+ target: keyId,
838
+ });
839
+ const record = await config.store.get(authorized.tenantId, keyId);
840
+ if (!record) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
841
+ return sanitizeApiKeyResponse(record);
842
+ },
698
843
  };
699
844
  return operations;
700
845
  }
@@ -750,7 +895,7 @@ function jsonNull(): Response {
750
895
  });
751
896
  }
752
897
  /** Creates tenant-bound API-key routes with request-local authorization. */
753
- export function createApiKeyRoutes(config: ApiKeyConfig): Elysia {
898
+ export function createApiKeyRoutes(config: ApiKeyConfig): AnyElysia {
754
899
  const factory = createApiKeyOperations(config);
755
900
  return new Elysia({ prefix: "/api-keys" })
756
901
  .get("/", async ({ request, set }) => {
@@ -798,7 +943,11 @@ export function createApiKeyRoutes(config: ApiKeyConfig): Elysia {
798
943
  })
799
944
  .get("/:keyId/shared-keys", async ({ request, params, set }) => {
800
945
  try {
801
- return await factory.listSharedKeys(config.accessResolver(request), params.keyId);
946
+ return await factory.listSharedKeys(
947
+ config.accessResolver(request),
948
+ params.keyId,
949
+ new URL(request.url).searchParams,
950
+ );
802
951
  } catch (error) {
803
952
  return errorResponse(error, set, "API-key operation failed");
804
953
  }
@@ -814,6 +963,21 @@ export function createApiKeyRoutes(config: ApiKeyConfig): Elysia {
814
963
  return errorResponse(error, set, "API-key operation failed");
815
964
  }
816
965
  })
966
+ .post(
967
+ "/:keyId/shared-keys/revoke-scope",
968
+ { body: t.Object({ scope: t.Union([t.Literal("active"), t.Literal("unused")]) }) },
969
+ async ({ request, params, body, set }) => {
970
+ try {
971
+ return await factory.revokeSharedScope(
972
+ config.accessResolver(request),
973
+ params.keyId,
974
+ body.scope,
975
+ );
976
+ } catch (error) {
977
+ return errorResponse(error, set, "API-key operation failed");
978
+ }
979
+ },
980
+ )
817
981
  .delete("/:keyId/shares/:shareId", async ({ request, params, set }) => {
818
982
  try {
819
983
  return await factory.revokeShare(
@@ -846,7 +1010,31 @@ export function createApiKeyRoutes(config: ApiKeyConfig): Elysia {
846
1010
  })
847
1011
  .delete("/:keyId", async ({ request, params, set }) => {
848
1012
  try {
849
- return await factory.revokeKey(config.accessResolver(request), params.keyId);
1013
+ return await factory.deleteKey(config.accessResolver(request), params.keyId);
1014
+ } catch (error) {
1015
+ return errorResponse(error, set, "API-key operation failed");
1016
+ }
1017
+ })
1018
+ .post(
1019
+ "/:keyId/ban",
1020
+ {
1021
+ body: t.Object({
1022
+ reason: t.String(),
1023
+ reasonLabel: t.Optional(t.String()),
1024
+ minutes: t.Optional(t.Union([t.Number({ minimum: 1 }), t.Null()])),
1025
+ }),
1026
+ },
1027
+ async ({ request, params, body, set }) => {
1028
+ try {
1029
+ return await factory.banKey(config.accessResolver(request), params.keyId, body);
1030
+ } catch (error) {
1031
+ return errorResponse(error, set, "API-key operation failed");
1032
+ }
1033
+ },
1034
+ )
1035
+ .post("/:keyId/unban", async ({ request, params, set }) => {
1036
+ try {
1037
+ return await factory.unbanKey(config.accessResolver(request), params.keyId);
850
1038
  } catch (error) {
851
1039
  return errorResponse(error, set, "API-key operation failed");
852
1040
  }
@@ -889,5 +1077,5 @@ export function createApiKeyRoutes(config: ApiKeyConfig): Elysia {
889
1077
  } catch (error) {
890
1078
  return errorResponse(error, set, "API-key operation failed");
891
1079
  }
892
- }) as unknown as Elysia;
1080
+ });
893
1081
  }
@@ -1,4 +1,4 @@
1
- import { Elysia, t } from "elysia";
1
+ import { Elysia, t, type AnyElysia } from "elysia";
2
2
  import { errorResponse, requireGlobalAdmin, requireTenantScope } from "../shared/errors";
3
3
  import { parseQueryLimit } from "../shared/query";
4
4
  import type { AuditRoutesConfig } from "./contracts";
@@ -10,7 +10,7 @@ const listQuery = t.Object({
10
10
  actor: t.Optional(t.String()),
11
11
  });
12
12
 
13
- export function createAuditRoutes(config: AuditRoutesConfig): Elysia {
13
+ export function createAuditRoutes(config: AuditRoutesConfig): AnyElysia {
14
14
  return new Elysia().get("/audit", { query: listQuery }, async ({ request, query, set }) => {
15
15
  try {
16
16
  const resolved = config.accessResolver(request);
@@ -28,5 +28,5 @@ export function createAuditRoutes(config: AuditRoutesConfig): Elysia {
28
28
  } catch (e) {
29
29
  return errorResponse(e, set, "Audit read failed");
30
30
  }
31
- }) as unknown as Elysia;
31
+ });
32
32
  }
@@ -42,12 +42,12 @@ export function createConsoleAccountResetHandler(
42
42
  return async ({ request, body }) => {
43
43
  if (!isLoopbackPeer(deps.resolvePeerAddress(request)))
44
44
  throw new GatewayError(
45
- "invalid_request",
45
+ "policy_rejected",
46
46
  403,
47
- "console account reset is only available from loopback",
47
+ "console account reset is only available from loopback (request {requestId}). This operation is only allowed from a trusted loopback connection — no hard feelings 🫂",
48
48
  );
49
49
  if (!tokenMatches(request.headers.get("x-drain-token"), deps.token))
50
- throw new GatewayError("invalid_request", 401, "invalid reset token");
50
+ throw new GatewayError("authentication_failed", 401, "invalid reset token (request {requestId}). Wrong key, right door — check the token 🔑");
51
51
  const confirm =
52
52
  body !== null && typeof body === "object" && "confirm" in body
53
53
  ? body.confirm
@@ -56,7 +56,7 @@ export function createConsoleAccountResetHandler(
56
56
  throw new GatewayError(
57
57
  "invalid_request",
58
58
  400,
59
- `send {"confirm":"${CONFIRMATION}"} to confirm; this deletes every console account`,
59
+ `send {"confirm":"${CONFIRMATION}"} to confirm (request {requestId}); this deletes every console account. Make sure you mean it — there's no undo 🫂`,
60
60
  );
61
61
 
62
62
  const removed = await deps.resetConsoleAccount();
@@ -1,5 +1,5 @@
1
1
  import { asCookie, clearCsrfCookie, newCsrfToken, setCsrfCookie } from "../../security/csrf";
2
- import { Elysia, t } from "elysia";
2
+ import { Elysia, t, type AnyElysia } from "elysia";
3
3
  import { eq } from "drizzle-orm";
4
4
  import { isRecord } from "../../protocol/codecs";
5
5
  import { userRecord, returningRows, assertMutationApplied } from "./row-coercion";
@@ -217,7 +217,7 @@ export function createConsoleAuthRoutes(
217
217
  cookiePolicy: SessionCookiePolicy = defaultSessionCookiePolicy,
218
218
  trustedProxyBoundary: TrustedProxyBoundary = { mode: "disabled" },
219
219
  resolvePeerAddress: (request: Request) => string | undefined = () => undefined,
220
- ): Elysia {
220
+ ): AnyElysia {
221
221
  const app = new Elysia({ prefix: "/auth" });
222
222
  const policy = cookiePolicy;
223
223
  const database = db;
@@ -532,5 +532,5 @@ export function createConsoleAuthRoutes(
532
532
  },
533
533
  );
534
534
 
535
- return app as unknown as Elysia;
535
+ return app;
536
536
  }
@@ -33,6 +33,7 @@ import {
33
33
  poolRoutingSettings,
34
34
  providerAccounts,
35
35
  providerOauthStates,
36
+ providerPoolAccounts,
36
37
  providerRoutingSettings,
37
38
  providers,
38
39
  shareLinks,
@@ -96,6 +97,7 @@ export const CONFIG_TABLES = [
96
97
  models,
97
98
  providerRoutingSettings,
98
99
  providerAccounts,
100
+ providerPoolAccounts,
99
101
  poolRoutingSettings,
100
102
  networkPools,
101
103
  providers,
@@ -24,7 +24,7 @@ import { join } from "node:path";
24
24
  import { GatewayError } from "../../protocol/gateway-error";
25
25
  import { isLoopbackPeer, tokenMatches } from "../../transport/drain-endpoint";
26
26
  import { BACKUP_SECTIONS, tablesForSection } from "./contracts";
27
- import { exportBackup } from "./store";
27
+ import { compressBackupStream, exportBackupStream } from "./store";
28
28
  import type { CartethyiaDatabase } from "../../persistence/db-handle";
29
29
 
30
30
  export interface BackupExportDeps {
@@ -52,31 +52,28 @@ export function createBackupExportHandler(
52
52
  // came from, the second is the credential it carried — and `invalid_request`
53
53
  // told an operator to fix a body that was never the problem.
54
54
  if (!isLoopbackPeer(deps.resolvePeerAddress(request)))
55
- throw new GatewayError("policy_rejected", 403, "backup export is only available from loopback");
55
+ throw new GatewayError("policy_rejected", 403, "backup export is only available from loopback (request {requestId}). This operation is only allowed from a trusted loopback connection — no hard feelings 🫂");
56
56
  if (!tokenMatches(request.headers.get("x-drain-token"), deps.token))
57
- throw new GatewayError("authentication_failed", 401, "invalid export token");
57
+ throw new GatewayError("authentication_failed", 401, "invalid export token (request {requestId}). Wrong key, right door — check the token 🔑");
58
58
 
59
59
  const directory =
60
60
  body !== null && typeof body === "object" && "directory" in body
61
61
  ? body.directory
62
62
  : undefined;
63
63
  if (directory !== undefined && typeof directory !== "string")
64
- throw new GatewayError("invalid_request", 400, "directory must be a string");
64
+ throw new GatewayError("invalid_request", 400, "directory must be a string (request {requestId}). Check the shape of what you sent — then resend 🪞");
65
65
 
66
66
  const tenantId = await deps.resolveTenantId();
67
67
  if (tenantId === null)
68
- throw new GatewayError("invalid_request", 409, "no tenant exists; run setup first");
68
+ throw new GatewayError("invalid_request", 409, "no tenant exists; run setup first (request {requestId}). Nothing here but dust — run setup first 🌙");
69
69
 
70
- const { payload, counts } = await exportBackup(
71
- deps.db,
72
- tablesForSection("config"),
73
- tenantId,
74
- );
75
-
76
- // gzipSync matches what the console writes, so a CLI backup and a dashboard
77
- // backup are the same file format and either can be restored by the other.
78
- const json = JSON.stringify(payload);
79
- const gzipped = Bun.gzipSync(new TextEncoder().encode(json));
70
+ // One gzip pass over the streamed document: a tenant with hundreds of
71
+ // thousands of rows is read page by page and only the compressed bytes
72
+ // land in the buffer — the same file format a console backup produces, so
73
+ // a CLI backup and a dashboard backup remain restorable by the other.
74
+ const stream = await exportBackupStream(deps.db, tablesForSection("config"), tenantId);
75
+ const gzipped = await compressBackupStream(stream.jsonChunks);
76
+ const counts = await stream.counts;
80
77
  const target = directory ?? downloadDirectory();
81
78
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
82
79
  const path = join(target, `cartethyia-backup-${stamp}.json.gz`);
@@ -86,9 +83,9 @@ export function createBackupExportHandler(
86
83
  writeFileSync(path, gzipped);
87
84
  } catch (error) {
88
85
  throw new GatewayError(
89
- "invalid_request",
86
+ "internal_error",
90
87
  500,
91
- `could not write ${path}: ${error instanceof Error ? error.message : "unknown error"}`,
88
+ `could not write ${path}: ${error instanceof Error ? error.message : "unknown error"} (request {requestId}). Even we have bad days — retry, and quote the request id if it persists 🐻`,
92
89
  );
93
90
  }
94
91
 
@@ -14,7 +14,7 @@
14
14
  * The body bound is enforced here, before the JSON parse, because the whole
15
15
  * point of the limit is to not buffer an unbounded file.
16
16
  */
17
- import { Elysia, t } from "elysia";
17
+ import { Elysia, t, type AnyElysia } from "elysia";
18
18
  import type { ConsoleAccessResolver } from "../auth/access";
19
19
  import { invalidateApiKeyCache } from "../../security/api-key-auth";
20
20
  import { invalidatePublicModels } from "../providers/catalog/public-model-store";
@@ -22,7 +22,7 @@ import { ConsoleDomainError, errorResponse, requireAnyScope } from "../shared/er
22
22
  import { NO_COMPRESS_HEADER } from "../response-compression";
23
23
  import { MAX_BACKUP_BYTES } from "./contracts";
24
24
  import type { BackupSection } from "./contracts";
25
- import { DELETE_ALL_SCOPES, compressBackup } from "./store";
25
+ import { DELETE_ALL_SCOPES, compressBackupStream } from "./store";
26
26
  import type { BackupService } from "./service";
27
27
  import type { AuditRecorder } from "../auth/audit-recorder";
28
28
 
@@ -48,7 +48,7 @@ export interface BackupRoutesConfig {
48
48
  /**
49
49
  * The tenant's API keys, read after a restore so their admission counters can
50
50
  * be purged. A restore replaces `api_keys` wholesale, so a key that was
51
- * revoked after the backup — and had its counters purged by `revokeKey` — can
51
+ * revoked after the backup — and had its counters purged by `deleteKey` — can
52
52
  * come back with `revoked_at = NULL`; without purging, its lifetime counter
53
53
  * is re-seeded from the restored total, granting a fresh full budget on top
54
54
  * of everything already spent. Optional so a host that never restores keys
@@ -118,7 +118,28 @@ function parseSections(raw: string | undefined): readonly BackupSection[] | unde
118
118
  .filter((part) => part.length > 0) as readonly BackupSection[];
119
119
  }
120
120
 
121
- export function createBackupRoutes(config: BackupRoutesConfig): Elysia {
121
+ /**
122
+ * Streams the export document into the response body as it is written; one
123
+ * row-page of the tenant's data is in memory at a time.
124
+ */
125
+ function payloadDocument(chunks: AsyncGenerator<string, void, void>): ReadableStream<Uint8Array> {
126
+ const encoder = new TextEncoder();
127
+ return new ReadableStream<Uint8Array>({
128
+ async pull(controller) {
129
+ const next = await chunks.next();
130
+ if (next.done) {
131
+ controller.close();
132
+ return;
133
+ }
134
+ controller.enqueue(encoder.encode(next.value));
135
+ },
136
+ async cancel() {
137
+ await chunks.return();
138
+ },
139
+ });
140
+ }
141
+
142
+ export function createBackupRoutes(config: BackupRoutesConfig): AnyElysia {
122
143
  return new Elysia({ prefix: "/backup" })
123
144
  .get("/export", { query: exportQuery }, async ({ request, query, set }) => {
124
145
  try {
@@ -129,7 +150,7 @@ export function createBackupRoutes(config: BackupRoutesConfig): Elysia {
129
150
  const backup = config.backupFor(request);
130
151
  const sections = parseSections(query.sections);
131
152
  const compressed = query.compressed === "1";
132
- const { payload } = await backup.export({
153
+ const exportStream = await backup.export({
133
154
  password: query.password,
134
155
  tenantId: access.tenantId,
135
156
  compressed,
@@ -138,11 +159,22 @@ export function createBackupRoutes(config: BackupRoutesConfig): Elysia {
138
159
  const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
139
160
  set.headers["cache-control"] = "no-store";
140
161
  if (!compressed) {
141
- set.headers["content-disposition"] = `attachment; filename="cartethyia_backup_${stamp}.json"`;
142
- set.headers["content-type"] = "application/json; charset=utf-8";
143
- return payload;
162
+ // The document is written through the stream, so a tenant with
163
+ // hundreds of thousands of rows is serialized here while only one
164
+ // row-page of it is ever in memory. The headers live on the
165
+ // Response itself: a native Response carries the stream, and the
166
+ // response wrapper would otherwise buffer it whole to gzip it —
167
+ // the same trade its ceiling path already made for large exports
168
+ // (rebuilt, uncompressed) — so this branch opts out of it.
169
+ const headers = new Headers({
170
+ "cache-control": "no-store",
171
+ "content-disposition": `attachment; filename="cartethyia_backup_${stamp}.json"`,
172
+ "content-type": "application/json; charset=utf-8",
173
+ [NO_COMPRESS_HEADER]: "1",
174
+ });
175
+ return new Response(payloadDocument(exportStream.jsonChunks), { headers });
144
176
  }
145
- const bytes = compressBackup(payload);
177
+ const bytes = await compressBackupStream(exportStream.jsonChunks);
146
178
  set.headers["content-type"] = "application/json; charset=utf-8";
147
179
  // Opt out of the response wrapper's gzip rather than declaring
148
180
  // `content-encoding: gzip`: the bytes here are base64 of a gzip
@@ -193,7 +225,7 @@ export function createBackupRoutes(config: BackupRoutesConfig): Elysia {
193
225
  // snapshot must be rebuilt before the next `/v1/*` dispatch.
194
226
  await config.snapshotInvalidator?.invalidate();
195
227
  // A restore can re-insert keys the tenant revoked after the backup, and
196
- // `revokeKey` purged their counters when they went away. Drop the whole
228
+ // `deleteKey` purged their counters when they went away. Drop the whole
197
229
  // auth cache (the restore is rare, and a stale entry for any key must
198
230
  // not survive it) and purge admission state for every key the tenant
199
231
  // now has, so a resurrected key cannot start with a fresh budget.
@@ -272,5 +304,5 @@ export function createBackupRoutes(config: BackupRoutesConfig): Elysia {
272
304
  } catch (error) {
273
305
  return errorResponse(error, set, "Backup inspection failed");
274
306
  }
275
- }) as unknown as Elysia;
307
+ });
276
308
  }
@@ -40,7 +40,8 @@ import {
40
40
  applyRestore,
41
41
  decompressBackup,
42
42
  deleteAll,
43
- exportBackup,
43
+ exportBackupStream,
44
+ type BackupExportStream,
44
45
  type DeleteAllResult,
45
46
  type DeleteAllScope,
46
47
  } from "./store";
@@ -269,17 +270,16 @@ async function omitRowsForUnavailableProviders(
269
270
  export class BackupService {
270
271
  constructor(private readonly options: BackupServiceOptions) {}
271
272
 
272
- async export(options: ExportOptions): Promise<{ payload: unknown; counts: Record<string, number> }> {
273
+ async export(options: ExportOptions): Promise<BackupExportStream> {
273
274
  if (!(await this.options.verifyPassword(options.password))) {
274
275
  throw new ConsoleDomainError("unauthorized", 401, "password is incorrect");
275
276
  }
276
277
  const sections = sectionsOrDefault(options.sections);
277
- const { payload, counts } = await exportBackup(
278
+ return exportBackupStream(
278
279
  this.options.db,
279
280
  sections.flatMap((section) => tablesForSection(section)),
280
281
  options.tenantId,
281
282
  );
282
- return { payload, counts };
283
283
  }
284
284
 
285
285
  /**
@@ -453,11 +453,16 @@ export class BackupService {
453
453
  sections: readonly BackupSection[] | undefined,
454
454
  tenantId: string,
455
455
  ): Promise<Record<string, number>> {
456
- const { counts } = await exportBackup(
456
+ const stream = exportBackupStream(
457
457
  this.options.db,
458
458
  sectionsOrDefault(sections).flatMap((section) => tablesForSection(section)),
459
459
  tenantId,
460
460
  );
461
- return counts;
461
+ // Walk the stream so the tally settles, discarding the bytes: a preview
462
+ // must not hold the payload either.
463
+ for await (const _chunk of stream.jsonChunks) {
464
+ // discard
465
+ }
466
+ return stream.counts;
462
467
  }
463
468
  }