cartethyia 2.7.1 → 2.7.3

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 (263) hide show
  1. package/.env.example +2 -0
  2. package/dist/dashboard/assets/{BannedPage-D-J75hBM.js → BannedPage-2TJD37Hb.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-2TJD37Hb.js.gz +0 -0
  4. package/dist/dashboard/assets/CliToolDetailPage-xefJNRmJ.js +1 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-xefJNRmJ.js.gz +0 -0
  6. package/dist/dashboard/assets/CliToolsPage-Rj9iHQ3F.js +1 -0
  7. package/dist/dashboard/assets/CliToolsPage-Rj9iHQ3F.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-QoJ6foxo.js → CombosPage-JEbYJFxw.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-JEbYJFxw.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-DpNRay4C.js → ConsoleLogPage-C3xx_inM.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-C3xx_inM.js.gz +0 -0
  12. package/dist/dashboard/assets/CustomizationPage-Dw306gJU.js +1 -0
  13. package/dist/dashboard/assets/CustomizationPage-Dw306gJU.js.gz +0 -0
  14. package/dist/dashboard/assets/HealthEventsModal-jaBo1TWV.js +1 -0
  15. package/dist/dashboard/assets/HealthEventsModal-jaBo1TWV.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-DubdQcvX.js → LoginPage-C4w7vea-.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-C4w7vea-.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-BH8Tehdi.js → ModelPicker-BtPcAfot.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-BtPcAfot.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-CGC5gNs1.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-CGC5gNs1.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-DQcthNGt.js +4 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-DQcthNGt.js.gz +0 -0
  24. package/dist/dashboard/assets/ProvidersPage-0OsXRGXe.js +1 -0
  25. package/dist/dashboard/assets/ProvidersPage-0OsXRGXe.js.gz +0 -0
  26. package/dist/dashboard/assets/ProxyPage-DdPc8slb.js +9 -0
  27. package/dist/dashboard/assets/ProxyPage-DdPc8slb.js.gz +0 -0
  28. package/dist/dashboard/assets/{QuotaPage-ATpaxrbl.js → QuotaPage-Ci5O4BnH.js} +1 -1
  29. package/dist/dashboard/assets/QuotaPage-Ci5O4BnH.js.gz +0 -0
  30. package/dist/dashboard/assets/{SettingsPage-CETophpx.js → SettingsPage-CDZjTt87.js} +1 -1
  31. package/dist/dashboard/assets/SettingsPage-CDZjTt87.js.gz +0 -0
  32. package/dist/dashboard/assets/SetupPage-BQS0Rx_u.js +1 -0
  33. package/dist/dashboard/assets/SetupPage-BQS0Rx_u.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-CAK-EN9z.js → SortableList-BAL5npBC.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-BAL5npBC.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-B7W2_RhT.js → StudioPage-TP7voAQ0.js} +3 -3
  37. package/dist/dashboard/assets/StudioPage-TP7voAQ0.js.gz +0 -0
  38. package/dist/dashboard/assets/{UsagePage-6T4EFDjW.js → UsagePage-Cj_fEzhg.js} +13 -13
  39. package/dist/dashboard/assets/UsagePage-Cj_fEzhg.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-CUi6UXsR.js → api-keys-MbI7e7rt.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-MbI7e7rt.js.gz +0 -0
  42. package/dist/dashboard/assets/app-B2vyPoII.css +2 -0
  43. package/dist/dashboard/assets/app-B2vyPoII.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-67xXH5rf.js → assert-C0YYuZJ4.js} +1 -1
  47. package/dist/dashboard/assets/assert-C0YYuZJ4.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-DC2L43Mj.js → badge-B_DMpMJN.js} +1 -1
  49. package/dist/dashboard/assets/badge-B_DMpMJN.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-BBVcb0J3.js → banner-DMrMPmth.js} +1 -1
  51. package/dist/dashboard/assets/banner-DMrMPmth.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-Cca1XC6G.js → card-Be5Uzamx.js} +1 -1
  53. package/dist/dashboard/assets/card-Be5Uzamx.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-KvsWa1Jl.js → cli-tools-muFRphDu.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-muFRphDu.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-DUzEbynq.js → clipboard-button-DOfrVmsi.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-DOfrVmsi.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-DFvWhwTa.js → dialog-C9omdjQt.js} +1 -1
  59. package/dist/dashboard/assets/dialog-C9omdjQt.js.gz +0 -0
  60. package/dist/dashboard/assets/drawer-Bm8WKaGY.js +1 -0
  61. package/dist/dashboard/assets/drawer-Bm8WKaGY.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-_xHo5x6r.js → entry-DS90gUks.js} +1 -1
  63. package/dist/dashboard/assets/entry-DS90gUks.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-BLBLxX0h.js → entry-j40LJotO.js} +3 -3
  65. package/dist/dashboard/assets/entry-j40LJotO.js.gz +0 -0
  66. package/dist/dashboard/assets/{entry-Bqy8RFvM.js → entry-q5HwZ8nN.js} +1 -1
  67. package/dist/dashboard/assets/entry-q5HwZ8nN.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-mpBPqAQ8.js → formatters-BTScrQpe.js} +1 -1
  71. package/dist/dashboard/assets/formatters-BTScrQpe.js.gz +0 -0
  72. package/dist/dashboard/assets/{github-badge-C9e8Ts7e.js → github-badge-Orjuzemr.js} +1 -1
  73. package/dist/dashboard/assets/github-badge-Orjuzemr.js.gz +0 -0
  74. package/dist/dashboard/assets/{index-D6QzEC_n.js → index-cPrX5vn6.js} +2 -2
  75. package/dist/dashboard/assets/index-cPrX5vn6.js.gz +0 -0
  76. package/dist/dashboard/assets/{inline-BFUcvllj.js → inline-Dvux8w-0.js} +1 -1
  77. package/dist/dashboard/assets/inline-Dvux8w-0.js.gz +0 -0
  78. package/dist/dashboard/assets/{input-C_ILAvBb.js → input-CU1Rgf3p.js} +1 -1
  79. package/dist/dashboard/assets/input-CU1Rgf3p.js.gz +0 -0
  80. package/dist/dashboard/assets/{layout-CBIyT6Rt.js → layout-C_7A6H55.js} +1 -1
  81. package/dist/dashboard/assets/layout-C_7A6H55.js.gz +0 -0
  82. package/dist/dashboard/assets/{list-row-ebJ_-Nsb.js → list-row-CzFvogbj.js} +1 -1
  83. package/dist/dashboard/assets/list-row-CzFvogbj.js.gz +0 -0
  84. package/dist/dashboard/assets/{live-D0mm--ME.js → live-KaG15HLQ.js} +1 -1
  85. package/dist/dashboard/assets/live-KaG15HLQ.js.gz +0 -0
  86. package/dist/dashboard/assets/{mutation-sMD965oO.js → mutation-JJGQm8TD.js} +1 -1
  87. package/dist/dashboard/assets/mutation-JJGQm8TD.js.gz +0 -0
  88. package/dist/dashboard/assets/{numbers-BO9dKaFr.js → numbers-BNDZnsUE.js} +1 -1
  89. package/dist/dashboard/assets/numbers-BNDZnsUE.js.gz +0 -0
  90. package/dist/dashboard/assets/{query-keys-CfL_Zdoh.js → query-keys-CLaNz8Xz.js} +1 -1
  91. package/dist/dashboard/assets/query-keys-CLaNz8Xz.js.gz +0 -0
  92. package/dist/dashboard/assets/{routing-BnIpi1IJ.js → routing-BEH32QOC.js} +1 -1
  93. package/dist/dashboard/assets/routing-BEH32QOC.js.gz +0 -0
  94. package/dist/dashboard/assets/{select-Cj5Nw43g.js → select-B3iTetHN.js} +1 -1
  95. package/dist/dashboard/assets/select-B3iTetHN.js.gz +0 -0
  96. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js +1 -0
  97. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js.gz +0 -0
  98. package/dist/dashboard/assets/{settings-BAFhDgX8.js → settings-Dt3Eqbnq.js} +1 -1
  99. package/dist/dashboard/assets/settings-Dt3Eqbnq.js.gz +0 -0
  100. package/dist/dashboard/assets/{stack-EMrOMt4I.js → stack-KrOrm7JZ.js} +1 -1
  101. package/dist/dashboard/assets/stack-KrOrm7JZ.js.gz +0 -0
  102. package/dist/dashboard/assets/{state-CWoo7ty5.js → state-DLS7IDMy.js} +1 -1
  103. package/dist/dashboard/assets/state-DLS7IDMy.js.gz +0 -0
  104. package/dist/dashboard/assets/{switch-CCjT6IkF.js → switch-42ItvbAs.js} +1 -1
  105. package/dist/dashboard/assets/switch-42ItvbAs.js.gz +0 -0
  106. package/dist/dashboard/assets/{useMutation-DjkLHUcE.js → useMutation-Dmnfh1Ue.js} +1 -1
  107. package/dist/dashboard/assets/useMutation-Dmnfh1Ue.js.gz +0 -0
  108. package/dist/dashboard/index.html +3 -2
  109. package/dist/dashboard/index.html.gz +0 -0
  110. package/dist/dashboard/precompressed-assets.json +117 -115
  111. package/migrations/full-postgres/0026_add_qoder_native_identity.sql +20 -0
  112. package/migrations/full-postgres/0027_qoder_identity_genuine_seed.sql +22 -0
  113. package/migrations/full-postgres/0028_add_family_template_key_telemetry.sql +40 -0
  114. package/migrations/full-postgres/0029_null_share_child_policy_columns.sql +38 -0
  115. package/migrations/full-postgres/0030_add_api_key_max_children.sql +10 -0
  116. package/migrations/full-postgres/0031_tenant_system_prompts.sql +16 -0
  117. package/migrations/lite-sqlite3/0026_add_qoder_native_identity.sql +15 -0
  118. package/migrations/lite-sqlite3/0027_qoder_identity_genuine_seed.sql +22 -0
  119. package/migrations/lite-sqlite3/0028_add_family_template_key_telemetry.sql +40 -0
  120. package/migrations/lite-sqlite3/0029_null_share_child_policy_columns.sql +38 -0
  121. package/migrations/lite-sqlite3/0030_add_api_key_max_children.sql +10 -0
  122. package/migrations/lite-sqlite3/0031_tenant_system_prompts.sql +10 -0
  123. package/package.json +1 -1
  124. package/src/config/network.ts +1 -1
  125. package/src/console/api-keys/bucket-spend.ts +7 -7
  126. package/src/console/api-keys/contracts.ts +423 -410
  127. package/src/console/api-keys/routes.ts +118 -19
  128. package/src/console/backup/export-endpoint.ts +8 -11
  129. package/src/console/backup/routes.ts +40 -8
  130. package/src/console/backup/service.ts +11 -6
  131. package/src/console/backup/store.ts +384 -88
  132. package/src/console/cli-tools/contracts.ts +10 -4
  133. package/src/console/dashboard-assets.ts +16 -1
  134. package/src/console/domain-registration.ts +2 -0
  135. package/src/console/quota/account-quota-tenant.ts +8 -1
  136. package/src/console/quota/account-quota.ts +4 -1
  137. package/src/console/quota/native-identity.ts +189 -0
  138. package/src/console/remote-support/contracts.ts +12 -6
  139. package/src/console/remote-support/service.ts +2 -2
  140. package/src/console/share/share-router.ts +39 -6
  141. package/src/console/share/share-stats.ts +64 -25
  142. package/src/console/share/share-usage.ts +24 -7
  143. package/src/console/system-prompt/contracts.ts +102 -0
  144. package/src/console/system-prompt/routes.ts +165 -0
  145. package/src/main.ts +11 -0
  146. package/src/network/outbound-fetch.ts +281 -226
  147. package/src/network/pool/agent.ts +7 -0
  148. package/src/observability/event-query.ts +118 -7
  149. package/src/persistence/db-pg.ts +42 -0
  150. package/src/persistence/schema.ts +102 -0
  151. package/src/persistence/stores/api-key-store.ts +753 -581
  152. package/src/persistence/stores/share-store.ts +65 -13
  153. package/src/persistence/stores/telemetry-store.ts +57 -1
  154. package/src/persistence/system-prompt-store.ts +104 -0
  155. package/src/protocol/reasoning.ts +65 -1
  156. package/src/protocol/request/chat.ts +1 -0
  157. package/src/providers/default-registry.ts +1 -0
  158. package/src/providers/integrations/antigravity/antigravity.ts +20 -23
  159. package/src/providers/integrations/buddy/buddy-chat-shared.ts +30 -2
  160. package/src/providers/integrations/buddy/buddy-media.ts +5 -1
  161. package/src/providers/integrations/buddy/codebuddy/provider.ts +5 -1
  162. package/src/providers/integrations/buddy/workbuddy/provider.ts +5 -1
  163. package/src/providers/integrations/cline/cline.ts +7 -0
  164. package/src/providers/integrations/github/github.ts +20 -1
  165. package/src/providers/integrations/hermes.ts +5 -1
  166. package/src/providers/integrations/kilo/kilo.ts +4 -0
  167. package/src/providers/integrations/ollamacloud-models.ts +50 -0
  168. package/src/providers/integrations/openrouter/provider.ts +4 -0
  169. package/src/providers/integrations/qoder/qoder-checkin.ts +224 -51
  170. package/src/providers/integrations/qoder/qoder-native-identity-store.ts +156 -0
  171. package/src/providers/integrations/qoder/qoder-oauth.ts +24 -4
  172. package/src/providers/integrations/qoder/qoder-realm.ts +20 -0
  173. package/src/providers/integrations/qoder/qoder-text-tools.ts +277 -0
  174. package/src/providers/integrations/qoder/qoder-umid.ts +288 -0
  175. package/src/providers/integrations/qoder/qoder.ts +1375 -1641
  176. package/src/providers/operations/account-error-classification.ts +1682 -1660
  177. package/src/providers/operations/account-health-service.ts +619 -621
  178. package/src/providers/operations/client-versions.ts +37 -22
  179. package/src/providers/operations/provider-catalog-service.ts +11 -1
  180. package/src/providers/provider-contracts.ts +15 -2
  181. package/src/security/access-control.ts +19 -0
  182. package/src/security/api-key-auth.ts +14 -1
  183. package/src/security/cli-client-fingerprint.ts +9 -2
  184. package/src/transport/dispatch/empty-stream.ts +224 -224
  185. package/src/transport/dispatch/proxy-request.ts +31 -2
  186. package/src/transport/dispatch/streaming/stream.ts +16 -1
  187. package/src/transport/dispatch/upstream.ts +10 -0
  188. package/src/transport/failure-policy.ts +33 -0
  189. package/src/transport/request/preparer.ts +17 -1
  190. package/src/transport/request/system-prompt-injection.ts +89 -0
  191. package/src/transport/request/tenant-preferences.ts +43 -0
  192. package/src/transport/routing/admission.ts +13 -3
  193. package/src/transport/routing/route-catalog.ts +41 -12
  194. package/src/transport/routing/route-model.ts +53 -0
  195. package/src/transport/routing/router.ts +306 -228
  196. package/src/transport/surface/responses/encode.ts +2 -3
  197. package/src/transport/surface/responses/parse.ts +49 -7
  198. package/src/types/undici.d.ts +33 -0
  199. package/src/workers/daily-checkin.ts +2 -0
  200. package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
  201. package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js +0 -1
  202. package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
  203. package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js +0 -1
  204. package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
  205. package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
  206. package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
  207. package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js +0 -1
  208. package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
  209. package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js +0 -1
  210. package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
  211. package/dist/dashboard/assets/LoginPage-DubdQcvX.js.gz +0 -0
  212. package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
  213. package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js +0 -1
  214. package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
  215. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +0 -4
  216. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js.gz +0 -0
  217. package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js +0 -1
  218. package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
  219. package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js +0 -9
  220. package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js.gz +0 -0
  221. package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
  222. package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
  223. package/dist/dashboard/assets/SetupPage-BdgECjqq.js +0 -1
  224. package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
  225. package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
  226. package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
  227. package/dist/dashboard/assets/UsagePage-6T4EFDjW.js.gz +0 -0
  228. package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
  229. package/dist/dashboard/assets/app-KvsUJUMF.css +0 -2
  230. package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
  231. package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
  232. package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
  233. package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
  234. package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
  235. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
  236. package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
  237. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  238. package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
  239. package/dist/dashboard/assets/drawer-CFM2sMX1.js +0 -1
  240. package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
  241. package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
  242. package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
  243. package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
  244. package/dist/dashboard/assets/formatters-mpBPqAQ8.js.gz +0 -0
  245. package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
  246. package/dist/dashboard/assets/index-D6QzEC_n.js.gz +0 -0
  247. package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
  248. package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
  249. package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
  250. package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
  251. package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
  252. package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
  253. package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
  254. package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
  255. package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
  256. package/dist/dashboard/assets/select-Cj5Nw43g.js.gz +0 -0
  257. package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
  258. package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
  259. package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
  260. package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
  261. package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
  262. /package/dist/dashboard/assets/{app-BaWd0puM.js → app-wvwCi4Eh.js} +0 -0
  263. /package/dist/dashboard/assets/{app-BaWd0puM.js.gz → app-wvwCi4Eh.js.gz} +0 -0
