cartethyia 2.7.2 → 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 (249) hide show
  1. package/.env.example +2 -0
  2. package/dist/dashboard/assets/{BannedPage-XaUmiBrC.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-CXGS_Ct-.js → CombosPage-JEbYJFxw.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-JEbYJFxw.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-DGnGNYnM.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-BdmRLnq2.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-7P2n--Fv.js → QuotaPage-Ci5O4BnH.js} +1 -1
  29. package/dist/dashboard/assets/QuotaPage-Ci5O4BnH.js.gz +0 -0
  30. package/dist/dashboard/assets/{SettingsPage-CxZ1fTkN.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-Dqf4zpS-.js → SortableList-BAL5npBC.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-BAL5npBC.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-Byj2lAEm.js → StudioPage-TP7voAQ0.js} +3 -3
  37. package/dist/dashboard/assets/StudioPage-TP7voAQ0.js.gz +0 -0
  38. package/dist/dashboard/assets/{UsagePage-CTkLAuRc.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-ClCMCWWQ.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-CWWZ-uCZ.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-DFVFrnWW.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-DBNVLdUu.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/dashboard-assets.ts +16 -1
  133. package/src/console/domain-registration.ts +2 -0
  134. package/src/console/quota/account-quota-tenant.ts +8 -1
  135. package/src/console/quota/account-quota.ts +4 -1
  136. package/src/console/quota/native-identity.ts +189 -0
  137. package/src/console/remote-support/contracts.ts +12 -6
  138. package/src/console/remote-support/service.ts +2 -2
  139. package/src/console/share/share-router.ts +39 -6
  140. package/src/console/share/share-stats.ts +64 -25
  141. package/src/console/share/share-usage.ts +24 -7
  142. package/src/console/system-prompt/contracts.ts +102 -0
  143. package/src/console/system-prompt/routes.ts +165 -0
  144. package/src/main.ts +11 -0
  145. package/src/observability/event-query.ts +118 -7
  146. package/src/persistence/db-pg.ts +42 -0
  147. package/src/persistence/schema.ts +102 -0
  148. package/src/persistence/stores/api-key-store.ts +753 -581
  149. package/src/persistence/stores/share-store.ts +65 -13
  150. package/src/persistence/stores/telemetry-store.ts +57 -1
  151. package/src/persistence/system-prompt-store.ts +104 -0
  152. package/src/protocol/reasoning.ts +65 -1
  153. package/src/protocol/request/chat.ts +1 -0
  154. package/src/providers/integrations/antigravity/antigravity.ts +20 -23
  155. package/src/providers/integrations/buddy/buddy-chat-shared.ts +30 -2
  156. package/src/providers/integrations/buddy/buddy-media.ts +5 -1
  157. package/src/providers/integrations/buddy/codebuddy/provider.ts +5 -1
  158. package/src/providers/integrations/buddy/workbuddy/provider.ts +5 -1
  159. package/src/providers/integrations/cline/cline.ts +7 -0
  160. package/src/providers/integrations/github/github.ts +20 -1
  161. package/src/providers/integrations/hermes.ts +5 -1
  162. package/src/providers/integrations/kilo/kilo.ts +4 -0
  163. package/src/providers/integrations/openrouter/provider.ts +4 -0
  164. package/src/providers/integrations/qoder/qoder-checkin.ts +224 -51
  165. package/src/providers/integrations/qoder/qoder-native-identity-store.ts +156 -0
  166. package/src/providers/integrations/qoder/qoder-oauth.ts +12 -3
  167. package/src/providers/integrations/qoder/qoder-umid.ts +288 -0
  168. package/src/providers/integrations/qoder/qoder.ts +5 -3
  169. package/src/providers/operations/account-error-classification.ts +6 -8
  170. package/src/providers/operations/account-health-service.ts +14 -16
  171. package/src/providers/operations/client-versions.ts +37 -22
  172. package/src/providers/provider-contracts.ts +8 -0
  173. package/src/security/access-control.ts +19 -0
  174. package/src/security/api-key-auth.ts +14 -1
  175. package/src/transport/dispatch/proxy-request.ts +31 -2
  176. package/src/transport/dispatch/streaming/stream.ts +16 -1
  177. package/src/transport/dispatch/upstream.ts +10 -0
  178. package/src/transport/request/preparer.ts +17 -1
  179. package/src/transport/request/system-prompt-injection.ts +89 -0
  180. package/src/transport/request/tenant-preferences.ts +43 -0
  181. package/src/transport/routing/admission.ts +13 -3
  182. package/src/transport/routing/route-catalog.ts +7 -0
  183. package/src/transport/routing/route-model.ts +53 -0
  184. package/src/transport/routing/router.ts +306 -256
  185. package/src/workers/daily-checkin.ts +2 -0
  186. package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
  187. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js +0 -1
  188. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
  189. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js +0 -1
  190. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
  191. package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
  192. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
  193. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js +0 -1
  194. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
  195. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js +0 -1
  196. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
  197. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js.gz +0 -0
  198. package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
  199. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js +0 -1
  200. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
  201. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +0 -4
  202. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js.gz +0 -0
  203. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js +0 -1
  204. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
  205. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js +0 -9
  206. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js.gz +0 -0
  207. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
  208. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
  209. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js +0 -1
  210. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
  211. package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
  212. package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
  213. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js.gz +0 -0
  214. package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
  215. package/dist/dashboard/assets/app-KvsUJUMF.css +0 -2
  216. package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
  217. package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
  218. package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
  219. package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
  220. package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
  221. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
  222. package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
  223. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  224. package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
  225. package/dist/dashboard/assets/drawer-CFM2sMX1.js +0 -1
  226. package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
  227. package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
  228. package/dist/dashboard/assets/entry-ClCMCWWQ.js.gz +0 -0
  229. package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
  230. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js.gz +0 -0
  231. package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
  232. package/dist/dashboard/assets/index-DFVFrnWW.js.gz +0 -0
  233. package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
  234. package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
  235. package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
  236. package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
  237. package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
  238. package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
  239. package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
  240. package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
  241. package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
  242. package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
  243. package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
  244. package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
  245. package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
  246. package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
  247. package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
  248. /package/dist/dashboard/assets/{app-BaWd0puM.js → app-wvwCi4Eh.js} +0 -0
  249. /package/dist/dashboard/assets/{app-BaWd0puM.js.gz → app-wvwCi4Eh.js.gz} +0 -0
@@ -1,581 +1,753 @@
1
- // Persisted API-key store: the console API-key domain and gateway authentication both consume this boundary.
2
-
3
- import { and, eq, inArray, isNull, sql } from "drizzle-orm";
4
- import { nextSortIndex, reorderSortIndex, type SortOrderDb } from "../sort-order";
5
- import { getDbHandle } from "../boot";
6
- import type { CartethyiaDatabase } from "../db-handle";
7
- import { lockRows } from "../dialect";
8
- import { apiKeys, shareLinks, sharePopups, type ApiKeyMode, type ApiKeyModelAccessMode } from "../schema";
9
- import type { AccessScope } from "../../security/access-control";
10
-
11
- /** Persisted API key. Only its one-way hash authenticates; plaintext is never stored. */
12
- export interface ApiKeyRecord {
13
- readonly id: string;
14
- readonly tenantId: string;
15
- readonly keyHash: string | null;
16
- readonly keyMode: ApiKeyMode;
17
- readonly parentKeyId?: string;
18
- readonly issuedClientIp?: string;
19
- readonly issuedClientIpKey?: string;
20
- readonly label: string;
21
- readonly enabled: boolean;
22
- readonly scopes: readonly AccessScope[];
23
- /** The tenant's mandatory gateway key: rotatable, never revocable. */
24
- readonly isDefault: boolean;
25
- /** Non-secret prefix configured on a key or used to identify a share child. */
26
- readonly keyPrefix?: string;
27
- /** Encrypted copy exists only for personal keys used by CLI handoff. */
28
- readonly keyEncrypted?: Buffer;
29
- readonly notesTitle?: string;
30
- readonly notesSubtitle?: string;
31
- readonly notesBody?: string;
32
- readonly sharePopupEnabled?: boolean;
33
- /** Uploaded popup art; served from the share page rather than a remote URL. */
34
- readonly sharePopupImage?: Buffer;
35
- readonly sharePopupImageMime?: string;
36
- readonly sharePopupTitle?: string;
37
- readonly sharePopupBody?: string;
38
- readonly requestsPerMinute?: number;
39
- readonly dailyTokenLimit?: number;
40
- readonly monthlyTokenLimit?: number;
41
- readonly lifetimeTokenBudget?: number;
42
- readonly maxConcurrentRequests?: number;
43
- readonly modelPrefix?: string;
44
- readonly modelAccessMode?: ApiKeyModelAccessMode;
45
- readonly modelList?: readonly string[];
46
- /** Client-router ids this key refuses; see the schema column for the contract. */
47
- readonly clientRouterDenylist?: readonly string[];
48
- readonly createdAt: Date;
49
- readonly revokedAt?: Date;
50
- readonly tokensConsumed: number;
51
- }
52
-
53
- /**
54
- * Partial key update. Nullable policy fields clear their values; `undefined`
55
- * leaves them unchanged. Credential metadata is writable only for a mode
56
- * transition performed by the API-key domain.
57
- */
58
- export interface ApiKeyPatch
59
- extends Omit<
60
- Partial<ApiKeyRecord>,
61
- | "id"
62
- | "tenantId"
63
- | "keyHash"
64
- | "parentKeyId"
65
- | "issuedClientIp"
66
- | "issuedClientIpKey"
67
- | "createdAt"
68
- | "revokedAt"
69
- | "tokensConsumed"
70
- | "keyEncrypted"
71
- | "keyPrefix"
72
- | "notesTitle"
73
- | "notesSubtitle"
74
- | "notesBody"
75
- | "sharePopupEnabled"
76
- | "sharePopupImage"
77
- | "sharePopupImageMime"
78
- | "sharePopupTitle"
79
- | "sharePopupBody"
80
- | "requestsPerMinute"
81
- | "dailyTokenLimit"
82
- | "monthlyTokenLimit"
83
- | "lifetimeTokenBudget"
84
- | "maxConcurrentRequests"
85
- > {
86
- readonly keyHash?: string | null;
87
- readonly keyEncrypted?: Buffer | null;
88
- readonly keyPrefix?: string | null;
89
- readonly notesTitle?: string | null;
90
- readonly notesSubtitle?: string | null;
91
- readonly notesBody?: string | null;
92
- readonly sharePopupEnabled?: boolean | null;
93
- /** Null clears both bytes and mime; setting one without the other is rejected upstream. */
94
- readonly sharePopupImage?: Buffer | null;
95
- readonly sharePopupImageMime?: string | null;
96
- readonly sharePopupTitle?: string | null;
97
- readonly sharePopupBody?: string | null;
98
- readonly requestsPerMinute?: number | null;
99
- readonly dailyTokenLimit?: number | null;
100
- readonly monthlyTokenLimit?: number | null;
101
- readonly lifetimeTokenBudget?: number | null;
102
- readonly maxConcurrentRequests?: number | null;
103
- }
104
-
105
- interface PopupFields {
106
- readonly sharePopupEnabled?: boolean | null;
107
- readonly sharePopupImage?: Buffer | null;
108
- readonly sharePopupImageMime?: string | null;
109
- readonly sharePopupTitle?: string | null;
110
- readonly sharePopupBody?: string | null;
111
- }
112
-
113
- function popupPatchTouched(patch: PopupFields): boolean {
114
- return (
115
- patch.sharePopupEnabled !== undefined ||
116
- patch.sharePopupImage !== undefined ||
117
- patch.sharePopupImageMime !== undefined ||
118
- patch.sharePopupTitle !== undefined ||
119
- patch.sharePopupBody !== undefined
120
- );
121
- }
122
-
123
- /** Merges a partial popup patch onto the stored row, so an omitted field is kept. */
124
- async function mergeSharePopup(
125
- db: CartethyiaDatabase,
126
- apiKeyId: string,
127
- patch: PopupFields,
128
- ): Promise<void> {
129
- const existing = await db
130
- .select()
131
- .from(sharePopups)
132
- .where(eq(sharePopups.apiKeyId, apiKeyId))
133
- .limit(1);
134
- const row = existing[0];
135
- await writeSharePopup(db, apiKeyId, {
136
- sharePopupEnabled: patch.sharePopupEnabled !== undefined ? patch.sharePopupEnabled : (row?.enabled ?? false),
137
- sharePopupImage: patch.sharePopupImage !== undefined ? patch.sharePopupImage : (row?.image ?? null),
138
- sharePopupImageMime: patch.sharePopupImageMime !== undefined ? patch.sharePopupImageMime : (row?.imageMime ?? null),
139
- sharePopupTitle: patch.sharePopupTitle !== undefined ? patch.sharePopupTitle : (row?.title ?? null),
140
- sharePopupBody: patch.sharePopupBody !== undefined ? patch.sharePopupBody : (row?.body ?? null),
141
- });
142
- }
143
-
144
- /** Writes the popup row, or removes it when the owner cleared every field. */
145
- async function writeSharePopup(
146
- db: CartethyiaDatabase,
147
- apiKeyId: string,
148
- fields: PopupFields,
149
- ): Promise<void> {
150
- const enabled = fields.sharePopupEnabled ?? false;
151
- const image = fields.sharePopupImage ?? null;
152
- const title = fields.sharePopupTitle ?? null;
153
- const body = fields.sharePopupBody ?? null;
154
- if (!enabled && image === null && title === null && body === null) {
155
- await db.delete(sharePopups).where(eq(sharePopups.apiKeyId, apiKeyId));
156
- return;
157
- }
158
- await db
159
- .insert(sharePopups)
160
- .values({
161
- apiKeyId,
162
- enabled,
163
- image,
164
- imageMime: fields.sharePopupImageMime ?? null,
165
- title,
166
- body,
167
- })
168
- .onConflictDoUpdate({
169
- target: sharePopups.apiKeyId,
170
- set: {
171
- enabled,
172
- image,
173
- imageMime: fields.sharePopupImageMime ?? null,
174
- title,
175
- body,
176
- },
177
- });
178
- }
179
-
180
- /** Explicit persistence boundary. Production must provide this; there is no fallback store. */
181
- export interface ApiKeyStore {
182
- list(tenantId: string): Promise<readonly ApiKeyRecord[]>;
183
- get(tenantId: string, keyId: string): Promise<ApiKeyRecord | undefined>;
184
- listChildren(tenantId: string, parentKeyId: string): Promise<readonly ApiKeyRecord[]>;
185
- create(record: ApiKeyRecord): Promise<void>;
186
- /** Rewrites list order for one tenant; `keyIds` is the full desired order. */
187
- reorder(tenantId: string, keyIds: readonly string[]): Promise<void>;
188
- update(tenantId: string, keyId: string, patch: ApiKeyPatch): Promise<ApiKeyRecord | undefined>;
189
- revoke(tenantId: string, keyId: string, revokedAt: Date): Promise<boolean>;
190
- remove(tenantId: string, keyId: string): Promise<boolean>;
191
- findActiveByHash?(hash: string): Promise<typeof apiKeys.$inferSelect | undefined>;
192
- findActiveById?(keyId: string): Promise<typeof apiKeys.$inferSelect | undefined>;
193
- sumChildrenConsumed?(parentKeyId: string): Promise<number>;
194
- }
195
-
196
- export class DrizzleApiKeyStore implements ApiKeyStore {
197
- constructor(private readonly db: CartethyiaDatabase) {}
198
-
199
- /** Attaches the popup, which lives in its own table so the auth read never loads it. */
200
- private async withPopups(
201
- db: SortOrderDb | CartethyiaDatabase,
202
- rows: readonly (typeof apiKeys.$inferSelect)[],
203
- ): Promise<ApiKeyRecord[]> {
204
- if (rows.length === 0) return [];
205
- const popups = await db
206
- .select()
207
- .from(sharePopups)
208
- .where(inArray(sharePopups.apiKeyId, rows.map((row) => row.id)));
209
- const byKey = new Map(popups.map((popup) => [popup.apiKeyId, popup]));
210
- return rows.map((row) => this.map(row, byKey.get(row.id)));
211
- }
212
-
213
- private map(
214
- row: typeof apiKeys.$inferSelect,
215
- popup?: typeof sharePopups.$inferSelect,
216
- ): ApiKeyRecord {
217
- return {
218
- id: row.id,
219
- tenantId: row.tenantId,
220
- keyHash: row.keyHash,
221
- keyMode: row.keyMode,
222
- isDefault: row.isDefault,
223
- ...(row.parentKeyId === null ? {} : { parentKeyId: row.parentKeyId }),
224
- ...(row.issuedClientIp === null ? {} : { issuedClientIp: row.issuedClientIp }),
225
- ...(row.issuedClientIpKey === null
226
- ? {}
227
- : { issuedClientIpKey: row.issuedClientIpKey }),
228
- label: row.label,
229
- enabled: row.enabled,
230
- scopes: row.scopes as ApiKeyRecord["scopes"],
231
- ...(row.keyPrefix === null ? {} : { keyPrefix: row.keyPrefix }),
232
- ...(row.keyEncrypted === null ? {} : { keyEncrypted: row.keyEncrypted }),
233
- ...(row.notesTitle === null ? {} : { notesTitle: row.notesTitle }),
234
- ...(row.notesSubtitle === null ? {} : { notesSubtitle: row.notesSubtitle }),
235
- ...(row.notesBody === null ? {} : { notesBody: row.notesBody }),
236
- ...(popup?.enabled ? { sharePopupEnabled: popup.enabled } : {}),
237
- ...(popup?.image == null ? {} : { sharePopupImage: popup.image }),
238
- ...(popup?.imageMime == null ? {} : { sharePopupImageMime: popup.imageMime }),
239
- ...(popup?.title == null ? {} : { sharePopupTitle: popup.title }),
240
- ...(popup?.body == null ? {} : { sharePopupBody: popup.body }),
241
- ...(row.requestsPerMinute === null ? {} : { requestsPerMinute: row.requestsPerMinute }),
242
- ...(row.dailyTokenLimit === null ? {} : { dailyTokenLimit: row.dailyTokenLimit }),
243
- ...(row.monthlyTokenLimit === null ? {} : { monthlyTokenLimit: row.monthlyTokenLimit }),
244
- ...(row.lifetimeTokenBudget === null ? {} : { lifetimeTokenBudget: row.lifetimeTokenBudget }),
245
- ...(row.maxConcurrentRequests === null
246
- ? {}
247
- : { maxConcurrentRequests: row.maxConcurrentRequests }),
248
- ...(row.modelPrefix === null ? {} : { modelPrefix: row.modelPrefix }),
249
- modelAccessMode: row.modelAccessMode,
250
- ...(row.modelList === null ? {} : { modelList: row.modelList as string[] }),
251
- ...(row.clientRouterDenylist === null
252
- ? {}
253
- : { clientRouterDenylist: row.clientRouterDenylist as string[] }),
254
- createdAt: row.createdAt,
255
- ...(row.revokedAt ? { revokedAt: row.revokedAt } : {}),
256
- tokensConsumed: row.lifetimeTokensConsumed,
257
- };
258
- }
259
- async list(tenantId: string): Promise<readonly ApiKeyRecord[]> {
260
- // Bounded: a large tenant key list must not load fully into memory.
261
- // Ordered by the explicit `sort_index`, not `created_at`: a stable key means
262
- // rows keep their position across reloads (and can be reordered) instead of
263
- // shifting when two rows share a timestamp or one is updated.
264
- const rows = await this.db
265
- .select()
266
- .from(apiKeys)
267
- .where(eq(apiKeys.tenantId, tenantId))
268
- .orderBy(apiKeys.sortIndex, apiKeys.createdAt, apiKeys.id)
269
- .limit(1000);
270
- return this.withPopups(this.db, rows);
271
- }
272
-
273
- /** Rewrites the list order for one tenant; `keyIds` is the full order. */
274
- async reorder(tenantId: string, keyIds: readonly string[]): Promise<void> {
275
- await reorderSortIndex(
276
- this.db,
277
- { table: apiKeys, idColumn: apiKeys.id, sortIndexColumn: apiKeys.sortIndex },
278
- eq(apiKeys.tenantId, tenantId),
279
- keyIds,
280
- );
281
- }
282
-
283
- /** Next free list position for a new key in this tenant. */
284
- private async nextSortIndex(tenantId: string): Promise<number> {
285
- return nextSortIndex(
286
- this.db,
287
- { table: apiKeys, idColumn: apiKeys.id, sortIndexColumn: apiKeys.sortIndex },
288
- eq(apiKeys.tenantId, tenantId),
289
- );
290
- }
291
- async get(tenantId: string, keyId: string): Promise<ApiKeyRecord | undefined> {
292
- const rows = await this.db
293
- .select()
294
- .from(apiKeys)
295
- .where(and(eq(apiKeys.tenantId, tenantId), eq(apiKeys.id, keyId)))
296
- .limit(1);
297
- return rows[0] ? (await this.withPopups(this.db, rows))[0] : undefined;
298
- }
299
- async listChildren(tenantId: string, parentKeyId: string): Promise<readonly ApiKeyRecord[]> {
300
- const rows = await this.db
301
- .select()
302
- .from(apiKeys)
303
- .where(
304
- and(
305
- eq(apiKeys.tenantId, tenantId),
306
- eq(apiKeys.parentKeyId, parentKeyId),
307
- ),
308
- )
309
- .orderBy(apiKeys.createdAt, apiKeys.id);
310
- return this.withPopups(this.db, rows);
311
- }
312
- async create(record: ApiKeyRecord): Promise<void> {
313
- // Append to the end of the tenant's list rather than relying on creation
314
- // time, so a new key never displaces the existing rows' positions.
315
- const sortIndex = await this.nextSortIndex(record.tenantId);
316
- await this.db.insert(apiKeys).values({
317
- id: record.id,
318
- tenantId: record.tenantId,
319
- sortIndex,
320
- keyHash: record.keyHash,
321
- keyMode: record.keyMode,
322
- parentKeyId: record.parentKeyId ?? null,
323
- issuedClientIp: record.issuedClientIp ?? null,
324
- issuedClientIpKey: record.issuedClientIpKey ?? null,
325
- label: record.label,
326
- scopes: record.scopes,
327
- enabled: record.enabled,
328
- keyPrefix: record.keyPrefix ?? null,
329
- keyEncrypted: record.keyEncrypted ?? null,
330
- notesTitle: record.notesTitle ?? null,
331
- notesSubtitle: record.notesSubtitle ?? null,
332
- notesBody: record.notesBody ?? null,
333
- requestsPerMinute: record.requestsPerMinute ?? null,
334
- dailyTokenLimit: record.dailyTokenLimit ?? null,
335
- monthlyTokenLimit: record.monthlyTokenLimit ?? null,
336
- lifetimeTokenBudget: record.lifetimeTokenBudget ?? null,
337
- maxConcurrentRequests: record.maxConcurrentRequests ?? null,
338
- modelPrefix: record.modelPrefix ?? null,
339
- modelAccessMode: record.modelAccessMode ?? "whitelist",
340
- modelList: record.modelList ?? null,
341
- clientRouterDenylist: record.clientRouterDenylist ?? null,
342
- lifetimeTokensConsumed: record.tokensConsumed,
343
- });
344
- await writeSharePopup(this.db, record.id, record);
345
- }
346
- async update(
347
- tenantId: string,
348
- keyId: string,
349
- patch: ApiKeyPatch,
350
- ): Promise<ApiKeyRecord | undefined> {
351
- return this.db.transaction(async (tx) => {
352
- const existingRows = await lockRows(
353
- tx
354
- .select({
355
- id: apiKeys.id,
356
- keyMode: apiKeys.keyMode,
357
- parentKeyId: apiKeys.parentKeyId,
358
- })
359
- .from(apiKeys)
360
- .where(and(eq(apiKeys.tenantId, tenantId), eq(apiKeys.id, keyId)))
361
- .limit(1),
362
- );
363
- const existing = existingRows[0];
364
- if (!existing) return undefined;
365
- if (existing.parentKeyId !== null && patch.keyMode !== undefined && patch.keyMode !== existing.keyMode)
366
- return undefined;
367
-
368
- if (existing.keyMode === "share" && patch.keyMode === "personal") {
369
- await tx
370
- .update(apiKeys)
371
- .set({ revokedAt: new Date() })
372
- .where(and(eq(apiKeys.parentKeyId, keyId), isNull(apiKeys.revokedAt)));
373
- await tx
374
- .update(shareLinks)
375
- .set({ active: false })
376
- .where(and(eq(shareLinks.apiKeyId, keyId), eq(shareLinks.active, true)));
377
- }
378
-
379
- const rows = await tx
380
- .update(apiKeys)
381
- .set({
382
- ...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}),
383
- ...(patch.label !== undefined ? { label: patch.label } : {}),
384
- ...(patch.isDefault !== undefined ? { isDefault: patch.isDefault } : {}),
385
- ...(patch.keyHash !== undefined ? { keyHash: patch.keyHash } : {}),
386
- ...(patch.keyMode !== undefined ? { keyMode: patch.keyMode } : {}),
387
- ...(patch.keyEncrypted !== undefined ? { keyEncrypted: patch.keyEncrypted } : {}),
388
- ...(patch.keyPrefix !== undefined ? { keyPrefix: patch.keyPrefix } : {}),
389
- ...(patch.notesTitle !== undefined ? { notesTitle: patch.notesTitle } : {}),
390
- ...(patch.notesSubtitle !== undefined ? { notesSubtitle: patch.notesSubtitle } : {}),
391
- ...(patch.notesBody !== undefined ? { notesBody: patch.notesBody } : {}),
392
- ...(patch.requestsPerMinute !== undefined
393
- ? { requestsPerMinute: patch.requestsPerMinute }
394
- : {}),
395
- ...(patch.dailyTokenLimit !== undefined ? { dailyTokenLimit: patch.dailyTokenLimit } : {}),
396
- ...(patch.monthlyTokenLimit !== undefined
397
- ? { monthlyTokenLimit: patch.monthlyTokenLimit }
398
- : {}),
399
- ...(patch.lifetimeTokenBudget !== undefined
400
- ? { lifetimeTokenBudget: patch.lifetimeTokenBudget }
401
- : {}),
402
- ...(patch.maxConcurrentRequests !== undefined
403
- ? { maxConcurrentRequests: patch.maxConcurrentRequests }
404
- : {}),
405
- ...(patch.modelPrefix !== undefined ? { modelPrefix: patch.modelPrefix } : {}),
406
- ...(patch.modelAccessMode !== undefined ? { modelAccessMode: patch.modelAccessMode } : {}),
407
- ...(patch.modelList !== undefined ? { modelList: patch.modelList } : {}),
408
- ...(patch.clientRouterDenylist !== undefined
409
- ? { clientRouterDenylist: patch.clientRouterDenylist }
410
- : {}),
411
- // Scopes are validated once, in `validateApiKeyRequest`, against the
412
- // tenant scope set; the store writes what it is handed. Omitting the
413
- // field here means an update is type-checked, accepted, and then
414
- // silently dropped — a grant like `routing:cli_mapping` can be
415
- // turned on and never stick.
416
- ...(patch.scopes === undefined ? {} : { scopes: patch.scopes }),
417
- })
418
- .where(and(eq(apiKeys.tenantId, tenantId), eq(apiKeys.id, keyId)))
419
- .returning();
420
- if (!rows[0]) return undefined;
421
- if (popupPatchTouched(patch)) await mergeSharePopup(tx, keyId, patch);
422
- return (await this.withPopups(tx, rows))[0];
423
- });
424
- }
425
- async revoke(tenantId: string, keyId: string, revokedAt: Date): Promise<boolean> {
426
- return this.db.transaction(async (tx) => {
427
- const rows = await tx
428
- .update(apiKeys)
429
- .set({ revokedAt })
430
- .where(
431
- and(
432
- eq(apiKeys.tenantId, tenantId),
433
- eq(apiKeys.id, keyId),
434
- isNull(apiKeys.revokedAt),
435
- ),
436
- )
437
- .returning({ id: apiKeys.id, keyMode: apiKeys.keyMode, parentKeyId: apiKeys.parentKeyId });
438
- const row = rows[0];
439
- if (!row) return false;
440
- if (row.keyMode === "share" && row.parentKeyId === null) {
441
- await tx
442
- .update(apiKeys)
443
- .set({ revokedAt })
444
- .where(and(eq(apiKeys.parentKeyId, keyId), isNull(apiKeys.revokedAt)));
445
- await tx
446
- .update(shareLinks)
447
- .set({ active: false })
448
- .where(and(eq(shareLinks.apiKeyId, keyId), eq(shareLinks.active, true)));
449
- }
450
- return true;
451
- });
452
- }
453
- async remove(tenantId: string, keyId: string): Promise<boolean> {
454
- return this.db.transaction(async (tx) => {
455
- const rows = await tx
456
- .delete(apiKeys)
457
- .where(
458
- and(
459
- eq(apiKeys.tenantId, tenantId),
460
- eq(apiKeys.id, keyId),
461
- eq(apiKeys.isDefault, false),
462
- ),
463
- )
464
- .returning({ id: apiKeys.id });
465
- return rows.length > 0;
466
- });
467
- }
468
- async findActiveByHash(hash: string): Promise<typeof apiKeys.$inferSelect | undefined> {
469
- // The SQLite backend holds this lookup prepared. The builder path rebuilds
470
- // the query per call, which measured 60x the lookup itself, so the hot
471
- // path skips it. PostgreSQL has no prepared statement here and uses the
472
- // builder, whose plan is cached server-side.
473
- const prepared = getDbHandle().auth;
474
- if (prepared) return this.authRow(prepared.byHash.get(hash));
475
- const rows = await this.db
476
- .select()
477
- .from(apiKeys)
478
- .where(and(eq(apiKeys.keyHash, hash), eq(apiKeys.enabled, true), isNull(apiKeys.revokedAt)))
479
- .limit(1);
480
- return rows[0];
481
- }
482
-
483
- async findActiveById(keyId: string): Promise<typeof apiKeys.$inferSelect | undefined> {
484
- const prepared = getDbHandle().auth;
485
- if (prepared) return this.authRow(prepared.byId.get(keyId));
486
- const rows = await this.db
487
- .select()
488
- .from(apiKeys)
489
- .where(and(eq(apiKeys.id, keyId), eq(apiKeys.enabled, true), isNull(apiKeys.revokedAt)))
490
- .limit(1);
491
- return rows[0];
492
- }
493
-
494
- /**
495
- * Maps a raw SQLite auth row to the shape drizzle returns.
496
- *
497
- * The prepared statement selects the table's own column names, which are
498
- * snake_case, while every reader of this row — `resolveApiKeyAuthorization`
499
- * included — reads the camelCase `$inferSelect` fields. Copying the raw
500
- * keys and casting would typecheck and then hand auth `undefined` for
501
- * `parentKeyId`, `tenantId`, and every limit. SQLite also stores JSON as
502
- * text and booleans as integers, so those are converted here too. The SQL
503
- * stays a plain select.
504
- */
505
- private authRow(raw: Record<string, unknown> | null): typeof apiKeys.$inferSelect | undefined {
506
- if (raw === null) return undefined;
507
- const text = (key: string): string | null => {
508
- const value = raw[key];
509
- return typeof value === "string" ? value : null;
510
- };
511
- const date = (key: string): Date | null => {
512
- const value = text(key);
513
- return value === null ? null : new Date(value);
514
- };
515
- const integer = (key: string): number | null => {
516
- const value = raw[key];
517
- return typeof value === "number" ? value : null;
518
- };
519
- const json = (key: string): unknown => {
520
- const value = raw[key];
521
- return typeof value === "string" ? JSON.parse(value) : (value ?? null);
522
- };
523
- const flag = (key: string): boolean => raw[key] === 1 || raw[key] === true;
524
- return {
525
- id: text("id") ?? "",
526
- tenantId: text("tenant_id") ?? "",
527
- keyHash: text("key_hash"),
528
- keyMode: (text("key_mode") ?? "personal") as ApiKeyMode,
529
- parentKeyId: text("parent_key_id"),
530
- issuedClientIp: text("issued_client_ip"),
531
- issuedClientIpKey: text("issued_client_ip_key"),
532
- label: text("label") ?? "",
533
- scopes: json("scopes"),
534
- requestsPerMinute: integer("requests_per_minute"),
535
- dailyTokenLimit: integer("daily_token_limit"),
536
- monthlyTokenLimit: integer("monthly_token_limit"),
537
- lifetimeTokenBudget: integer("lifetime_token_budget"),
538
- lifetimeTokensConsumed: integer("lifetime_tokens_consumed") ?? 0,
539
- maxConcurrentRequests: integer("max_concurrent_requests"),
540
- modelAccessMode: (text("model_access_mode") ?? "whitelist") as ApiKeyModelAccessMode,
541
- modelList: json("model_list"),
542
- clientRouterDenylist: json("client_router_denylist"),
543
- modelPrefix: text("model_prefix"),
544
- createdAt: date("created_at") ?? new Date(0),
545
- enabled: flag("enabled"),
546
- revokedAt: date("revoked_at"),
547
- keyPrefix: text("key_prefix"),
548
- keyEncrypted: raw["key_encrypted"] ?? null,
549
- notesTitle: text("notes_title"),
550
- notesSubtitle: text("notes_subtitle"),
551
- notesBody: text("notes_body"),
552
- isDefault: flag("is_default"),
553
- sortIndex: integer("sort_index") ?? 0,
554
- } as unknown as typeof apiKeys.$inferSelect;
555
- }
556
-
557
- /**
558
- * Total lifetime consumption across one key's children, revoked included.
559
- *
560
- * The parent's own counter never moves for child traffic — attribution
561
- * lands on the child row — so quota enforcement reads the parent plus this
562
- * sum rather than the parent alone.
563
- *
564
- * Revoked children stay in the sum on purpose. Tokens a recipient already
565
- * spent are spent; dropping them on revoke would refund the parent's budget
566
- * the moment the Redis lifetime counter is re-seeded from this total (which
567
- * happens on every purge, and whenever that counter's TTL lapses), letting a
568
- * recipient burn the whole allowance, be revoked, and hand the next
569
- * recipient a fresh one. It also keeps this figure equal to the "Total
570
- * quota" the share dialog reports, which counts every recipient.
571
- */
572
- async sumChildrenConsumed(parentKeyId: string): Promise<number> {
573
- const prepared = getDbHandle().auth;
574
- if (prepared) return Number(prepared.childrenConsumed.get(parentKeyId)?.total ?? 0);
575
- const rows = await this.db
576
- .select({ total: sql<number>`coalesce(sum(${apiKeys.lifetimeTokensConsumed}), 0)` })
577
- .from(apiKeys)
578
- .where(eq(apiKeys.parentKeyId, parentKeyId));
579
- return Number(rows[0]?.total ?? 0);
580
- }
581
- }
1
+ // Persisted API-key store: the console API-key domain and gateway authentication both consume this boundary.
2
+
3
+ import { and, asc, count, desc, eq, inArray, isNotNull, isNull, sql } from "drizzle-orm";
4
+ import { nextSortIndex, reorderSortIndex, type SortOrderDb } from "../sort-order";
5
+ import { getDbHandle } from "../boot";
6
+ import type { CartethyiaDatabase } from "../db-handle";
7
+ import { lockRows } from "../dialect";
8
+ import { apiKeys, shareLinks, sharePopups, telemetryUsageTotals, type ApiKeyMode, type ApiKeyModelAccessMode } from "../schema";
9
+ import type { AccessScope } from "../../security/access-control";
10
+
11
+ /** Sortable columns for the paged share-child listing (api_keys columns only). */
12
+ export type SharedChildSortColumn =
13
+ | "createdAt"
14
+ | "label"
15
+ | "keyPrefix"
16
+ | "issuedClientIp"
17
+ | "revokedAt"
18
+ | "enabled";
19
+
20
+ export interface SharedChildrenPageOptions {
21
+ readonly limit: number;
22
+ readonly offset: number;
23
+ readonly sortBy: SharedChildSortColumn;
24
+ readonly order: "asc" | "desc";
25
+ readonly status: "all" | "active" | "revoked";
26
+ }
27
+
28
+ export interface SharedChildrenPage {
29
+ readonly rows: readonly ApiKeyRecord[];
30
+ readonly total: number;
31
+ readonly active: number;
32
+ readonly revoked: number;
33
+ /** Family-wide `lifetime_tokens_consumed` sum, independent of the page. */
34
+ readonly consumed: number;
35
+ }
36
+
37
+ /** Persisted API key. Only its one-way hash authenticates; plaintext is never stored. */
38
+ export interface ApiKeyRecord {
39
+ readonly id: string;
40
+ readonly tenantId: string;
41
+ readonly keyHash: string | null;
42
+ readonly keyMode: ApiKeyMode;
43
+ readonly parentKeyId?: string;
44
+ readonly issuedClientIp?: string;
45
+ readonly issuedClientIpKey?: string;
46
+ readonly label: string;
47
+ readonly enabled: boolean;
48
+ readonly scopes: readonly AccessScope[];
49
+ /** The tenant's mandatory gateway key: rotatable, never revocable. */
50
+ readonly isDefault: boolean;
51
+ /** Non-secret prefix configured on a key or used to identify a share child. */
52
+ readonly keyPrefix?: string;
53
+ /** Encrypted copy exists only for personal keys used by CLI handoff. */
54
+ readonly keyEncrypted?: Buffer;
55
+ readonly notesTitle?: string;
56
+ readonly notesSubtitle?: string;
57
+ readonly notesBody?: string;
58
+ readonly sharePopupEnabled?: boolean;
59
+ /** Uploaded popup art; served from the share page rather than a remote URL. */
60
+ readonly sharePopupImage?: Buffer;
61
+ readonly sharePopupImageMime?: string;
62
+ readonly sharePopupTitle?: string;
63
+ readonly sharePopupBody?: string;
64
+ readonly requestsPerMinute?: number;
65
+ readonly dailyTokenLimit?: number;
66
+ readonly monthlyTokenLimit?: number;
67
+ readonly lifetimeTokenBudget?: number;
68
+ readonly maxConcurrentRequests?: number;
69
+ /** Share-template recipient slot cap; undefined = unlimited. */
70
+ readonly maxChildren?: number;
71
+ readonly modelPrefix?: string;
72
+ readonly modelAccessMode?: ApiKeyModelAccessMode;
73
+ readonly modelList?: readonly string[];
74
+ /** Client-router ids this key refuses; see the schema column for the contract. */
75
+ readonly clientRouterDenylist?: readonly string[];
76
+ readonly createdAt: Date;
77
+ readonly revokedAt?: Date;
78
+ readonly tokensConsumed: number;
79
+ }
80
+
81
+ type SharedChildSortColumnRef =
82
+ | typeof apiKeys.createdAt
83
+ | typeof apiKeys.label
84
+ | typeof apiKeys.keyPrefix
85
+ | typeof apiKeys.issuedClientIp
86
+ | typeof apiKeys.revokedAt
87
+ | typeof apiKeys.enabled;
88
+
89
+ const SHARED_CHILD_SORT_COLUMNS: Readonly<Record<SharedChildSortColumn, SharedChildSortColumnRef>> = {
90
+ createdAt: apiKeys.createdAt,
91
+ label: apiKeys.label,
92
+ keyPrefix: apiKeys.keyPrefix,
93
+ issuedClientIp: apiKeys.issuedClientIp,
94
+ revokedAt: apiKeys.revokedAt,
95
+ enabled: apiKeys.enabled,
96
+ };
97
+
98
+ /**
99
+ * Partial key update. Nullable policy fields clear their values; `undefined`
100
+ * leaves them unchanged. Credential metadata is writable only for a mode
101
+ * transition performed by the API-key domain.
102
+ */
103
+ export interface ApiKeyPatch
104
+ extends Omit<
105
+ Partial<ApiKeyRecord>,
106
+ | "id"
107
+ | "tenantId"
108
+ | "keyHash"
109
+ | "parentKeyId"
110
+ | "issuedClientIp"
111
+ | "issuedClientIpKey"
112
+ | "createdAt"
113
+ | "revokedAt"
114
+ | "tokensConsumed"
115
+ | "keyEncrypted"
116
+ | "keyPrefix"
117
+ | "notesTitle"
118
+ | "notesSubtitle"
119
+ | "notesBody"
120
+ | "sharePopupEnabled"
121
+ | "sharePopupImage"
122
+ | "sharePopupImageMime"
123
+ | "sharePopupTitle"
124
+ | "sharePopupBody"
125
+ | "requestsPerMinute"
126
+ | "dailyTokenLimit"
127
+ | "monthlyTokenLimit"
128
+ | "lifetimeTokenBudget"
129
+ | "maxConcurrentRequests"
130
+ | "maxChildren"
131
+ > {
132
+ readonly keyHash?: string | null;
133
+ readonly keyEncrypted?: Buffer | null;
134
+ readonly keyPrefix?: string | null;
135
+ readonly notesTitle?: string | null;
136
+ readonly notesSubtitle?: string | null;
137
+ readonly notesBody?: string | null;
138
+ readonly sharePopupEnabled?: boolean | null;
139
+ /** Null clears both bytes and mime; setting one without the other is rejected upstream. */
140
+ readonly sharePopupImage?: Buffer | null;
141
+ readonly sharePopupImageMime?: string | null;
142
+ readonly sharePopupTitle?: string | null;
143
+ readonly sharePopupBody?: string | null;
144
+ readonly requestsPerMinute?: number | null;
145
+ readonly dailyTokenLimit?: number | null;
146
+ readonly monthlyTokenLimit?: number | null;
147
+ readonly lifetimeTokenBudget?: number | null;
148
+ readonly maxConcurrentRequests?: number | null;
149
+ readonly maxChildren?: number | null;
150
+ }
151
+
152
+ interface PopupFields {
153
+ readonly sharePopupEnabled?: boolean | null;
154
+ readonly sharePopupImage?: Buffer | null;
155
+ readonly sharePopupImageMime?: string | null;
156
+ readonly sharePopupTitle?: string | null;
157
+ readonly sharePopupBody?: string | null;
158
+ }
159
+
160
+ function popupPatchTouched(patch: PopupFields): boolean {
161
+ return (
162
+ patch.sharePopupEnabled !== undefined ||
163
+ patch.sharePopupImage !== undefined ||
164
+ patch.sharePopupImageMime !== undefined ||
165
+ patch.sharePopupTitle !== undefined ||
166
+ patch.sharePopupBody !== undefined
167
+ );
168
+ }
169
+
170
+ /** Merges a partial popup patch onto the stored row, so an omitted field is kept. */
171
+ async function mergeSharePopup(
172
+ db: CartethyiaDatabase,
173
+ apiKeyId: string,
174
+ patch: PopupFields,
175
+ ): Promise<void> {
176
+ const existing = await db
177
+ .select()
178
+ .from(sharePopups)
179
+ .where(eq(sharePopups.apiKeyId, apiKeyId))
180
+ .limit(1);
181
+ const row = existing[0];
182
+ await writeSharePopup(db, apiKeyId, {
183
+ sharePopupEnabled: patch.sharePopupEnabled !== undefined ? patch.sharePopupEnabled : (row?.enabled ?? false),
184
+ sharePopupImage: patch.sharePopupImage !== undefined ? patch.sharePopupImage : (row?.image ?? null),
185
+ sharePopupImageMime: patch.sharePopupImageMime !== undefined ? patch.sharePopupImageMime : (row?.imageMime ?? null),
186
+ sharePopupTitle: patch.sharePopupTitle !== undefined ? patch.sharePopupTitle : (row?.title ?? null),
187
+ sharePopupBody: patch.sharePopupBody !== undefined ? patch.sharePopupBody : (row?.body ?? null),
188
+ });
189
+ }
190
+
191
+ /** Writes the popup row, or removes it when the owner cleared every field. */
192
+ async function writeSharePopup(
193
+ db: CartethyiaDatabase,
194
+ apiKeyId: string,
195
+ fields: PopupFields,
196
+ ): Promise<void> {
197
+ const enabled = fields.sharePopupEnabled ?? false;
198
+ const image = fields.sharePopupImage ?? null;
199
+ const title = fields.sharePopupTitle ?? null;
200
+ const body = fields.sharePopupBody ?? null;
201
+ if (!enabled && image === null && title === null && body === null) {
202
+ await db.delete(sharePopups).where(eq(sharePopups.apiKeyId, apiKeyId));
203
+ return;
204
+ }
205
+ await db
206
+ .insert(sharePopups)
207
+ .values({
208
+ apiKeyId,
209
+ enabled,
210
+ image,
211
+ imageMime: fields.sharePopupImageMime ?? null,
212
+ title,
213
+ body,
214
+ })
215
+ .onConflictDoUpdate({
216
+ target: sharePopups.apiKeyId,
217
+ set: {
218
+ enabled,
219
+ image,
220
+ imageMime: fields.sharePopupImageMime ?? null,
221
+ title,
222
+ body,
223
+ },
224
+ });
225
+ }
226
+
227
+ /** Explicit persistence boundary. Production must provide this; there is no fallback store. */
228
+ export interface ApiKeyStore {
229
+ list(tenantId: string): Promise<readonly ApiKeyRecord[]>;
230
+ get(tenantId: string, keyId: string): Promise<ApiKeyRecord | undefined>;
231
+ listChildren(tenantId: string, parentKeyId: string): Promise<readonly ApiKeyRecord[]>;
232
+ revokeChildrenByScope(
233
+ tenantId: string,
234
+ parentKeyId: string,
235
+ scope: "active" | "unused",
236
+ ): Promise<number>;
237
+ /** Cumulative family spend: template counter + every telemetry total row ever
238
+ * recorded for the family (revoked/deleted children stay counted). */
239
+ sumFamilyTelemetryConsumed(tenantId: string, parentKeyId: string): Promise<number>;
240
+ listChildrenPage(
241
+ tenantId: string,
242
+ parentKeyId: string,
243
+ options: SharedChildrenPageOptions,
244
+ ): Promise<SharedChildrenPage>;
245
+ create(record: ApiKeyRecord): Promise<void>;
246
+ /** Rewrites list order for one tenant; `keyIds` is the full desired order. */
247
+ reorder(tenantId: string, keyIds: readonly string[]): Promise<void>;
248
+ update(tenantId: string, keyId: string, patch: ApiKeyPatch): Promise<ApiKeyRecord | undefined>;
249
+ revoke(tenantId: string, keyId: string, revokedAt: Date): Promise<boolean>;
250
+ remove(tenantId: string, keyId: string): Promise<boolean>;
251
+ findActiveByHash?(hash: string): Promise<typeof apiKeys.$inferSelect | undefined>;
252
+ findActiveById?(keyId: string): Promise<typeof apiKeys.$inferSelect | undefined>;
253
+ sumChildrenConsumed?(parentKeyId: string): Promise<number>;
254
+ }
255
+
256
+ export class DrizzleApiKeyStore implements ApiKeyStore {
257
+ constructor(private readonly db: CartethyiaDatabase) {}
258
+
259
+ /** Attaches the popup, which lives in its own table so the auth read never loads it. */
260
+ private async withPopups(
261
+ db: SortOrderDb | CartethyiaDatabase,
262
+ rows: readonly (typeof apiKeys.$inferSelect)[],
263
+ ): Promise<ApiKeyRecord[]> {
264
+ if (rows.length === 0) return [];
265
+ const popups = await db
266
+ .select()
267
+ .from(sharePopups)
268
+ .where(inArray(sharePopups.apiKeyId, rows.map((row) => row.id)));
269
+ const byKey = new Map(popups.map((popup) => [popup.apiKeyId, popup]));
270
+ return rows.map((row) => this.map(row, byKey.get(row.id)));
271
+ }
272
+
273
+ private map(
274
+ row: typeof apiKeys.$inferSelect,
275
+ popup?: typeof sharePopups.$inferSelect,
276
+ ): ApiKeyRecord {
277
+ return {
278
+ id: row.id,
279
+ tenantId: row.tenantId,
280
+ keyHash: row.keyHash,
281
+ keyMode: row.keyMode,
282
+ isDefault: row.isDefault,
283
+ ...(row.parentKeyId === null ? {} : { parentKeyId: row.parentKeyId }),
284
+ ...(row.issuedClientIp === null ? {} : { issuedClientIp: row.issuedClientIp }),
285
+ ...(row.issuedClientIpKey === null
286
+ ? {}
287
+ : { issuedClientIpKey: row.issuedClientIpKey }),
288
+ label: row.label,
289
+ enabled: row.enabled,
290
+ scopes: row.scopes as ApiKeyRecord["scopes"],
291
+ ...(row.keyPrefix === null ? {} : { keyPrefix: row.keyPrefix }),
292
+ ...(row.keyEncrypted === null ? {} : { keyEncrypted: row.keyEncrypted }),
293
+ ...(row.notesTitle === null ? {} : { notesTitle: row.notesTitle }),
294
+ ...(row.notesSubtitle === null ? {} : { notesSubtitle: row.notesSubtitle }),
295
+ ...(row.notesBody === null ? {} : { notesBody: row.notesBody }),
296
+ ...(popup?.enabled ? { sharePopupEnabled: popup.enabled } : {}),
297
+ ...(popup?.image == null ? {} : { sharePopupImage: popup.image }),
298
+ ...(popup?.imageMime == null ? {} : { sharePopupImageMime: popup.imageMime }),
299
+ ...(popup?.title == null ? {} : { sharePopupTitle: popup.title }),
300
+ ...(popup?.body == null ? {} : { sharePopupBody: popup.body }),
301
+ ...(row.requestsPerMinute === null ? {} : { requestsPerMinute: row.requestsPerMinute }),
302
+ ...(row.dailyTokenLimit === null ? {} : { dailyTokenLimit: row.dailyTokenLimit }),
303
+ ...(row.monthlyTokenLimit === null ? {} : { monthlyTokenLimit: row.monthlyTokenLimit }),
304
+ ...(row.lifetimeTokenBudget === null ? {} : { lifetimeTokenBudget: row.lifetimeTokenBudget }),
305
+ ...(row.maxConcurrentRequests === null
306
+ ? {}
307
+ : { maxConcurrentRequests: row.maxConcurrentRequests }),
308
+ ...(row.maxChildren === null ? {} : { maxChildren: row.maxChildren }),
309
+ ...(row.modelPrefix === null ? {} : { modelPrefix: row.modelPrefix }),
310
+ modelAccessMode: row.modelAccessMode,
311
+ ...(row.modelList === null ? {} : { modelList: row.modelList as string[] }),
312
+ ...(row.clientRouterDenylist === null
313
+ ? {}
314
+ : { clientRouterDenylist: row.clientRouterDenylist as string[] }),
315
+ createdAt: row.createdAt,
316
+ ...(row.revokedAt ? { revokedAt: row.revokedAt } : {}),
317
+ tokensConsumed: row.lifetimeTokensConsumed,
318
+ };
319
+ }
320
+ async list(tenantId: string): Promise<readonly ApiKeyRecord[]> {
321
+ // Bounded: a large tenant key list must not load fully into memory.
322
+ // Ordered by the explicit `sort_index`, not `created_at`: a stable key means
323
+ // rows keep their position across reloads (and can be reordered) instead of
324
+ // shifting when two rows share a timestamp or one is updated.
325
+ const rows = await this.db
326
+ .select()
327
+ .from(apiKeys)
328
+ .where(eq(apiKeys.tenantId, tenantId))
329
+ // Parents first: share children all carry `sort_index = 0` (the column
330
+ // default), so sorting by `sort_index` alone lets a huge family of
331
+ // children crowd every top-level key except the one with the oldest
332
+ // `created_at` out of the 1000-row page. The console only renders
333
+ // top-level keys, so they must never be truncated away by their own
334
+ // children.
335
+ .orderBy(sql`(${apiKeys.parentKeyId} IS NOT NULL)`, apiKeys.sortIndex, apiKeys.createdAt, apiKeys.id)
336
+ .limit(1000);
337
+ return this.withPopups(this.db, rows);
338
+ }
339
+
340
+ /** Rewrites the list order for one tenant; `keyIds` is the full order. */
341
+ async reorder(tenantId: string, keyIds: readonly string[]): Promise<void> {
342
+ await reorderSortIndex(
343
+ this.db,
344
+ { table: apiKeys, idColumn: apiKeys.id, sortIndexColumn: apiKeys.sortIndex },
345
+ eq(apiKeys.tenantId, tenantId),
346
+ keyIds,
347
+ );
348
+ }
349
+
350
+ /** Next free list position for a new key in this tenant. */
351
+ private async nextSortIndex(tenantId: string): Promise<number> {
352
+ return nextSortIndex(
353
+ this.db,
354
+ { table: apiKeys, idColumn: apiKeys.id, sortIndexColumn: apiKeys.sortIndex },
355
+ eq(apiKeys.tenantId, tenantId),
356
+ );
357
+ }
358
+ async get(tenantId: string, keyId: string): Promise<ApiKeyRecord | undefined> {
359
+ const rows = await this.db
360
+ .select()
361
+ .from(apiKeys)
362
+ .where(and(eq(apiKeys.tenantId, tenantId), eq(apiKeys.id, keyId)))
363
+ .limit(1);
364
+ return rows[0] ? (await this.withPopups(this.db, rows))[0] : undefined;
365
+ }
366
+ async listChildren(tenantId: string, parentKeyId: string): Promise<readonly ApiKeyRecord[]> {
367
+ const rows = await this.db
368
+ .select()
369
+ .from(apiKeys)
370
+ .where(
371
+ and(
372
+ eq(apiKeys.tenantId, tenantId),
373
+ eq(apiKeys.parentKeyId, parentKeyId),
374
+ ),
375
+ )
376
+ .orderBy(apiKeys.createdAt, apiKeys.id);
377
+ return this.withPopups(this.db, rows);
378
+ }
379
+
380
+ async listChildrenPage(
381
+ tenantId: string,
382
+ parentKeyId: string,
383
+ options: SharedChildrenPageOptions,
384
+ ): Promise<SharedChildrenPage> {
385
+ const column = SHARED_CHILD_SORT_COLUMNS[options.sortBy];
386
+ const direction = options.order === "desc" ? desc(column) : asc(column);
387
+ const statusFilter =
388
+ options.status === "active"
389
+ ? isNull(apiKeys.revokedAt)
390
+ : options.status === "revoked"
391
+ ? isNotNull(apiKeys.revokedAt)
392
+ : undefined;
393
+ const where = and(
394
+ eq(apiKeys.tenantId, tenantId),
395
+ eq(apiKeys.parentKeyId, parentKeyId),
396
+ statusFilter,
397
+ );
398
+ // The page query and the count query run together; the counts feed the
399
+ // modal's header ("active users") without a second round trip per page.
400
+ const [rows, counts, template] = await Promise.all([
401
+ this.db.select().from(apiKeys).where(where).orderBy(direction, apiKeys.createdAt, apiKeys.id)
402
+ .limit(options.limit)
403
+ .offset(options.offset),
404
+ this.db
405
+ .select({
406
+ total: count(),
407
+ active: sql<number>`count(*) filter (where ${apiKeys.revokedAt} is null)`,
408
+ revoked: sql<number>`count(*) filter (where ${apiKeys.revokedAt} is not null)`,
409
+ })
410
+ .from(apiKeys)
411
+ .where(where),
412
+ this.db
413
+ .select({ lifetime: apiKeys.lifetimeTokensConsumed })
414
+ .from(apiKeys)
415
+ .where(eq(apiKeys.id, parentKeyId))
416
+ .limit(1),
417
+ ]);
418
+ const summary = counts[0]!;
419
+ // Cumulative family spend: the template's own counter plus every telemetry
420
+ // total row the family ever recorded (revoked/deleted children stay
421
+ // counted). Deleted children leave telemetry rows behind, so this is the
422
+ // number that survives cleanup — matching the overview and share page.
423
+ const familyTelemetry = await this.sumFamilyTelemetryConsumed(tenantId, parentKeyId);
424
+ return {
425
+ rows: await this.withPopups(this.db, rows),
426
+ total: Number(summary.total),
427
+ active: Number(summary.active),
428
+ revoked: Number(summary.revoked),
429
+ consumed: Number(template[0]?.lifetime ?? 0) + familyTelemetry,
430
+ };
431
+ }
432
+ /**
433
+ * Revokes every active child (or only the active children that never spent
434
+ * a token) of a share template, in bounded batches so one statement never
435
+ * exceeds the pool's statement timeout. A family can hold hundreds of
436
+ * thousands of recipients; the console's bulk actions target all of them,
437
+ * not just the currently rendered page.
438
+ */
439
+ async revokeChildrenByScope(
440
+ tenantId: string,
441
+ parentKeyId: string,
442
+ scope: "active" | "unused",
443
+ ): Promise<number> {
444
+ const condition = and(
445
+ eq(apiKeys.tenantId, tenantId),
446
+ eq(apiKeys.parentKeyId, parentKeyId),
447
+ isNull(apiKeys.revokedAt),
448
+ ...(scope === "unused" ? [eq(apiKeys.lifetimeTokensConsumed, 0)] : []),
449
+ );
450
+ let deleted = 0;
451
+ for (;;) {
452
+ const batch = await this.db
453
+ .select({ id: apiKeys.id })
454
+ .from(apiKeys)
455
+ .where(condition)
456
+ .orderBy(apiKeys.id)
457
+ .limit(5000);
458
+ if (batch.length === 0) return deleted;
459
+ const result = await this.db
460
+ .delete(apiKeys)
461
+ .where(inArray(apiKeys.id, batch.map((row) => row.id)));
462
+ deleted += result.rowCount ?? batch.length;
463
+ }
464
+ }
465
+
466
+ async sumFamilyTelemetryConsumed(tenantId: string, parentKeyId: string): Promise<number> {
467
+ const rows = await this.db
468
+ .select({
469
+ total: sql<number>`coalesce(sum(${telemetryUsageTotals.inputTokens} + ${telemetryUsageTotals.outputTokens}), 0)`,
470
+ })
471
+ .from(telemetryUsageTotals)
472
+ .where(
473
+ and(
474
+ eq(telemetryUsageTotals.tenantId, tenantId),
475
+ eq(telemetryUsageTotals.identityType, "api_key"),
476
+ sql`(${telemetryUsageTotals.familyTemplateKeyId} = ${parentKeyId} or (${telemetryUsageTotals.familyTemplateKeyId} is null and exists (select 1 from ${apiKeys} where ${apiKeys.id} = ${telemetryUsageTotals.entityId} and ${apiKeys.parentKeyId} = ${parentKeyId})))`,
477
+ ),
478
+ );
479
+ return Number(rows[0]?.total ?? 0);
480
+ }
481
+
482
+ async create(record: ApiKeyRecord): Promise<void> {
483
+ // Append to the end of the tenant's list rather than relying on creation
484
+ // time, so a new key never displaces the existing rows' positions.
485
+ const sortIndex = await this.nextSortIndex(record.tenantId);
486
+ await this.db.insert(apiKeys).values({
487
+ id: record.id,
488
+ tenantId: record.tenantId,
489
+ sortIndex,
490
+ keyHash: record.keyHash,
491
+ keyMode: record.keyMode,
492
+ parentKeyId: record.parentKeyId ?? null,
493
+ issuedClientIp: record.issuedClientIp ?? null,
494
+ issuedClientIpKey: record.issuedClientIpKey ?? null,
495
+ label: record.label,
496
+ scopes: record.scopes,
497
+ enabled: record.enabled,
498
+ keyPrefix: record.keyPrefix ?? null,
499
+ keyEncrypted: record.keyEncrypted ?? null,
500
+ notesTitle: record.notesTitle ?? null,
501
+ notesSubtitle: record.notesSubtitle ?? null,
502
+ notesBody: record.notesBody ?? null,
503
+ requestsPerMinute: record.requestsPerMinute ?? null,
504
+ dailyTokenLimit: record.dailyTokenLimit ?? null,
505
+ monthlyTokenLimit: record.monthlyTokenLimit ?? null,
506
+ lifetimeTokenBudget: record.lifetimeTokenBudget ?? null,
507
+ maxConcurrentRequests: record.maxConcurrentRequests ?? null,
508
+ maxChildren: record.maxChildren ?? null,
509
+ modelPrefix: record.modelPrefix ?? null,
510
+ modelAccessMode: record.modelAccessMode ?? "whitelist",
511
+ modelList: record.modelList ?? null,
512
+ clientRouterDenylist: record.clientRouterDenylist ?? null,
513
+ lifetimeTokensConsumed: record.tokensConsumed,
514
+ });
515
+ await writeSharePopup(this.db, record.id, record);
516
+ }
517
+ async update(
518
+ tenantId: string,
519
+ keyId: string,
520
+ patch: ApiKeyPatch,
521
+ ): Promise<ApiKeyRecord | undefined> {
522
+ return this.db.transaction(async (tx) => {
523
+ const existingRows = await lockRows(
524
+ tx
525
+ .select({
526
+ id: apiKeys.id,
527
+ keyMode: apiKeys.keyMode,
528
+ parentKeyId: apiKeys.parentKeyId,
529
+ })
530
+ .from(apiKeys)
531
+ .where(and(eq(apiKeys.tenantId, tenantId), eq(apiKeys.id, keyId)))
532
+ .limit(1),
533
+ );
534
+ const existing = existingRows[0];
535
+ if (!existing) return undefined;
536
+ if (existing.parentKeyId !== null && patch.keyMode !== undefined && patch.keyMode !== existing.keyMode)
537
+ return undefined;
538
+
539
+ if (existing.keyMode === "share" && patch.keyMode === "personal") {
540
+ await tx
541
+ .update(apiKeys)
542
+ .set({ revokedAt: new Date() })
543
+ .where(and(eq(apiKeys.parentKeyId, keyId), isNull(apiKeys.revokedAt)));
544
+ await tx
545
+ .update(shareLinks)
546
+ .set({ active: false })
547
+ .where(and(eq(shareLinks.apiKeyId, keyId), eq(shareLinks.active, true)));
548
+ }
549
+
550
+ const rows = await tx
551
+ .update(apiKeys)
552
+ .set({
553
+ ...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}),
554
+ ...(patch.label !== undefined ? { label: patch.label } : {}),
555
+ ...(patch.isDefault !== undefined ? { isDefault: patch.isDefault } : {}),
556
+ ...(patch.keyHash !== undefined ? { keyHash: patch.keyHash } : {}),
557
+ ...(patch.keyMode !== undefined ? { keyMode: patch.keyMode } : {}),
558
+ ...(patch.keyEncrypted !== undefined ? { keyEncrypted: patch.keyEncrypted } : {}),
559
+ ...(patch.keyPrefix !== undefined ? { keyPrefix: patch.keyPrefix } : {}),
560
+ ...(patch.notesTitle !== undefined ? { notesTitle: patch.notesTitle } : {}),
561
+ ...(patch.notesSubtitle !== undefined ? { notesSubtitle: patch.notesSubtitle } : {}),
562
+ ...(patch.notesBody !== undefined ? { notesBody: patch.notesBody } : {}),
563
+ ...(patch.requestsPerMinute !== undefined
564
+ ? { requestsPerMinute: patch.requestsPerMinute }
565
+ : {}),
566
+ ...(patch.dailyTokenLimit !== undefined ? { dailyTokenLimit: patch.dailyTokenLimit } : {}),
567
+ ...(patch.monthlyTokenLimit !== undefined
568
+ ? { monthlyTokenLimit: patch.monthlyTokenLimit }
569
+ : {}),
570
+ ...(patch.lifetimeTokenBudget !== undefined
571
+ ? { lifetimeTokenBudget: patch.lifetimeTokenBudget }
572
+ : {}),
573
+ ...(patch.maxConcurrentRequests !== undefined
574
+ ? { maxConcurrentRequests: patch.maxConcurrentRequests }
575
+ : {}),
576
+ ...(patch.maxChildren !== undefined ? { maxChildren: patch.maxChildren } : {}),
577
+ ...(patch.modelPrefix !== undefined ? { modelPrefix: patch.modelPrefix } : {}),
578
+ ...(patch.modelAccessMode !== undefined ? { modelAccessMode: patch.modelAccessMode } : {}),
579
+ ...(patch.modelList !== undefined ? { modelList: patch.modelList } : {}),
580
+ ...(patch.clientRouterDenylist !== undefined
581
+ ? { clientRouterDenylist: patch.clientRouterDenylist }
582
+ : {}),
583
+ // Scopes are validated once, in `validateApiKeyRequest`, against the
584
+ // tenant scope set; the store writes what it is handed. Omitting the
585
+ // field here means an update is type-checked, accepted, and then
586
+ // silently dropped — a grant like `routing:cli_mapping` can be
587
+ // turned on and never stick.
588
+ ...(patch.scopes === undefined ? {} : { scopes: patch.scopes }),
589
+ })
590
+ .where(and(eq(apiKeys.tenantId, tenantId), eq(apiKeys.id, keyId)))
591
+ .returning();
592
+ if (!rows[0]) return undefined;
593
+ if (popupPatchTouched(patch)) await mergeSharePopup(tx, keyId, patch);
594
+ return (await this.withPopups(tx, rows))[0];
595
+ });
596
+ }
597
+ async revoke(tenantId: string, keyId: string, revokedAt: Date): Promise<boolean> {
598
+ return this.db.transaction(async (tx) => {
599
+ const rows = await tx
600
+ .update(apiKeys)
601
+ .set({ revokedAt })
602
+ .where(
603
+ and(
604
+ eq(apiKeys.tenantId, tenantId),
605
+ eq(apiKeys.id, keyId),
606
+ isNull(apiKeys.revokedAt),
607
+ ),
608
+ )
609
+ .returning({ id: apiKeys.id, keyMode: apiKeys.keyMode, parentKeyId: apiKeys.parentKeyId });
610
+ const row = rows[0];
611
+ if (!row) return false;
612
+ if (row.keyMode === "share" && row.parentKeyId === null) {
613
+ await tx
614
+ .update(apiKeys)
615
+ .set({ revokedAt })
616
+ .where(and(eq(apiKeys.parentKeyId, keyId), isNull(apiKeys.revokedAt)));
617
+ await tx
618
+ .update(shareLinks)
619
+ .set({ active: false })
620
+ .where(and(eq(shareLinks.apiKeyId, keyId), eq(shareLinks.active, true)));
621
+ }
622
+ return true;
623
+ });
624
+ }
625
+ async remove(tenantId: string, keyId: string): Promise<boolean> {
626
+ return this.db.transaction(async (tx) => {
627
+ const rows = await tx
628
+ .delete(apiKeys)
629
+ .where(
630
+ and(
631
+ eq(apiKeys.tenantId, tenantId),
632
+ eq(apiKeys.id, keyId),
633
+ eq(apiKeys.isDefault, false),
634
+ ),
635
+ )
636
+ .returning({ id: apiKeys.id });
637
+ return rows.length > 0;
638
+ });
639
+ }
640
+ async findActiveByHash(hash: string): Promise<typeof apiKeys.$inferSelect | undefined> {
641
+ // The SQLite backend holds this lookup prepared. The builder path rebuilds
642
+ // the query per call, which measured 60x the lookup itself, so the hot
643
+ // path skips it. PostgreSQL has no prepared statement here and uses the
644
+ // builder, whose plan is cached server-side.
645
+ const prepared = getDbHandle().auth;
646
+ if (prepared) return this.authRow(prepared.byHash.get(hash));
647
+ const rows = await this.db
648
+ .select()
649
+ .from(apiKeys)
650
+ .where(and(eq(apiKeys.keyHash, hash), eq(apiKeys.enabled, true), isNull(apiKeys.revokedAt)))
651
+ .limit(1);
652
+ return rows[0];
653
+ }
654
+
655
+ async findActiveById(keyId: string): Promise<typeof apiKeys.$inferSelect | undefined> {
656
+ const prepared = getDbHandle().auth;
657
+ if (prepared) return this.authRow(prepared.byId.get(keyId));
658
+ const rows = await this.db
659
+ .select()
660
+ .from(apiKeys)
661
+ .where(and(eq(apiKeys.id, keyId), eq(apiKeys.enabled, true), isNull(apiKeys.revokedAt)))
662
+ .limit(1);
663
+ return rows[0];
664
+ }
665
+
666
+ /**
667
+ * Maps a raw SQLite auth row to the shape drizzle returns.
668
+ *
669
+ * The prepared statement selects the table's own column names, which are
670
+ * snake_case, while every reader of this row — `resolveApiKeyAuthorization`
671
+ * included — reads the camelCase `$inferSelect` fields. Copying the raw
672
+ * keys and casting would typecheck and then hand auth `undefined` for
673
+ * `parentKeyId`, `tenantId`, and every limit. SQLite also stores JSON as
674
+ * text and booleans as integers, so those are converted here too. The SQL
675
+ * stays a plain select.
676
+ */
677
+ private authRow(raw: Record<string, unknown> | null): typeof apiKeys.$inferSelect | undefined {
678
+ if (raw === null) return undefined;
679
+ const text = (key: string): string | null => {
680
+ const value = raw[key];
681
+ return typeof value === "string" ? value : null;
682
+ };
683
+ const date = (key: string): Date | null => {
684
+ const value = text(key);
685
+ return value === null ? null : new Date(value);
686
+ };
687
+ const integer = (key: string): number | null => {
688
+ const value = raw[key];
689
+ return typeof value === "number" ? value : null;
690
+ };
691
+ const json = (key: string): unknown => {
692
+ const value = raw[key];
693
+ return typeof value === "string" ? JSON.parse(value) : (value ?? null);
694
+ };
695
+ const flag = (key: string): boolean => raw[key] === 1 || raw[key] === true;
696
+ return {
697
+ id: text("id") ?? "",
698
+ tenantId: text("tenant_id") ?? "",
699
+ keyHash: text("key_hash"),
700
+ keyMode: (text("key_mode") ?? "personal") as ApiKeyMode,
701
+ parentKeyId: text("parent_key_id"),
702
+ issuedClientIp: text("issued_client_ip"),
703
+ issuedClientIpKey: text("issued_client_ip_key"),
704
+ label: text("label") ?? "",
705
+ scopes: json("scopes"),
706
+ requestsPerMinute: integer("requests_per_minute"),
707
+ dailyTokenLimit: integer("daily_token_limit"),
708
+ monthlyTokenLimit: integer("monthly_token_limit"),
709
+ lifetimeTokenBudget: integer("lifetime_token_budget"),
710
+ lifetimeTokensConsumed: integer("lifetime_tokens_consumed") ?? 0,
711
+ maxConcurrentRequests: integer("max_concurrent_requests"),
712
+ modelAccessMode: (text("model_access_mode") ?? "whitelist") as ApiKeyModelAccessMode,
713
+ modelList: json("model_list"),
714
+ clientRouterDenylist: json("client_router_denylist"),
715
+ modelPrefix: text("model_prefix"),
716
+ createdAt: date("created_at") ?? new Date(0),
717
+ enabled: flag("enabled"),
718
+ revokedAt: date("revoked_at"),
719
+ keyPrefix: text("key_prefix"),
720
+ keyEncrypted: raw["key_encrypted"] ?? null,
721
+ notesTitle: text("notes_title"),
722
+ notesSubtitle: text("notes_subtitle"),
723
+ notesBody: text("notes_body"),
724
+ isDefault: flag("is_default"),
725
+ sortIndex: integer("sort_index") ?? 0,
726
+ } as unknown as typeof apiKeys.$inferSelect;
727
+ }
728
+
729
+ /**
730
+ * Total lifetime consumption across one key's children, revoked included.
731
+ *
732
+ * The parent's own counter never moves for child traffic — attribution
733
+ * lands on the child row — so quota enforcement reads the parent plus this
734
+ * sum rather than the parent alone.
735
+ *
736
+ * Revoked children stay in the sum on purpose. Tokens a recipient already
737
+ * spent are spent; dropping them on revoke would refund the parent's budget
738
+ * the moment the Redis lifetime counter is re-seeded from this total (which
739
+ * happens on every purge, and whenever that counter's TTL lapses), letting a
740
+ * recipient burn the whole allowance, be revoked, and hand the next
741
+ * recipient a fresh one. It also keeps this figure equal to the "Total
742
+ * quota" the share dialog reports, which counts every recipient.
743
+ */
744
+ async sumChildrenConsumed(parentKeyId: string): Promise<number> {
745
+ const prepared = getDbHandle().auth;
746
+ if (prepared) return Number(prepared.childrenConsumed.get(parentKeyId)?.total ?? 0);
747
+ const rows = await this.db
748
+ .select({ total: sql<number>`coalesce(sum(${apiKeys.lifetimeTokensConsumed}), 0)` })
749
+ .from(apiKeys)
750
+ .where(eq(apiKeys.parentKeyId, parentKeyId));
751
+ return Number(rows[0]?.total ?? 0);
752
+ }
753
+ }