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
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Qoder native-identity console operations, shared by the tenant-scoped and
3
+ * global-admin account route groups.
4
+ *
5
+ * The identity is the account's device identity (minted seed). The routes
6
+ * surface it for export/import/regenerate from the provider-detail screen;
7
+ * the device-uniqueness invariant lives in the store (and the partial unique
8
+ * index behind it).
9
+ */
10
+ import { and, eq, isNull } from "drizzle-orm";
11
+ import { Elysia, t } from "elysia";
12
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
13
+ import { providerAccounts, type NativeIdentityRecord } from "../../persistence/schema";
14
+ import {
15
+ allocateQoderNativeIdentity,
16
+ DrizzleQoderNativeIdentityStore,
17
+ type QoderNativeIdentityStore,
18
+ } from "../../providers/integrations/qoder/qoder-native-identity-store";
19
+ import { ConsoleDomainError, requireGlobalAdmin, requireTenantScope } from "../shared/errors";
20
+ import type { ConsoleAccessResolver } from "../auth/access";
21
+
22
+ /** True when `token` looks like a plausible machine token. */
23
+ export function isValidNativeIdentityToken(token: string): boolean {
24
+ return typeof token === "string" && token.length >= 8 && token.length <= 512;
25
+ }
26
+
27
+ /**
28
+ * Allocates a fresh device identity for regenerate. Injected so tests can stub
29
+ * the native bridge; the default runs the real `runtime-info` bridge through
30
+ * the store.
31
+ */
32
+ export type NativeIdentityAllocator = (args: {
33
+ providerId: string;
34
+ accountId: string;
35
+ avoidToken?: string;
36
+ }) => Promise<NativeIdentityRecord | null>;
37
+
38
+ function defaultAllocator(store: QoderNativeIdentityStore): NativeIdentityAllocator {
39
+ return ({ providerId, accountId, avoidToken }) =>
40
+ allocateQoderNativeIdentity(store, providerId, accountId, {
41
+ ...(avoidToken !== undefined ? { avoidToken } : {}),
42
+ });
43
+ }
44
+
45
+ /** Loads one account row, scoped by tenant (null = global/shared). */
46
+ async function loadAccount(
47
+ db: CartethyiaDatabase,
48
+ accountId: string,
49
+ tenantId: string | null,
50
+ ): Promise<{ providerId: string; nativeIdentity: NativeIdentityRecord | null } | undefined> {
51
+ const rows = await db
52
+ .select({
53
+ providerId: providerAccounts.providerId,
54
+ nativeIdentity: providerAccounts.nativeIdentity,
55
+ })
56
+ .from(providerAccounts)
57
+ .where(
58
+ tenantId === null
59
+ ? and(eq(providerAccounts.id, accountId), isNull(providerAccounts.tenantId))
60
+ : and(eq(providerAccounts.id, accountId), eq(providerAccounts.tenantId, tenantId)),
61
+ )
62
+ .limit(1);
63
+ return rows[0];
64
+ }
65
+
66
+ export interface NativeIdentityOps {
67
+ get(accountId: string, tenantId: string | null): Promise<NativeIdentityRecord | null>;
68
+ put(accountId: string, tenantId: string | null, record: NativeIdentityRecord): Promise<NativeIdentityRecord>;
69
+ regenerate(accountId: string, tenantId: string | null): Promise<NativeIdentityRecord | null>;
70
+ }
71
+
72
+ export function createNativeIdentityOps(
73
+ db: CartethyiaDatabase,
74
+ allocator: NativeIdentityAllocator = defaultAllocator(new DrizzleQoderNativeIdentityStore(db)),
75
+ ): NativeIdentityOps {
76
+ const store = new DrizzleQoderNativeIdentityStore(db);
77
+ return {
78
+ async get(accountId, tenantId) {
79
+ const account = await loadAccount(db, accountId, tenantId);
80
+ // A missing row under the narrowed tenant scope means the account is not
81
+ // visible to this caller (cross-tenant read) — report not-found, the
82
+ // same convention the account routes use, rather than echoing null.
83
+ if (!account) throw new ConsoleDomainError("not_found", 404, "Account not found");
84
+ return account.nativeIdentity;
85
+ },
86
+ async put(accountId, tenantId, record) {
87
+ const account = await loadAccount(db, accountId, tenantId);
88
+ if (!account) throw new ConsoleDomainError("not_found", 404, "Account not found");
89
+ if (!isValidNativeIdentityToken(record.machineToken)) {
90
+ throw new ConsoleDomainError("invalid_identity", 400, "machineToken must be a non-empty string (8–512 chars)");
91
+ }
92
+ if (!record.machineType || !record.machineCode) {
93
+ throw new ConsoleDomainError("invalid_identity", 400, "machineToken, machineType, and machineCode are required");
94
+ }
95
+ // Device uniqueness: another account already owns this token? The pool is
96
+ // global across Qoder accounts (both realms), matching the unique index.
97
+ const occupied = await store.occupied(accountId);
98
+ if (occupied.has(record.machineToken)) {
99
+ throw new ConsoleDomainError("slot_taken", 409, `Native identity token is already bound to another account`);
100
+ }
101
+ await store.bind(account.providerId, accountId, record);
102
+ return record;
103
+ },
104
+ async regenerate(accountId, tenantId) {
105
+ const account = await loadAccount(db, accountId, tenantId);
106
+ if (!account) throw new ConsoleDomainError("not_found", 404, "Account not found");
107
+ return allocator({
108
+ providerId: account.providerId,
109
+ accountId,
110
+ ...(account.nativeIdentity?.machineToken !== undefined ? { avoidToken: account.nativeIdentity.machineToken } : {}),
111
+ });
112
+ },
113
+ };
114
+ }
115
+
116
+ /**
117
+ * The authorization decision for one account's native-identity route.
118
+ *
119
+ * Account ownership decides which rule applies: shared accounts
120
+ * (`tenant_id IS NULL`) need platform admin; tenant-owned accounts need the
121
+ * tenant scope for the owning tenant.
122
+ */
123
+ export interface NativeIdentityScope {
124
+ readonly tenantId: string | null;
125
+ }
126
+
127
+ /**
128
+ * Registers the native-identity routes on `app` (path
129
+ * `/provider-accounts/:id/native-identity`).
130
+ *
131
+ * `authorize` performs the access check based on the account's owner and
132
+ * returns the tenant scope to load the row under, so a shared account is
133
+ * reachable only by a platform admin and a tenant-owned account only by its
134
+ * tenant.
135
+ */
136
+ export function registerNativeIdentityRoutes(
137
+ app: Elysia,
138
+ deps: { readonly db: CartethyiaDatabase; readonly accessResolver: ConsoleAccessResolver },
139
+ allocator?: NativeIdentityAllocator,
140
+ ): void {
141
+ const ops = createNativeIdentityOps(deps.db, allocator);
142
+ const authorize = async (
143
+ request: Request,
144
+ accountId: string,
145
+ scope: "dashboard:read" | "dashboard:write",
146
+ ): Promise<NativeIdentityScope> => {
147
+ const rows = await deps.db
148
+ .select({ tenantId: providerAccounts.tenantId })
149
+ .from(providerAccounts)
150
+ .where(eq(providerAccounts.id, accountId))
151
+ .limit(1);
152
+ if (rows.length === 0) {
153
+ throw new ConsoleDomainError("not_found", 404, "Account not found");
154
+ }
155
+ const ownerTenantId = rows[0]!.tenantId;
156
+ if (ownerTenantId === null) {
157
+ requireGlobalAdmin(deps.accessResolver(request));
158
+ return { tenantId: null };
159
+ }
160
+ const access = requireTenantScope(deps.accessResolver(request), scope);
161
+ return { tenantId: access.tenantId };
162
+ };
163
+ app
164
+ .get("/provider-accounts/:id/native-identity", async ({ request, params }) => {
165
+ const { tenantId } = await authorize(request, params.id, "dashboard:read");
166
+ return { identity: await ops.get(params.id, tenantId) };
167
+ })
168
+ .put(
169
+ "/provider-accounts/:id/native-identity",
170
+ {
171
+ body: t.Object({
172
+ machineToken: t.String(),
173
+ machineType: t.String(),
174
+ machineCode: t.String(),
175
+ seed: t.Optional(t.String()),
176
+ }),
177
+ },
178
+ async ({ request, params, body }) => {
179
+ const { tenantId } = await authorize(request, params.id, "dashboard:write");
180
+ const identity = await ops.put(params.id, tenantId, body as NativeIdentityRecord);
181
+ return { identity };
182
+ },
183
+ )
184
+ .post("/provider-accounts/:id/native-identity/regenerate", async ({ request, params }) => {
185
+ const { tenantId } = await authorize(request, params.id, "dashboard:write");
186
+ const identity = await ops.regenerate(params.id, tenantId);
187
+ return { identity };
188
+ });
189
+ }
@@ -51,12 +51,18 @@ export interface RemoteSupportStartResponse extends RemoteSupportStatus {
51
51
  }