@@ -13,7 +13,12 @@ import { ConsoleDomainError, errorResponse, requireTenantScope } from "../shared
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
15
  import type { ApiKeyConfig, ApiKeyResponse, UpdateApiKeyResponse } from "./contracts";
16
- import type { ApiKeyPatch, ApiKeyRecord } from "../../persistence/stores/api-key-store";
16
+ import type {
17
+ ApiKeyPatch,
18
+ ApiKeyRecord,
19
+ SharedChildSortColumn,
20
+ SharedChildrenPageOptions,
21
+ } from "../../persistence/stores/api-key-store";
17
22
  import {
18
23
  generateApiKeySecret,
19
24
  isDefaultGatewayApiKey,
@@ -34,6 +39,39 @@ import {
34
39
  import type { SharedKeyActivityDetail, SharedKeySummary } from "../share/share-usage";
35
40
  import { popupImageBytes } from "./share-popup-image";
36
41
 
42
+ const SHARED_CHILD_SORT_COLUMNS = new Set<SharedChildSortColumn>([
43
+ "createdAt",
44
+ "label",
45
+ "keyPrefix",
46
+ "issuedClientIp",
47
+ "revokedAt",
48
+ "enabled",
49
+ ]);
50
+
51
+ /** Parses and bounds the paginated share-child list query. */
52
+ function parseSharedChildrenQuery(query: URLSearchParams): SharedChildrenPageOptions {
53
+ const limitRaw = query.get("limit");
54
+ const offsetRaw = query.get("offset");
55
+ const sortRaw = query.get("sort");
56
+ const orderRaw = query.get("order");
57
+ const statusRaw = query.get("status");
58
+ const limit = limitRaw === null ? 50 : Number(limitRaw);
59
+ const offset = offsetRaw === null ? 0 : Number(offsetRaw);
60
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
61
+ throw new ConsoleDomainError("invalid_request", 400, "limit must be an integer between 1 and 500");
62
+ }
63
+ if (!Number.isInteger(offset) || offset < 0) {
64
+ throw new ConsoleDomainError("invalid_request", 400, "offset must be a non-negative integer");
65
+ }
66
+ const sortBy = (sortRaw ?? "createdAt") as SharedChildSortColumn;
67
+ if (!SHARED_CHILD_SORT_COLUMNS.has(sortBy)) {
68
+ throw new ConsoleDomainError("invalid_request", 400, `unsupported sort column: ${String(sortBy)}`);
69
+ }
70
+ const order = orderRaw === "asc" ? "asc" : "desc";
71
+ const status = statusRaw === "active" || statusRaw === "revoked" ? statusRaw : "all";
72
+ return { limit, offset, sortBy, order, status };
73
+ }
74
+
37
75
  export function createApiKeyOperations(config: ApiKeyConfig) {
38
76
  const operations = {
39
77
  async listKeys(access: AccessDecision | undefined): Promise<ApiKeyResponse[]> {
@@ -44,18 +82,19 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
44
82
  );
45
83
  // A share template's own counter never moves for child traffic, so
46
84
  // 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);
85
+ // is refusing its recipients on the parent budget. Report the figure
86
+ // the gateway actually enforces: the template's counter plus every
87
+ // telemetry total the family ever recorded — revoked/deleted children
88
+ // included (their spend is already spent), so the number survives
89
+ // cleanup and matches the share page.
51
90
  return Promise.all(
52
91
  topLevel.map(async (record) => {
53
92
  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;
93
+ if (record.keyMode !== "share") return response;
94
+ const family = await config.store
95
+ .sumFamilyTelemetryConsumed(authorized.tenantId, record.id)
96
+ .catch(() => 0);
97
+ return { ...response, tokensConsumed: response.tokensConsumed + family };
59
98
  }),
60
99
  );
61
100
  },
@@ -146,6 +185,7 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
146
185
  ...(request.monthlyTokenLimit == null ? {} : { monthlyTokenLimit: request.monthlyTokenLimit }),
147
186
  ...(request.lifetimeTokenBudget == null ? {} : { lifetimeTokenBudget: request.lifetimeTokenBudget }),
148
187
  ...(request.maxConcurrentRequests == null ? {} : { maxConcurrentRequests: request.maxConcurrentRequests }),
188
+ ...(request.maxChildren == null ? {} : { maxChildren: request.maxChildren }),
149
189
  ...(request.modelPrefix === undefined ? {} : { modelPrefix: request.modelPrefix }),
150
190
  modelAccessMode: request.modelAccessMode ?? "whitelist",
151
191
  ...(request.modelList === undefined ? {} : { modelList: request.modelList }),
@@ -280,6 +320,7 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
280
320
  ...(patchRequest.monthlyTokenLimit === undefined ? {} : { monthlyTokenLimit: patchRequest.monthlyTokenLimit }),
281
321
  ...(patchRequest.lifetimeTokenBudget === undefined ? {} : { lifetimeTokenBudget: patchRequest.lifetimeTokenBudget }),
282
322
  ...(patchRequest.maxConcurrentRequests === undefined ? {} : { maxConcurrentRequests: patchRequest.maxConcurrentRequests }),
323
+ ...(patchRequest.maxChildren === undefined ? {} : { maxChildren: patchRequest.maxChildren }),
283
324
  ...(patchRequest.modelPrefix === undefined ? {} : { modelPrefix: patchRequest.modelPrefix }),
284
325
  ...(patchRequest.modelAccessMode === undefined ? {} : { modelAccessMode: patchRequest.modelAccessMode }),
285
326
  ...(patchRequest.modelList === undefined ? {} : { modelList: patchRequest.modelList }),
@@ -376,7 +417,7 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
376
417
  });
377
418
  return { ...sanitizeApiKeyResponse(updated), ...(secret === undefined ? {} : { secret }) };
378
419
  },
379
- async revokeKey(access: AccessDecision | undefined, keyId: string): Promise<{ success: true }> {
420
+ async deleteKey(access: AccessDecision | undefined, keyId: string): Promise<{ success: true }> {
380
421
  const authorized = requireTenantScope(access, "dashboard:write");
381
422
  const target = await config.store.get(authorized.tenantId, keyId);
382
423
  if (!target)
@@ -401,7 +442,7 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
401
442
  ]);
402
443
  await config.auditSink?.record({
403
444
  access: authorized,
404
- action: "api_key.revoked",
445
+ action: "api_key.deleted",
405
446
  target: keyId,
406
447
  });
407
448
  return { success: true };
@@ -643,7 +684,14 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
643
684
  async listSharedKeys(
644
685
  access: AccessDecision | undefined,
645
686
  parentKeyId: string,
646
- ): Promise<readonly SharedKeySummary[]> {
687
+ query: URLSearchParams,
688
+ ): Promise<{
689
+ rows: readonly SharedKeySummary[];
690
+ total: number;
691
+ active: number;
692
+ revoked: number;
693
+ consumed: number;
694
+ }> {
647
695
  const authorized = requireTenantScope(access, "dashboard:read");
648
696
  const activity = config.shareActivity;
649
697
  if (!activity)
@@ -652,8 +700,14 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
652
700
  if (!parent || parent.revokedAt !== undefined ||
653
701
  parent.keyMode !== "share" || parent.parentKeyId !== undefined)
654
702
  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);