52
52
 
53
53
  /** Human-readable install guidance, keyed by tool. Never an installer. */
54
- export const REMOTE_SUPPORT_INSTALL_HINT: Readonly<Record<RemoteSupportTool, string>> = {
55
- cloudflared:
56
- "cloudflared is not installed. Install it yourself (winget install Cloudflare.cloudflared) and try again.",
57
- tailscale:
58
- "tailscale is not installed. Install it yourself (winget install Tailscale.Tailscale), run `tailscale up` once, then try again.",
59
- };
54
+ export function remoteSupportInstallHint(tool: RemoteSupportTool): string {
55
+ // Cross-OS install hint: pick the host's package manager. A Windows-only
56
+ // hint on Linux/macOS would be nonsense (and vice versa).
57
+ const win = process.platform === "win32";
58
+ const mac = process.platform === "darwin";
59
+ const installCommand = (pkg: string): string =>
60
+ win ? `winget install ${pkg}` : mac ? `brew install ${pkg}` : `sudo apt-get install -y ${pkg}`;
61
+ if (tool === "cloudflared") {
62
+ return `cloudflared is not installed. Install it yourself (${installCommand("cloudflared")}) and try again.`;
63
+ }
64
+ return `tailscale is not installed. Install it yourself (${installCommand("tailscale")}), run \`tailscale up\` once, then try again.`;
65
+ }
60
66
 