703
+ const options = parseSharedChildrenQuery(query);
704
+ const page = await config.store.listChildrenPage(authorized.tenantId, parentKeyId, options);
705
+ const rows = await activity.getSharedKeySummaries(
706
+ authorized.tenantId,
707
+ page.rows,
708
+ parentKeyId,
709
+ );
710
+ return { rows, total: page.total, active: page.active, revoked: page.revoked, consumed: page.consumed };
657
711
  },
658
712
  async getSharedKeyActivity(
659
713
  access: AccessDecision | undefined,
@@ -668,11 +722,37 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
668
722
  if (!parent || parent.revokedAt !== undefined ||
669
723
  parent.keyMode !== "share" || parent.parentKeyId !== undefined)
670
724
  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))
725
+ // Membership is one indexed row read, not a scan of the whole family.
726
+ const child = await config.store.get(authorized.tenantId, childKeyId);
727
+ if (!child || child.parentKeyId !== parentKeyId)
673
728
  throw new ConsoleDomainError("key_not_found", 404, "Shared API key not found");
674
729
  return activity.getSharedKeyDetail(authorized.tenantId, childKeyId);
675
730
  },
731
+ async revokeSharedScope(
732
+ access: AccessDecision | undefined,
733
+ parentKeyId: string,
734
+ scope: "active" | "unused",
735
+ ): Promise<{ success: true; deleted: number }> {
736
+ const authorized = requireTenantScope(access, "dashboard:write");
737
+ const parent = await config.store.get(authorized.tenantId, parentKeyId);
738
+ if (!parent || parent.keyMode !== "share" || parent.parentKeyId !== undefined)
739
+ throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
740
+ const deleted = await config.store.revokeChildrenByScope(
741
+ authorized.tenantId,
742
+ parentKeyId,
743
+ scope,
744
+ );
745
+ // Raw batched deletes bypass the per-key purge path; drop the whole auth
746
+ // cache so no resolved identity survives for a removed recipient.
747
+ invalidateApiKeyCache();
748
+ await config.auditSink?.record({
749
+ access: authorized,
750
+ action: "api_key.share_revoked_scope",
751
+ target: parentKeyId,
752
+ detail: { scope, deleted },
753
+ });
754
+ return { success: true, deleted };
755
+ },
676
756
  async revokeShare(
677
757
  access: AccessDecision | undefined,
678
758
  keyId: string,
@@ -798,7 +878,11 @@ export function createApiKeyRoutes(config: ApiKeyConfig): Elysia {
798
878
  })