61
67
  export function isRemoteSupportTool(value: unknown): value is RemoteSupportTool {
62
68
  return typeof value === "string" && (REMOTE_SUPPORT_TOOLS as readonly string[]).includes(value);
@@ -18,7 +18,7 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, write
18
18
  import { join } from "node:path";
19
19
  import { resolveUserConfigDir } from "../../config/package-paths";
20
20
  import {
21
- REMOTE_SUPPORT_INSTALL_HINT,
21
+ remoteSupportInstallHint,
22
22
  REMOTE_SUPPORT_TOOLS,
23
23
  type RemoteSupportBlocker,
24
24
  type RemoteSupportStatus,
@@ -430,7 +430,7 @@ export function createRemoteSupportService(config: RemoteSupportServiceConfig):
430
430
  active: null,
431
431
  url: null,
432
432
  blocker: "not_installed",
433
- message: REMOTE_SUPPORT_INSTALL_HINT[tool],
433
+ message: remoteSupportInstallHint(tool),
434
434
  port,
435
435
  installed: await detectInstalled(),
436
436
  };
@@ -5,7 +5,7 @@ import { globalOrOwnedBy } from "../../persistence/tenant-scope";
5
5
  // client IP. The child bearer is returned only by the successful issue call.
6
6
 
7
7
  import { Elysia } from "elysia";
8
- import { and, eq } from "drizzle-orm";
8
+ import { and, eq, sql } from "drizzle-orm";
9
9
  import type { CartethyiaDatabase } from "../../persistence/db-handle";
10
10
  import { apiKeys, models, providers } from "../../persistence/schema";
11
11
  import { PublicModelCatalogStore } from "../providers/catalog/public-model-store";
@@ -227,12 +227,20 @@ async function resolveFamilyStats(
227
227
  if (resolved === null) return null;
228
228
  const templateId = resolved.key.id;
229
229
  const tenantId = resolved.key.tenantId;
230
- const children = await db
231
- .select({ id: apiKeys.id, revokedAt: apiKeys.revokedAt })
230
+ // Recipient counts come back as one aggregate row: a family minted by the
231
+ // tenant can carry tens of thousands of children, and materializing their
232
+ // ids only to count them made every share-page view pay for the family's
233
+ // size. The fingerprint/quote keys the rollup needs are addressed by the
234
+ // template id (`familyApiKeyId`), which scopes in SQL without any id list.
235
+ const [counts] = await db
236
+ .select({
237
+ total: sql<number>`count(*)`,
238
+ active: sql<number>`sum(case when ${apiKeys.revokedAt} is null then 1 else 0 end)`,
239
+ })
232
240
  .from(apiKeys)
233
241
  .where(eq(apiKeys.parentKeyId, templateId));
234
- const active = children.filter((child) => child.revokedAt === null).length;
235
- const keyIds = [templateId, ...children.map((child) => child.id)];
242
+ const total = Number(counts?.total ?? 0);
243
+ const active = Number(counts?.active ?? 0);
236
244
  // Rank only the models this link grants. A refused request still writes a
237
245
  // telemetry row carrying the name the client asked for, so an unfiltered
238
246
  // top-models table would list the very names the grant excludes. An
@@ -244,7 +252,12 @@ async function resolveFamilyStats(
244
252
  configured !== null && configured.length > 0
245
253
  ? await modelsForShare(db, resolved.key)
246
254
  : undefined;
247
- return stats.getFamilyStats(tenantId, keyIds, { total: children.length, active }, allowedModels);
255
+ return stats.getFamilyStats(
256
+ tenantId,
257
+ { templateKeyId: templateId, memberCount: total + 1 },
258
+ { total, active },
259
+ allowedModels,
260
+ );
248
261
  }
249
262
 
250
263
  /** Creates the public enrollment page and one-time shared-key issuance route. */
@@ -379,6 +392,26 @@ export function createShareRouter(options: ShareRouterOptions): Elysia {
379
392
  nameHint,
380
393
  });
381
394
  if (issued.kind === "link_unavailable") return notFound();
395
+ if (issued.kind === "slot_full") {
396
+ return json({
397
+ error: {
398
+ code: "template_slot_full",
399
+ message: "This template has reached its maximum number of recipients.",
400
+ },
401
+ }, 429);
402
+ }
403
+ if (issued.kind === "already_issued") {
404
+ // The recipient's IP already enrolled from this link. The original
405
+ // bearer secret is not stored server-side, so it cannot be returned;
406
+ // refuse the duplicate mint instead. The share page surfaces this as
407
+ // a clear message rather than minting an unbounded key set.
408
+ return json({
409
+ error: {
410
+ code: "already_issued",
411
+ message: "This address already has a key from this share link.",
412
+ },
413
+ }, 409);
414
+ }
382
415
  set.status = 201;
383
416
  return json({
384
417
  key: generated.secret,
@@ -11,10 +11,15 @@
11
11
  // masked addresses. No request or response bodies cross this boundary.
12
12
 
13
13
  import { createHash } from "node:crypto";
14
+ import { eq } from "drizzle-orm";
14
15
  import { maskClientIp } from "../../observability/redaction";
15
16
  import { type TelemetryEventRecord } from "../../observability/event-log";
16
- import { queryTelemetryEvents as querySqlEvents } from "../../observability/event-query";
17
+ import {
18
+ queryTelemetryEvents as querySqlEvents,
19
+ sumTelemetryTokens,
20
+ } from "../../observability/event-query";
17
21
  import type { CartethyiaDatabase } from "../../persistence/db-handle";
22
+ import { apiKeys } from "../../persistence/schema";
18
23
  import { isGatewayError } from "../../observability/telemetry-status";
19
24
  import type { RedisClient } from "../../persistence/redis";
20
25
  import { cachedRead } from "../shared/read-through-cache";
@@ -81,6 +86,14 @@ export interface ShareFamilyStats {
81
86
  /** Read-only family rollup for one share link. */
82
87
  export interface ShareStatsPort {
83
88
  /**
89
+ * The family is addressed by its template key (`templateKeyId`) plus the
90
+ * number of keys it covers at call time (`memberCount`), not by an id list:
91
+ * a family grows with every key the link mints, and enumerating its ids to
92
+ * build a query — or a cache key — scaled both with the family instead of
93
+ * with the request. `memberCount` keeps the cache key sensitive to minting:
94
+ * a newly issued child starts a fresh entry rather than being folded into
95
+ * the previous one for a full TTL.
96
+ *
84
97
  * `allowedModels`, when supplied, restricts the top-models table to the names
85
98
  * the link actually grants. A rejected request still writes a telemetry row
86
99
  * (with the requested name), so without this the table would rank models the
@@ -90,7 +103,7 @@ export interface ShareStatsPort {
90
103
  */
91
104
  getFamilyStats(
92
105
  tenantId: string,
93
- keyIds: readonly string[],
106
+ family: { readonly templateKeyId: string; readonly memberCount: number },
94
107
  recipients: { readonly total: number; readonly active: number },
95
108
  allowedModels?: readonly string[],
96
109
  ): Promise<ShareFamilyStats>;
@@ -244,19 +257,21 @@ const HOURS_WINDOW = 24;
244
257
  export const SHARE_STATS_CACHE_TTL_SECONDS = 10;
245
258
 
246
259
  /**
247
- * Cache key for one family's rollup. Both inputs change the payload — `keyIds`
248
- * selects the family and `allowedModels` filters the model table — so both are
249
- * hashed in; the key ids are sorted so key order cannot split one family across
250
- * two entries.
260
+ * Cache key for one family's rollup. The family is named by its template key
261
+ * and the number of keys it covers — both change the payload — and
262
+ * `allowedModels` filters the model table, so all three are hashed in.
263
+ * (The key used to hash the sorted list of every family id; on a family with
264
+ * tens of thousands of minted children that built and sorted a multi-megabyte
265
+ * string on every page view, before the cache could even answer.)
251
266
  */
252
267
  function shareFamilyStatsKey(
253
268
  tenantId: string,
254
- keyIds: readonly string[],
269
+ family: { readonly templateKeyId: string; readonly memberCount: number },
255
270
  allowedModels: readonly string[] | undefined,
256
271
  ): string {
257
272
  const models = allowedModels === undefined ? "*" : [...allowedModels].sort().join(",");
258
273
  const hash = createHash("sha256")
259
- .update([...keyIds].sort().join(",") + "|" + models)
274
+ .update(`${family.templateKeyId}#${family.memberCount}|${models}`)
260
275
  .digest("hex");
261
276
  return `share:v1:stats:${tenantId}:${hash}`;
262
277
  }
@@ -271,12 +286,12 @@ interface ShareStatsPortDeps {
271
286
 
272
287
  export function createShareStatsPort(deps: ShareStatsPortDeps): ShareStatsPort {
273
288
  return {
274
- async getFamilyStats(tenantId, keyIds, recipients, allowedModels) {
275
- if (keyIds.length === 0) return emptyStats(recipients);
289
+ async getFamilyStats(tenantId, family, recipients, allowedModels) {
290
+ if (family.templateKeyId.length === 0) return emptyStats(recipients);
276
291
  const cached = await cachedRead<Omit<ShareFamilyStats, "recipients">>(
277
292
  deps.redis,
278
- shareFamilyStatsKey(tenantId, keyIds, allowedModels),
279
- () => computeFamilyStatsBody(deps.db, tenantId, keyIds, allowedModels),
293
+ shareFamilyStatsKey(tenantId, family, allowedModels),
294
+ () => computeFamilyStatsBody(deps.db, tenantId, family.templateKeyId, allowedModels),
280
295
  SHARE_STATS_CACHE_TTL_SECONDS,
281
296
  );
282
297
  return { ...cached, recipients };
@@ -288,7 +303,7 @@ export function createShareStatsPort(deps: ShareStatsPortDeps): ShareStatsPort {
288
303
  async function computeFamilyStatsBody(
289
304
  db: CartethyiaDatabase,
290
305
  tenantId: string,
291
- keyIds: readonly string[],
306
+ templateKeyId: string,
292
307
  allowedModels: readonly string[] | undefined,
293
308
  ): Promise<Omit<ShareFamilyStats, "recipients">> {
294
309
  // A share that grants a fixed set ranks only that set. Telemetry keeps the
@@ -304,20 +319,35 @@ async function computeFamilyStatsBody(
304
319
  const lastHourStart = new Date(now.getTime() - HOUR_MS);
305
320
  const windowStart = new Date(hourStart.getTime() - (HOURS_WINDOW - 1) * HOUR_MS);
306
321
 
307
- // Rank only names the grant actually authorizes. Telemetry keeps the
308
- // requested name even when the request was refused, so an unfiltered table
309
- // would list exactly the invalid names an abuser probed. Filtering before
310
- // the top-50 cut means refused names cannot hide a model the recipient may
311
- // use.
312
- //
313
- // Bounded by the retention floor, not by the oldest counter: `totalTokens`
314
- // is all-time, so cutting at `monthStart` would silently drop the rows that
315
- // make it differ from `monthTokens`. Retention already caps the window, and
316
- // the rows are read newest-first under a page cap, so a very large family
317
- // cannot pull the whole table.
322
+ // All-time and today's/month's spend come from SQL aggregates, not from the
323
+ // bounded page below. The page is capped so a very large family cannot pull
324
+ // the whole table, which means it cannot carry the all-time total. Lifetime,
325
+ // daily, and monthly are therefore separate bounded sums over the retained
326
+ // SQL window (each is a single `SUM` aggregate, not a row materialization).
327
+ // The page's events feed only the hourly chart and the two ranked tables.
328
+ const [totalTokens, todayTokens, monthTokens, template] = await Promise.all([
329
+ sumTelemetryTokens(db, tenantId, { familyKeyId: templateKeyId, since: new Date(0) }),
330
+ sumTelemetryTokens(db, tenantId, { familyKeyId: templateKeyId, since: dayStart }),
331
+ sumTelemetryTokens(db, tenantId, { familyKeyId: templateKeyId, since: monthStart }),
332
+ db
333
+ .select({ lifetime: apiKeys.lifetimeTokensConsumed })
334
+ .from(apiKeys)
335
+ .where(eq(apiKeys.id, templateKeyId))
336
+ .limit(1),
337
+ ]);
338
+ // Cumulative family spend: the template's own counter plus the telemetry
339
+ // total. The counter is a manually-configured seed (an operator limit), not
340
+ // traffic, and it is never reset; azmetry rows for revoked/deleted children
341
+ // likewise stay counted, so the running total is monotonic and consistent
342
+ // across the overview, the share dialog, and this page.
343
+ const templateLifetime = Number(template[0]?.lifetime ?? 0);
344
+
345
+ // The hourly chart covers the last 24 hours, so the page only needs events
346
+ // in that window — not the whole retained history. Bounded and small.
318
347
  const page = await querySqlEvents(db, {
319
348
  tenantId,
320
- apiKeyIds: [...keyIds],
349
+ familyApiKeyId: templateKeyId,
350
+ since: windowStart,
321
351
  limit: MAX_FAMILY_EVENTS,
322
352
  });
323
353
  const events = page.events;
@@ -328,6 +358,15 @@ async function computeFamilyStatsBody(
328
358
  windowStart: windowStart.getTime(),
329
359
  allowed: allowedSet,
330
360
  });
361
+ rollup.totals = {
362
+ ...rollup.totals,
363
+ // The template counter is the seed on the running total, not a distinct
364
+ // bucket: daily/monthly windows are telemetry-only (the counter has no
365
+ // time axis), so those keep their own sums.
366
+ totalTokens: templateLifetime + totalTokens,
367
+ todayTokens,
368
+ monthTokens,
369
+ };
331
370
 
332
371
  // Fill the 24 buckets in JS: only hours that saw traffic are present, and
333
372
  // a chart drawn from sparse rows would silently compress the axis.
@@ -1,4 +1,4 @@
1
- import { and, eq, inArray } from "drizzle-orm";
1
+ import { and, eq, sql } from "drizzle-orm";
2
2
  import type { CartethyiaDatabase } from "../../persistence/db-handle";
3
3
  import type { ApiKeyRecord } from "../../persistence/stores/api-key-store";
4
4
  import { CachedPreferencesReader, DrizzlePreferencesReader } from "../../persistence/tenant-preferences";
@@ -9,7 +9,7 @@ import { isGatewayError } from "../../observability/telemetry-status";
9
9
  import type { RedisClient } from "../../persistence/redis";
10
10
  import { cachedRead } from "../shared/read-through-cache";
11
11
  import { SHARE_STATS_CACHE_TTL_SECONDS } from "./share-stats";
12
- import { telemetryUsageTotals } from "../../persistence/schema";
12
+ import { apiKeys, telemetryUsageTotals } from "../../persistence/schema";
13
13
  import { shouldMaskClientIp } from "../shared/ip-privacy";
14
14
  import { utcDayStart } from "../shared/day";
15
15
 
@@ -66,9 +66,16 @@ export interface SharedKeyActivityDetail {
66
66
 
67
67
  /** Read-only share-recipient telemetry; payloads and credential material never cross this boundary. */
68
68
  export interface ShareActivityPort {
69
+ /**
70
+ * `familyTemplateKeyId` is the share template the children were issued from:
71
+ * the telemetry reads scope to the whole family through it (an `EXISTS`
72
+ * against `api_keys`, constant-wire) rather than through an id list, so the
73
+ * read is the same no matter how many keys the template has minted.
74
+ */
69
75
  getSharedKeySummaries(
70
76
  tenantId: string,
71
77
  children: readonly ApiKeyRecord[],
78
+ familyTemplateKeyId: string,
72
79
  ): Promise<readonly SharedKeySummary[]>;
73
80
  getSharedKeyDetail(tenantId: string, childKeyId: string): Promise<SharedKeyActivityDetail>;
74
81
  }
@@ -133,15 +140,25 @@ export function createShareUsagePort(deps: ShareUsagePortDeps): ShareActivityPor
133
140
  const { db, redis } = deps;
134
141
  const preferences = new CachedPreferencesReader(new DrizzlePreferencesReader(db));
135
142
  return {
136
- async getSharedKeySummaries(tenantId, children) {
143
+ async getSharedKeySummaries(tenantId, children, familyTemplateKeyId) {
137
144
  if (children.length === 0) return [];
138
- const childIds = children.map((child) => child.id);
139
145
  const todayStart = utcDayStart(new Date());
146
+ /**
147
+ * Family membership of the entity the totals/events row is attributed to.
148
+ * Primary predicate is the denormalized template column (so history
149
+ * survives key deletion); the `EXISTS` fallback covers rows written
150
+ * before the column existed.
151
+ */
152
+ const familyMembership = (entityId: typeof apiKeys.id | typeof telemetryUsageTotals.entityId) =>
153
+ sql`(${telemetryUsageTotals.familyTemplateKeyId} = ${familyTemplateKeyId} or (${telemetryUsageTotals.familyTemplateKeyId} is null and exists (select 1 from ${apiKeys} where ${apiKeys.id} = ${entityId} and (${apiKeys.id} = ${familyTemplateKeyId} or ${apiKeys.parentKeyId} = ${familyTemplateKeyId}))))`;
140
154
  const [todayEvents, lifetimeRows, hideClientIp] = await Promise.all([
141
155
  // Today's per-key totals as one GROUP BY instead of reading every
142
156
  // event of the family. `addEventUsage` needs per-event fields, so the
143
- // groups are projected into the same shape the loop consumes.
144
- querySqlEvents(db, { tenantId, apiKeyIds: childIds, since: todayStart, limit: MAX_SUMMARY_EVENTS }).then(
157
+ // groups are projected into the same shape the loop consumes. The
158
+ // family filter is the template key, not an id list: a family with
159
+ // tens of thousands of minted children must not expand them into bind
160
+ // parameters on every dashboard poll.
161
+ querySqlEvents(db, { tenantId, familyApiKeyId: familyTemplateKeyId, since: todayStart, limit: MAX_SUMMARY_EVENTS }).then(
145
162
  (page) => page.events,
146
163
  ),
147
164
  db
@@ -158,7 +175,7 @@ export function createShareUsagePort(deps: ShareUsagePortDeps): ShareActivityPor
158
175
  and(
159
176
  eq(telemetryUsageTotals.tenantId, tenantId),
160
177
  eq(telemetryUsageTotals.identityType, "api_key"),
161
- inArray(telemetryUsageTotals.entityId, childIds),
178
+ familyMembership(telemetryUsageTotals.entityId),
162
179
  ),
163
180
  ),
164
181
  shouldMaskClientIp(preferences, tenantId),
@@ -0,0 +1,102 @@
1
+ // Tenant system-prompt injection contracts.
2
+ //
3
+ // Shared by the console API, the persistence store, and the dispatch-path
4
+ // transform — all Elysia-free so the dashboard bundle can import the types.
5
+
6
+ /**
7
+ * One scope's prompt configuration. `enabled` is the master switch for the
8
+ * scope; `mode` decides how the text meets existing system content.
9
+ */
10
+ export interface PromptSetting {
11
+ readonly enabled: boolean;
12
+ readonly mode: PromptMode;
13
+ readonly text: string;
14
+ }
15
+
16
+ export const PROMPT_MODES = ["append", "override"] as const;
17
+ export type PromptMode = (typeof PROMPT_MODES)[number];
18
+
19
+ /** Per-provider entry: follow the tenant's global prompt, or carry a custom one. */
20
+ export interface ProviderPromptEntry {
21
+ readonly followGlobal: boolean;
22
+ /** Present and used only when `followGlobal` is false. */
23
+ readonly setting?: PromptSetting;
24
+ }
25
+
26
+ /** The full per-tenant configuration, one row in `tenant_system_prompts`. */
27
+ export interface TenantSystemPrompts {
28
+ /** `null` = never configured — distinct from an explicit disabled setting. */
29
+ readonly global: PromptSetting | null;
30
+ /** providerId → entry */
31
+ readonly providers: Readonly<Record<string, ProviderPromptEntry>>;
32
+ /** `${providerId}::${modelId}` → setting */
33
+ readonly models: Readonly<Record<string, PromptSetting>>;
34
+ }
35
+
36
+ /** Empty configuration: nothing configured anywhere. */
37
+ export const EMPTY_TENANT_SYSTEM_PROMPTS: TenantSystemPrompts = {
38
+ global: null,
39
+ providers: {},
40
+ models: {},
41
+ };
42
+
43
+ /** Maximum stored text per scope (5 MB). Enforced at the console API. */
44
+ export const SYSTEM_PROMPT_TEXT_CAP_BYTES = 5_000_000;
45
+
46
+ /**
47
+ * Marker wrapping tenant text injected into a provider wire whose upstream
48
+ * validates the system prompt as a channel identity (the buddy family). The
49
+ * fixed identity prompt stays the opener; the tenant text rides after it
50
+ * inside the same turn, framed so the provider's own parser is not confused.
51
+ */
52
+ export const SYSTEM_PROMPT_WRAPPER_PREFIX = "<cartethyia:system-prompt>";
53
+ export const SYSTEM_PROMPT_WRAPPER_SUFFIX = "</cartethyia:system-prompt>";
54
+
55
+ /** Maximum number of per-provider entries stored for one tenant. */
56
+ export const SYSTEM_PROMPT_PROVIDER_LIMIT = 256;
57
+
58
+ /** Maximum number of per-model entries stored for one tenant. */
59
+ export const SYSTEM_PROMPT_MODEL_LIMIT = 512;
60
+
61
+ /**
62
+ * Providers whose upstream validates the fixed system prompt as a channel
63
+ * identity. Their system turn must keep the fixed prompt as the opener, so
64
+ * tenant text is appended after it inside the same turn, wrapped in the
65
+ * gateway's prefix/suffix markers (see `SYSTEM_PROMPT_WRAPPER_PREFIX`).
66
+ * `override` mode is not honoured for these providers — it would strip the
67
+ * identity and the upstream would refuse the request.
68
+ */
69
+ export const WRAPPED_INJECTION_PROVIDERS: ReadonlySet<string> = new Set([
70
+ "cb", // CodeBuddy International
71
+ "cbcn", // CodeBuddy CN
72
+ "workbuddy",
73
+ ]);
74
+
75
+ /** Whether a provider's tenant text rides wrapped after the fixed identity prompt. */
76
+ export function isWrappedInjectionProvider(providerId: string): boolean {
77
+ return WRAPPED_INJECTION_PROVIDERS.has(providerId);
78
+ }
79
+
80
+ /** Model-scope key for the `models` map. */
81
+ export function systemPromptModelKey(providerId: string, modelId: string): string {
82
+ return `${providerId}::${modelId}`;
83
+ }
84
+
85
+ /** Parses a model-scope key; null when the key is malformed. */
86
+ export function parseSystemPromptModelKey(key: string): { providerId: string; modelId: string } | null {
87
+ const index = key.indexOf("::");
88
+ if (index <= 0 || index === key.length - 2) return null;
89
+ return { providerId: key.slice(0, index), modelId: key.slice(index + 2) };
90
+ }
91
+
92
+ /** True when the value is a well-formed prompt setting (shape only, not size). */
93
+ export function isPromptSetting(value: unknown): value is PromptSetting {
94
+ if (typeof value !== "object" || value === null) return false;
95
+ const candidate = value as Partial<PromptSetting>;
96
+ return (
97
+ typeof candidate.enabled === "boolean" &&
98
+ typeof candidate.mode === "string" &&
99
+ (PROMPT_MODES as readonly string[]).includes(candidate.mode) &&
100
+ typeof candidate.text === "string"
101
+ );
102
+ }