799
879
  .get("/:keyId/shared-keys", async ({ request, params, set }) => {
800
880
  try {
801
- return await factory.listSharedKeys(config.accessResolver(request), params.keyId);
881
+ return await factory.listSharedKeys(
882
+ config.accessResolver(request),
883
+ params.keyId,
884
+ new URL(request.url).searchParams,
885
+ );
802
886
  } catch (error) {
803
887
  return errorResponse(error, set, "API-key operation failed");
804
888
  }
@@ -814,6 +898,21 @@ export function createApiKeyRoutes(config: ApiKeyConfig): Elysia {
814
898
  return errorResponse(error, set, "API-key operation failed");
815
899
  }
816
900
  })
901
+ .post(
902
+ "/:keyId/shared-keys/revoke-scope",
903
+ { body: t.Object({ scope: t.Union([t.Literal("active"), t.Literal("unused")]) }) },
904
+ async ({ request, params, body, set }) => {
905
+ try {
906
+ return await factory.revokeSharedScope(
907
+ config.accessResolver(request),
908
+ params.keyId,
909
+ body.scope,
910
+ );
911
+ } catch (error) {
912
+ return errorResponse(error, set, "API-key operation failed");
913
+ }
914
+ },
915
+ )
817
916
  .delete("/:keyId/shares/:shareId", async ({ request, params, set }) => {
818
917
  try {
819
918
  return await factory.revokeShare(
@@ -846,7 +945,7 @@ export function createApiKeyRoutes(config: ApiKeyConfig): Elysia {
846
945
  })
847
946
  .delete("/:keyId", async ({ request, params, set }) => {
848
947
  try {
849
- return await factory.revokeKey(config.accessResolver(request), params.keyId);
948
+ return await factory.deleteKey(config.accessResolver(request), params.keyId);
850
949
  } catch (error) {
851
950
  return errorResponse(error, set, "API-key operation failed");
852
951
  }
@@ -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 {
@@ -67,16 +67,13 @@ export function createBackupExportHandler(
67
67
  if (tenantId === null)
68
68
  throw new GatewayError("invalid_request", 409, "no tenant exists; 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`);
@@ -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,6 +118,27 @@ function parseSections(raw: string | undefined): readonly BackupSection[] | unde
118
118
  .filter((part) => part.length > 0) as readonly BackupSection[];
119
119
  }
120
120
 
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
+
121
142
  export function createBackupRoutes(config: BackupRoutesConfig): Elysia {
122
143
  return new Elysia({ prefix: "/backup" })
123
144
  .get("/export", { query: exportQuery }, async ({ request, query, set }) => {
@@ -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.
@@ -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
  }