cartethyia 2.7.1 → 2.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (263) hide show
  1. package/.env.example +2 -0
  2. package/dist/dashboard/assets/{BannedPage-D-J75hBM.js → BannedPage-2TJD37Hb.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-2TJD37Hb.js.gz +0 -0
  4. package/dist/dashboard/assets/CliToolDetailPage-xefJNRmJ.js +1 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-xefJNRmJ.js.gz +0 -0
  6. package/dist/dashboard/assets/CliToolsPage-Rj9iHQ3F.js +1 -0
  7. package/dist/dashboard/assets/CliToolsPage-Rj9iHQ3F.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-QoJ6foxo.js → CombosPage-JEbYJFxw.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-JEbYJFxw.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-DpNRay4C.js → ConsoleLogPage-C3xx_inM.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-C3xx_inM.js.gz +0 -0
  12. package/dist/dashboard/assets/CustomizationPage-Dw306gJU.js +1 -0
  13. package/dist/dashboard/assets/CustomizationPage-Dw306gJU.js.gz +0 -0
  14. package/dist/dashboard/assets/HealthEventsModal-jaBo1TWV.js +1 -0
  15. package/dist/dashboard/assets/HealthEventsModal-jaBo1TWV.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-DubdQcvX.js → LoginPage-C4w7vea-.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-C4w7vea-.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-BH8Tehdi.js → ModelPicker-BtPcAfot.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-BtPcAfot.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-CGC5gNs1.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-CGC5gNs1.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-DQcthNGt.js +4 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-DQcthNGt.js.gz +0 -0
  24. package/dist/dashboard/assets/ProvidersPage-0OsXRGXe.js +1 -0
  25. package/dist/dashboard/assets/ProvidersPage-0OsXRGXe.js.gz +0 -0
  26. package/dist/dashboard/assets/ProxyPage-DdPc8slb.js +9 -0
  27. package/dist/dashboard/assets/ProxyPage-DdPc8slb.js.gz +0 -0
  28. package/dist/dashboard/assets/{QuotaPage-ATpaxrbl.js → QuotaPage-Ci5O4BnH.js} +1 -1
  29. package/dist/dashboard/assets/QuotaPage-Ci5O4BnH.js.gz +0 -0
  30. package/dist/dashboard/assets/{SettingsPage-CETophpx.js → SettingsPage-CDZjTt87.js} +1 -1
  31. package/dist/dashboard/assets/SettingsPage-CDZjTt87.js.gz +0 -0
  32. package/dist/dashboard/assets/SetupPage-BQS0Rx_u.js +1 -0
  33. package/dist/dashboard/assets/SetupPage-BQS0Rx_u.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-CAK-EN9z.js → SortableList-BAL5npBC.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-BAL5npBC.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-B7W2_RhT.js → StudioPage-TP7voAQ0.js} +3 -3
  37. package/dist/dashboard/assets/StudioPage-TP7voAQ0.js.gz +0 -0
  38. package/dist/dashboard/assets/{UsagePage-6T4EFDjW.js → UsagePage-Cj_fEzhg.js} +13 -13
  39. package/dist/dashboard/assets/UsagePage-Cj_fEzhg.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-CUi6UXsR.js → api-keys-MbI7e7rt.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-MbI7e7rt.js.gz +0 -0
  42. package/dist/dashboard/assets/app-B2vyPoII.css +2 -0
  43. package/dist/dashboard/assets/app-B2vyPoII.css.gz +0 -0
  44. package/dist/dashboard/assets/arrow-up-59ms1iiT.js +1 -0
  45. package/dist/dashboard/assets/arrow-up-59ms1iiT.js.gz +0 -0
  46. package/dist/dashboard/assets/{assert-67xXH5rf.js → assert-C0YYuZJ4.js} +1 -1
  47. package/dist/dashboard/assets/assert-C0YYuZJ4.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-DC2L43Mj.js → badge-B_DMpMJN.js} +1 -1
  49. package/dist/dashboard/assets/badge-B_DMpMJN.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-BBVcb0J3.js → banner-DMrMPmth.js} +1 -1
  51. package/dist/dashboard/assets/banner-DMrMPmth.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-Cca1XC6G.js → card-Be5Uzamx.js} +1 -1
  53. package/dist/dashboard/assets/card-Be5Uzamx.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-KvsWa1Jl.js → cli-tools-muFRphDu.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-muFRphDu.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-DUzEbynq.js → clipboard-button-DOfrVmsi.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-DOfrVmsi.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-DFvWhwTa.js → dialog-C9omdjQt.js} +1 -1
  59. package/dist/dashboard/assets/dialog-C9omdjQt.js.gz +0 -0
  60. package/dist/dashboard/assets/drawer-Bm8WKaGY.js +1 -0
  61. package/dist/dashboard/assets/drawer-Bm8WKaGY.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-_xHo5x6r.js → entry-DS90gUks.js} +1 -1
  63. package/dist/dashboard/assets/entry-DS90gUks.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-BLBLxX0h.js → entry-j40LJotO.js} +3 -3
  65. package/dist/dashboard/assets/entry-j40LJotO.js.gz +0 -0
  66. package/dist/dashboard/assets/{entry-Bqy8RFvM.js → entry-q5HwZ8nN.js} +1 -1
  67. package/dist/dashboard/assets/entry-q5HwZ8nN.js.gz +0 -0
  68. package/dist/dashboard/assets/{concurrency-BDF0ok7y.js → fingerprint-pattern-BWuHtbW4.js} +1 -1
  69. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  70. package/dist/dashboard/assets/{formatters-mpBPqAQ8.js → formatters-BTScrQpe.js} +1 -1
  71. package/dist/dashboard/assets/formatters-BTScrQpe.js.gz +0 -0
  72. package/dist/dashboard/assets/{github-badge-C9e8Ts7e.js → github-badge-Orjuzemr.js} +1 -1
  73. package/dist/dashboard/assets/github-badge-Orjuzemr.js.gz +0 -0
  74. package/dist/dashboard/assets/{index-D6QzEC_n.js → index-cPrX5vn6.js} +2 -2
  75. package/dist/dashboard/assets/index-cPrX5vn6.js.gz +0 -0
  76. package/dist/dashboard/assets/{inline-BFUcvllj.js → inline-Dvux8w-0.js} +1 -1
  77. package/dist/dashboard/assets/inline-Dvux8w-0.js.gz +0 -0
  78. package/dist/dashboard/assets/{input-C_ILAvBb.js → input-CU1Rgf3p.js} +1 -1
  79. package/dist/dashboard/assets/input-CU1Rgf3p.js.gz +0 -0
  80. package/dist/dashboard/assets/{layout-CBIyT6Rt.js → layout-C_7A6H55.js} +1 -1
  81. package/dist/dashboard/assets/layout-C_7A6H55.js.gz +0 -0
  82. package/dist/dashboard/assets/{list-row-ebJ_-Nsb.js → list-row-CzFvogbj.js} +1 -1
  83. package/dist/dashboard/assets/list-row-CzFvogbj.js.gz +0 -0
  84. package/dist/dashboard/assets/{live-D0mm--ME.js → live-KaG15HLQ.js} +1 -1
  85. package/dist/dashboard/assets/live-KaG15HLQ.js.gz +0 -0
  86. package/dist/dashboard/assets/{mutation-sMD965oO.js → mutation-JJGQm8TD.js} +1 -1
  87. package/dist/dashboard/assets/mutation-JJGQm8TD.js.gz +0 -0
  88. package/dist/dashboard/assets/{numbers-BO9dKaFr.js → numbers-BNDZnsUE.js} +1 -1
  89. package/dist/dashboard/assets/numbers-BNDZnsUE.js.gz +0 -0
  90. package/dist/dashboard/assets/{query-keys-CfL_Zdoh.js → query-keys-CLaNz8Xz.js} +1 -1
  91. package/dist/dashboard/assets/query-keys-CLaNz8Xz.js.gz +0 -0
  92. package/dist/dashboard/assets/{routing-BnIpi1IJ.js → routing-BEH32QOC.js} +1 -1
  93. package/dist/dashboard/assets/routing-BEH32QOC.js.gz +0 -0
  94. package/dist/dashboard/assets/{select-Cj5Nw43g.js → select-B3iTetHN.js} +1 -1
  95. package/dist/dashboard/assets/select-B3iTetHN.js.gz +0 -0
  96. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js +1 -0
  97. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js.gz +0 -0
  98. package/dist/dashboard/assets/{settings-BAFhDgX8.js → settings-Dt3Eqbnq.js} +1 -1
  99. package/dist/dashboard/assets/settings-Dt3Eqbnq.js.gz +0 -0
  100. package/dist/dashboard/assets/{stack-EMrOMt4I.js → stack-KrOrm7JZ.js} +1 -1
  101. package/dist/dashboard/assets/stack-KrOrm7JZ.js.gz +0 -0
  102. package/dist/dashboard/assets/{state-CWoo7ty5.js → state-DLS7IDMy.js} +1 -1
  103. package/dist/dashboard/assets/state-DLS7IDMy.js.gz +0 -0
  104. package/dist/dashboard/assets/{switch-CCjT6IkF.js → switch-42ItvbAs.js} +1 -1
  105. package/dist/dashboard/assets/switch-42ItvbAs.js.gz +0 -0
  106. package/dist/dashboard/assets/{useMutation-DjkLHUcE.js → useMutation-Dmnfh1Ue.js} +1 -1
  107. package/dist/dashboard/assets/useMutation-Dmnfh1Ue.js.gz +0 -0
  108. package/dist/dashboard/index.html +3 -2
  109. package/dist/dashboard/index.html.gz +0 -0
  110. package/dist/dashboard/precompressed-assets.json +117 -115
  111. package/migrations/full-postgres/0026_add_qoder_native_identity.sql +20 -0
  112. package/migrations/full-postgres/0027_qoder_identity_genuine_seed.sql +22 -0
  113. package/migrations/full-postgres/0028_add_family_template_key_telemetry.sql +40 -0
  114. package/migrations/full-postgres/0029_null_share_child_policy_columns.sql +38 -0
  115. package/migrations/full-postgres/0030_add_api_key_max_children.sql +10 -0
  116. package/migrations/full-postgres/0031_tenant_system_prompts.sql +16 -0
  117. package/migrations/lite-sqlite3/0026_add_qoder_native_identity.sql +15 -0
  118. package/migrations/lite-sqlite3/0027_qoder_identity_genuine_seed.sql +22 -0
  119. package/migrations/lite-sqlite3/0028_add_family_template_key_telemetry.sql +40 -0
  120. package/migrations/lite-sqlite3/0029_null_share_child_policy_columns.sql +38 -0
  121. package/migrations/lite-sqlite3/0030_add_api_key_max_children.sql +10 -0
  122. package/migrations/lite-sqlite3/0031_tenant_system_prompts.sql +10 -0
  123. package/package.json +1 -1
  124. package/src/config/network.ts +1 -1
  125. package/src/console/api-keys/bucket-spend.ts +7 -7
  126. package/src/console/api-keys/contracts.ts +423 -410
  127. package/src/console/api-keys/routes.ts +118 -19
  128. package/src/console/backup/export-endpoint.ts +8 -11
  129. package/src/console/backup/routes.ts +40 -8
  130. package/src/console/backup/service.ts +11 -6
  131. package/src/console/backup/store.ts +384 -88
  132. package/src/console/cli-tools/contracts.ts +10 -4
  133. package/src/console/dashboard-assets.ts +16 -1
  134. package/src/console/domain-registration.ts +2 -0
  135. package/src/console/quota/account-quota-tenant.ts +8 -1
  136. package/src/console/quota/account-quota.ts +4 -1
  137. package/src/console/quota/native-identity.ts +189 -0
  138. package/src/console/remote-support/contracts.ts +12 -6
  139. package/src/console/remote-support/service.ts +2 -2
  140. package/src/console/share/share-router.ts +39 -6
  141. package/src/console/share/share-stats.ts +64 -25
  142. package/src/console/share/share-usage.ts +24 -7
  143. package/src/console/system-prompt/contracts.ts +102 -0
  144. package/src/console/system-prompt/routes.ts +165 -0
  145. package/src/main.ts +11 -0
  146. package/src/network/outbound-fetch.ts +281 -226
  147. package/src/network/pool/agent.ts +7 -0
  148. package/src/observability/event-query.ts +118 -7
  149. package/src/persistence/db-pg.ts +42 -0
  150. package/src/persistence/schema.ts +102 -0
  151. package/src/persistence/stores/api-key-store.ts +753 -581
  152. package/src/persistence/stores/share-store.ts +65 -13
  153. package/src/persistence/stores/telemetry-store.ts +57 -1
  154. package/src/persistence/system-prompt-store.ts +104 -0
  155. package/src/protocol/reasoning.ts +65 -1
  156. package/src/protocol/request/chat.ts +1 -0
  157. package/src/providers/default-registry.ts +1 -0
  158. package/src/providers/integrations/antigravity/antigravity.ts +20 -23
  159. package/src/providers/integrations/buddy/buddy-chat-shared.ts +30 -2
  160. package/src/providers/integrations/buddy/buddy-media.ts +5 -1
  161. package/src/providers/integrations/buddy/codebuddy/provider.ts +5 -1
  162. package/src/providers/integrations/buddy/workbuddy/provider.ts +5 -1
  163. package/src/providers/integrations/cline/cline.ts +7 -0
  164. package/src/providers/integrations/github/github.ts +20 -1
  165. package/src/providers/integrations/hermes.ts +5 -1
  166. package/src/providers/integrations/kilo/kilo.ts +4 -0
  167. package/src/providers/integrations/ollamacloud-models.ts +50 -0
  168. package/src/providers/integrations/openrouter/provider.ts +4 -0
  169. package/src/providers/integrations/qoder/qoder-checkin.ts +224 -51
  170. package/src/providers/integrations/qoder/qoder-native-identity-store.ts +156 -0
  171. package/src/providers/integrations/qoder/qoder-oauth.ts +24 -4
  172. package/src/providers/integrations/qoder/qoder-realm.ts +20 -0
  173. package/src/providers/integrations/qoder/qoder-text-tools.ts +277 -0
  174. package/src/providers/integrations/qoder/qoder-umid.ts +288 -0
  175. package/src/providers/integrations/qoder/qoder.ts +1375 -1641
  176. package/src/providers/operations/account-error-classification.ts +1682 -1660
  177. package/src/providers/operations/account-health-service.ts +619 -621
  178. package/src/providers/operations/client-versions.ts +37 -22
  179. package/src/providers/operations/provider-catalog-service.ts +11 -1
  180. package/src/providers/provider-contracts.ts +15 -2
  181. package/src/security/access-control.ts +19 -0
  182. package/src/security/api-key-auth.ts +14 -1
  183. package/src/security/cli-client-fingerprint.ts +9 -2
  184. package/src/transport/dispatch/empty-stream.ts +224 -224
  185. package/src/transport/dispatch/proxy-request.ts +31 -2
  186. package/src/transport/dispatch/streaming/stream.ts +16 -1
  187. package/src/transport/dispatch/upstream.ts +10 -0
  188. package/src/transport/failure-policy.ts +33 -0
  189. package/src/transport/request/preparer.ts +17 -1
  190. package/src/transport/request/system-prompt-injection.ts +89 -0
  191. package/src/transport/request/tenant-preferences.ts +43 -0
  192. package/src/transport/routing/admission.ts +13 -3
  193. package/src/transport/routing/route-catalog.ts +41 -12
  194. package/src/transport/routing/route-model.ts +53 -0
  195. package/src/transport/routing/router.ts +306 -228
  196. package/src/transport/surface/responses/encode.ts +2 -3
  197. package/src/transport/surface/responses/parse.ts +49 -7
  198. package/src/types/undici.d.ts +33 -0
  199. package/src/workers/daily-checkin.ts +2 -0
  200. package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
  201. package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js +0 -1
  202. package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
  203. package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js +0 -1
  204. package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
  205. package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
  206. package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
  207. package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js +0 -1
  208. package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
  209. package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js +0 -1
  210. package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
  211. package/dist/dashboard/assets/LoginPage-DubdQcvX.js.gz +0 -0
  212. package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
  213. package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js +0 -1
  214. package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
  215. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +0 -4
  216. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js.gz +0 -0
  217. package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js +0 -1
  218. package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
  219. package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js +0 -9
  220. package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js.gz +0 -0
  221. package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
  222. package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
  223. package/dist/dashboard/assets/SetupPage-BdgECjqq.js +0 -1
  224. package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
  225. package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
  226. package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
  227. package/dist/dashboard/assets/UsagePage-6T4EFDjW.js.gz +0 -0
  228. package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
  229. package/dist/dashboard/assets/app-KvsUJUMF.css +0 -2
  230. package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
  231. package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
  232. package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
  233. package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
  234. package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
  235. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
  236. package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
  237. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  238. package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
  239. package/dist/dashboard/assets/drawer-CFM2sMX1.js +0 -1
  240. package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
  241. package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
  242. package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
  243. package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
  244. package/dist/dashboard/assets/formatters-mpBPqAQ8.js.gz +0 -0
  245. package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
  246. package/dist/dashboard/assets/index-D6QzEC_n.js.gz +0 -0
  247. package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
  248. package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
  249. package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
  250. package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
  251. package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
  252. package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
  253. package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
  254. package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
  255. package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
  256. package/dist/dashboard/assets/select-Cj5Nw43g.js.gz +0 -0
  257. package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
  258. package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
  259. package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
  260. package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
  261. package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
  262. /package/dist/dashboard/assets/{app-BaWd0puM.js → app-wvwCi4Eh.js} +0 -0
  263. /package/dist/dashboard/assets/{app-BaWd0puM.js.gz → app-wvwCi4Eh.js.gz} +0 -0
@@ -1,410 +1,423 @@
1
- // Console API-key domain contracts: request/response shapes and pure helpers.
2
- //
3
- // Single source for key creation validation and response sanitization;
4
- // routes consume it, they do not redeclare it.
5
-
6
- import type { AuditSink } from "../audit/contracts";
7
- import { randomBytes } from "node:crypto";
8
- import type { ApiKeyRecord, ApiKeyStore } from "../../persistence/stores/api-key-store";
9
- import { hashSecret } from "../../security/crypto";
10
- import { type ShareLinkStore, type ShareLinkSummary } from "../../persistence/stores/share-store";
11
- import { API_KEY_MODES, type ApiKeyMode, type ApiKeyModelAccessMode, type ShareLinkKind } from "../../persistence/schema";
12
- import { isValidTenantKeyScope, type AccessScope } from "../../security/access-control";
13
- import { normalizeClientRouterId } from "../../security/client-router-fingerprint";
14
- import type { ShareActivityPort } from "../share/share-usage";
15
- import { ConsoleDomainError } from "../shared/errors";
16
- import type { ConsoleAccessResolver } from "../auth/access";
17
- import type { ApiKeyAdmissionService } from "../../security/admission/service";
18
- import {
19
- SHARE_POPUP_IMAGE_MAX_BYTES,
20
- SHARE_POPUP_IMAGE_MIMES,
21
- sniffSharePopupImageMime,
22
- type SharePopupImageMime,
23
- } from "./share-popup-image";
24
-
25
- // Re-exported so consumers reach the image bounds from this contract module.
26
- // `share-popup-image.ts` is the pure authority; it stays import-free so the
27
- // dashboard can read the same bounds its upload control enforces.
28
- export {
29
- SHARE_POPUP_IMAGE_MAX_BYTES,
30
- SHARE_POPUP_IMAGE_MIMES,
31
- type SharePopupImageMime,
32
- } from "./share-popup-image";
33
- export type {
34
- SharedKeySummary,
35
- SharedKeyActivityDetail,
36
- } from "../share/share-usage";
37
- /** Input accepted when creating or editing a key. Null limits mean unlimited. */
38
- export interface CreateApiKeyRequest {
39
- enabled?: boolean;
40
- label?: string;
41
- keyMode?: ApiKeyMode;
42
- scopes?: readonly string[];
43
- /** Public leading fragment used for generated keys; defaults to the product prefix. */
44
- keyPrefix?: string;
45
- /** Owner-supplied raw key value; valid only for personal keys. */
46
- key?: string;
47
- requestsPerMinute?: number | null;
48
- dailyTokenLimit?: number | null;
49
- monthlyTokenLimit?: number | null;
50
- lifetimeTokenBudget?: number | null;
51
- maxConcurrentRequests?: number | null;
52
- modelPrefix?: string;
53
- modelAccessMode?: ApiKeyModelAccessMode;
54
- modelList?: readonly string[];
55
- /** Client-router ids this key refuses; see `client-router-fingerprint.ts`. */
56
- clientRouterDenylist?: readonly string[];
57
- notesTitle?: string;
58
- notesSubtitle?: string;
59
- notesBody?: string;
60
- sharePopupEnabled?: boolean | null;
61
- /**
62
- * Uploaded popup art as a `data:<mime>;base64,…` URL. `null` clears it and
63
- * omitting it leaves the stored image alone. There is no remote-URL form: a
64
- * hotlink leaked each visitor's IP to a third party and rotted on host moves.
65
- */
66
- sharePopupImage?: string | null;
67
- sharePopupTitle?: string;
68
- sharePopupBody?: string;
69
- }
70
-
71
- /** Public key representation; it never contains a secret, hash, or IP-key digest. */
72
- export interface ApiKeyResponse {
73
- readonly id: string;
74
- readonly label: string;
75
- readonly enabled: boolean;
76
- readonly keyMode: ApiKeyMode;
77
- readonly scopes: readonly AccessScope[];
78
- /** The tenant's mandatory gateway key: rotatable, never revocable. */
79
- readonly isDefault: boolean;
80
- readonly keyPrefix?: string;
81
- readonly parentKeyId?: string;
82
- readonly requestsPerMinute?: number;
83
- readonly dailyTokenLimit?: number;
84
- readonly monthlyTokenLimit?: number;
85
- readonly lifetimeTokenBudget?: number;
86
- readonly modelPrefix?: string;
87
- readonly modelAccessMode?: ApiKeyModelAccessMode;
88
- readonly modelList?: readonly string[];
89
- readonly clientRouterDenylist?: readonly string[];
90
- readonly maxConcurrentRequests?: number;
91
- readonly notesTitle?: string;
92
- readonly notesSubtitle?: string;
93
- readonly notesBody?: string;
94
- readonly sharePopupEnabled?: boolean;
95
- /** Mime of the stored popup art; its bytes are served by a dedicated route. */
96
- readonly sharePopupImageMime?: string;
97
- readonly sharePopupTitle?: string;
98
- readonly sharePopupBody?: string;
99
- readonly createdAt: string;
100
- readonly revokedAt?: string;
101
- readonly tokensConsumed: number;
102
- }
103
- /** Creation result; plaintext is returned only when a personal key is minted. */
104
- export interface CreateApiKeyResponse extends ApiKeyResponse {
105
- readonly secret?: string;
106
- }
107
-
108
- /** Edit result carries one new secret on personal rotation or template conversion. */
109
- export interface UpdateApiKeyResponse extends ApiKeyResponse {
110
- readonly secret?: string;
111
- }
112
-
113
- /** API-key route dependency boundary. */
114
- export interface ApiKeyConfig {
115
- readonly store: ApiKeyStore;
116
- readonly accessResolver: ConsoleAccessResolver;
117
- readonly auditSink?: AuditSink;
118
- readonly shareStore?: ShareLinkStore;
119
- readonly shareActivity?: ShareActivityPort;
120
- readonly admissionService: Pick<ApiKeyAdmissionService, "purgeKey"> & {
121
- seedBuckets?(request: {
122
- readonly apiKeyId: string;
123
- readonly now?: number;
124
- readonly daily?: number;
125
- readonly monthly?: number;
126
- }): Promise<void>;
127
- };
128
- /**
129
- * Reads tokens already spent in the current daily/monthly bucket, so a
130
- * newly-added limit is enforced against the whole bucket. Optional: without
131
- * it a new limit simply starts counting from the moment it was set.
132
- */
133
- readonly bucketSpend?: (
134
- keyId: string,
135
- now: Date,
136
- ) => Promise<{ daily: number; monthly: number }>;
137
- }
138
-
139
- /** Result of minting a share link. The bearer token is returned exactly once. */
140
- export interface ShareKeyResponse {
141
- readonly id: string;
142
- readonly url: string;
143
- /**
144
- * The bearer half of the link. Null when the row was written under an
145
- * encryption key this deployment no longer holds: the page stays reachable
146
- * from `url`, but the secret cannot be shown again and must be regenerated
147
- * before the link can authenticate.
148
- */
149
- readonly token: string | null;
150
- readonly kind: ShareLinkKind;
151
- readonly expiresAt: string | null;
152
- }
153
-
154
- /** One share link as shown in the owner's console list. Never carries the token. */
155
- export interface ShareLinkResponse {
156
- readonly id: string;
157
- readonly kind: ShareLinkKind;
158
- readonly active: boolean;
159
- readonly createdAt: string;
160
- readonly expiresAt: string | null;
161
- readonly lastViewedAt: string | null;
162
- }
163
-
164
- /**
165
- * Default prefix for newly generated API keys.
166
- *
167
- * The separator is a hyphen, not an underscore, and the random tail is a fixed
168
- * 16 characters: `pn-carte-` for personal keys, `sh-carte-` for the ones a
169
- * share template issues. Both are metadata shown in the console — nothing
170
- * looks a key up by its prefix — so changing these does not invalidate rows
171
- * already stored, which keep the prefix they were issued with.
172
- */
173
- export const DEFAULT_API_KEY_PREFIX = "pn-carte-";
174
- /** Default prefix used by share templates and the keys they issue. */
175
- export const DEFAULT_SHARE_API_KEY_PREFIX = "sh-carte-";
176
-
177
- /**
178
- * Bytes of entropy behind the random tail.
179
- *
180
- * 12 bytes base64url-encodes to exactly 16 characters with no padding, which
181
- * is the whole reason for the number: the length in the constants above and
182
- * the length a generated key actually has cannot disagree.
183
- */
184
- const KEY_SECRET_BYTES = 12;
185
-
186
- /** Label of the default gateway API key seeded at first boot; never revoked. */
187
- export const DEFAULT_API_KEY_LABEL = "Default Cartethyia API key";
188
-
189
- /** Recognizes the default gateway API key by its persisted flag. */
190
- export function isDefaultGatewayApiKey(
191
- record: Pick<ApiKeyRecord, "isDefault">,
192
- ): boolean {
193
- return record.isDefault;
194
- }
195
-
196
- /** Generates an inbound key and its one-way hash. */
197
- export function generateApiKeySecret(prefix = DEFAULT_API_KEY_PREFIX): {
198
- secret: string;
199
- hash: string;
200
- prefix: string;
201
- } {
202
- const secret = `${prefix}${randomBytes(KEY_SECRET_BYTES).toString("base64url")}`;
203
- return { secret, hash: hashSecret(secret), prefix };
204
- }
205
-
206
- /** Normalizes a caller-supplied prefix; falls back to the default. */
207
- export function resolveKeyPrefix(raw: string | undefined): string {
208
- const trimmed = raw?.trim();
209
- return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_API_KEY_PREFIX;
210
- }
211
-
212
- /** Resolves the share-key prefix; falls back to the default. */
213
- export function resolveShareKeyPrefix(raw: string | undefined): string {
214
- const trimmed = raw?.trim();
215
- return !trimmed ? DEFAULT_SHARE_API_KEY_PREFIX : trimmed;
216
- }
217
-
218
- /** Maximum hint the enroll page accepts; the dashboard caps its input there. */
219
- export const SHARED_CHILD_HINT_MAX_LENGTH = 20;
220
-
221
- /** Hashes an owner-supplied personal key; persistence encrypts it for CLI handoff. */
222
- export function prepareCustomKey(secret: string): { secret: string; hash: string } {
223
- if (secret.trim().length === 0) {
224
- throw new ConsoleDomainError("invalid_key", 400, "Custom key must be a non-empty string");
225
- }
226
- return { secret, hash: hashSecret(secret) };
227
- }
228
-
229
- export function finitePositive(value: number | null | undefined, name: string): void {
230
- // `null` explicitly clears a previously set limit; `undefined` leaves it
231
- // unchanged. Only a real non-null number must be finite and positive.
232
- if (value !== undefined && value !== null && (!Number.isFinite(value) || value < 1)) {
233
- throw new ConsoleDomainError("invalid_limits", 400, `${name} must be a finite positive number`);
234
- }
235
- }
236
-
237
- /**
238
- * Decodes an uploaded popup image into the bytes and mime to persist.
239
- *
240
- * Returns `null` for absent/blank input (nothing to store). Throws
241
- * `invalid_share_popup` for a non-data URL, an oversized payload, or bytes that
242
- * are not one of the four raster formats the share page may safely serve.
243
- */
244
- export function parseSharePopupImage(
245
- value: string | null | undefined,
246
- ): { image: Buffer; mime: SharePopupImageMime } | null {
247
- if (value === null || value === undefined) return null;
248
- const raw = value.trim();
249
- if (raw.length === 0) return null;
250
- const match =
251
- /^data:([a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+);base64,([A-Za-z0-9+/=]+)$/i.exec(raw);
252
- const declared = match?.[1]?.toLowerCase();
253
- const payload = match?.[2];
254
- if (!payload || declared === undefined || !(SHARE_POPUP_IMAGE_MIMES as readonly string[]).includes(declared)) {
255
- throw new ConsoleDomainError(
256
- "invalid_share_popup",
257
- 400,
258
- "sharePopupImage must be an uploaded PNG, JPEG, WebP, or GIF file",
259
- );
260
- }
261
- const image = Buffer.from(payload, "base64");
262
- if (image.length === 0 || image.length > SHARE_POPUP_IMAGE_MAX_BYTES) {
263
- throw new ConsoleDomainError(
264
- "invalid_share_popup",
265
- 400,
266
- `sharePopupImage must be at most ${Math.floor(SHARE_POPUP_IMAGE_MAX_BYTES / (1024 * 1024))} MB`,
267
- );
268
- }
269
- const mime = sniffSharePopupImageMime(image);
270
- if (mime === undefined || mime !== declared) {
271
- throw new ConsoleDomainError(
272
- "invalid_share_popup",
273
- 400,
274
- "sharePopupImage bytes do not match a supported image format",
275
- );
276
- }
277
- return { image, mime };
278
- }
279
-
280
- /** Validates scopes and quota fields before persistence. */
281
- export function validateApiKeyRequest(request: CreateApiKeyRequest): readonly AccessScope[] {
282
- const keyMode = request.keyMode ?? "personal";
283
- if (!(API_KEY_MODES as readonly string[]).includes(keyMode)) {
284
- throw new ConsoleDomainError("invalid_key_mode", 400, "keyMode must be personal or share");
285
- }
286
- if (keyMode === "share" && request.key !== undefined) {
287
- throw new ConsoleDomainError("invalid_key_mode", 400, "Share templates cannot carry a personal key");
288
- }
289
- const scopes = request.scopes ?? ["routing:invoke", "search:invoke"];
290
- for (const scope of scopes) {
291
- if (!isValidTenantKeyScope(scope as AccessScope)) {
292
- throw new ConsoleDomainError("invalid_scope", 400, `Scope is not allowed: ${scope}`);
293
- }
294
- }
295
- finitePositive(request.requestsPerMinute, "requestsPerMinute");
296
- finitePositive(request.dailyTokenLimit, "dailyTokenLimit");
297
- finitePositive(request.monthlyTokenLimit, "monthlyTokenLimit");
298
- finitePositive(request.lifetimeTokenBudget, "lifetimeTokenBudget");
299
- finitePositive(request.maxConcurrentRequests, "maxConcurrentRequests");
300
- parseSharePopupImage(request.sharePopupImage);
301
- if (request.sharePopupTitle != null && request.sharePopupTitle.length > 120) {
302
- throw new ConsoleDomainError("invalid_share_popup", 400, "Share popup title is too long");
303
- }
304
- if (request.sharePopupBody != null && request.sharePopupBody.length > 1200) {
305
- throw new ConsoleDomainError("invalid_share_popup", 400, "Share popup body is too long");
306
- }
307
- if (
308
- request.modelPrefix !== undefined &&
309
- (typeof request.modelPrefix !== "string" || request.modelPrefix.trim().length === 0)
310
- ) {
311
- throw new ConsoleDomainError(
312
- "invalid_model_prefix",
313
- 400,
314
- "modelPrefix must be a non-empty string if provided",
315
- );
316
- }
317
- if (
318
- request.keyPrefix !== undefined &&
319
- (typeof request.keyPrefix !== "string" || request.keyPrefix.trim().length === 0)
320
- ) {
321
- throw new ConsoleDomainError(
322
- "invalid_key_prefix",
323
- 400,
324
- "keyPrefix must be a non-empty string if provided",
325
- );
326
- }
327
- if (request.clientRouterDenylist !== undefined) {
328
- if (!Array.isArray(request.clientRouterDenylist)) {
329
- throw new ConsoleDomainError(
330
- "invalid_client_router_denylist",
331
- 400,
332
- "clientRouterDenylist must be an array of client-router ids",
333
- );
334
- }
335
- // An unrecognised id would persist a rule that can never match — the operator
336
- // would believe a router is blocked while nothing blocks it. Rejecting it is
337
- // the only outcome that keeps the stored policy honest.
338
- for (const id of request.clientRouterDenylist) {
339
- if (typeof id !== "string" || normalizeClientRouterId(id) === undefined) {
340
- throw new ConsoleDomainError(
341
- "invalid_client_router_denylist",
342
- 400,
343
- `Unknown client router: ${String(id)}`,
344
- );
345
- }
346
- }
347
- }
348
- return scopes as AccessScope[];
349
- }
350
-
351
- /** Serializes a persisted record without credential material or IP-key digests. */
352
- export function sanitizeApiKeyResponse(record: ApiKeyRecord): ApiKeyResponse {
353
- const keyPrefix =
354
- record.keyMode === "share" ? resolveShareKeyPrefix(record.keyPrefix) : record.keyPrefix;
355
- return {
356
- id: record.id,
357
- label: record.label,
358
- enabled: record.enabled,
359
- keyMode: record.keyMode,
360
- scopes: record.scopes,
361
- /**
362
- * The persisted flag alone decides this: the label is display text, so
363
- * renaming the key cannot change which key is the default.
364
- */
365
- isDefault: isDefaultGatewayApiKey(record),
366
- ...(keyPrefix === undefined ? {} : { keyPrefix }),
367
- ...(record.parentKeyId === undefined ? {} : { parentKeyId: record.parentKeyId }),
368
- ...(record.notesTitle === undefined ? {} : { notesTitle: record.notesTitle }),
369
- ...(record.notesSubtitle === undefined ? {} : { notesSubtitle: record.notesSubtitle }),
370
- ...(record.notesBody === undefined ? {} : { notesBody: record.notesBody }),
371
- ...(record.sharePopupEnabled === undefined ? {} : { sharePopupEnabled: record.sharePopupEnabled }),
372
- ...(record.sharePopupImageMime === undefined
373
- ? {}
374
- : { sharePopupImageMime: record.sharePopupImageMime }),
375
- ...(record.sharePopupTitle === undefined ? {} : { sharePopupTitle: record.sharePopupTitle }),
376
- ...(record.sharePopupBody === undefined ? {} : { sharePopupBody: record.sharePopupBody }),
377
- ...(record.requestsPerMinute === undefined ? {} : { requestsPerMinute: record.requestsPerMinute }),
378
- ...(record.dailyTokenLimit === undefined ? {} : { dailyTokenLimit: record.dailyTokenLimit }),
379
- ...(record.monthlyTokenLimit === undefined ? {} : { monthlyTokenLimit: record.monthlyTokenLimit }),
380
- ...(record.lifetimeTokenBudget === undefined ? {} : { lifetimeTokenBudget: record.lifetimeTokenBudget }),
381
- ...(record.maxConcurrentRequests === undefined ? {} : { maxConcurrentRequests: record.maxConcurrentRequests }),
382
- ...(record.modelPrefix === undefined ? {} : { modelPrefix: record.modelPrefix }),
383
- ...(record.modelAccessMode === undefined ? {} : { modelAccessMode: record.modelAccessMode }),
384
- ...(record.modelList === undefined ? {} : { modelList: record.modelList }),
385
- ...(record.clientRouterDenylist === undefined
386
- ? {}
387
- : { clientRouterDenylist: record.clientRouterDenylist }),
388
- createdAt: record.createdAt.toISOString(),
389
- ...(record.revokedAt === undefined ? {} : { revokedAt: record.revokedAt.toISOString() }),
390
- tokensConsumed: record.tokensConsumed,
391
- };
392
- }
393
-
394
- export function parseRequest(value: unknown): CreateApiKeyRequest {
395
- if (typeof value !== "object" || value === null)
396
- throw new ConsoleDomainError("invalid_request", 400, "Request body must be an object");
397
- return value as CreateApiKeyRequest;
398
- }
399
-
400
- /** Maps a persisted share link to the console response (ISO timestamps, no token). */
401
- export function mapShareLinkResponse(link: ShareLinkSummary): ShareLinkResponse {
402
- return {
403
- id: link.id,
404
- kind: link.kind,
405
- active: link.active,
406
- createdAt: link.createdAt.toISOString(),
407
- expiresAt: link.expiresAt === null ? null : link.expiresAt.toISOString(),
408
- lastViewedAt: link.lastViewedAt === null ? null : link.lastViewedAt.toISOString(),
409
- };
410
- }
1
+ // Console API-key domain contracts: request/response shapes and pure helpers.
2
+ //
3
+ // Single source for key creation validation and response sanitization;
4
+ // routes consume it, they do not redeclare it.
5
+
6
+ import type { AuditSink } from "../audit/contracts";
7
+ import { randomBytes } from "node:crypto";
8
+ import type { ApiKeyRecord, ApiKeyStore } from "../../persistence/stores/api-key-store";
9
+ import { hashSecret } from "../../security/crypto";
10
+ import { type ShareLinkStore, type ShareLinkSummary } from "../../persistence/stores/share-store";
11
+ import { API_KEY_MODES, type ApiKeyMode, type ApiKeyModelAccessMode, type ShareLinkKind } from "../../persistence/schema";
12
+ import { isValidTenantKeyScope, type AccessScope } from "../../security/access-control";
13
+ import { normalizeClientRouterId } from "../../security/client-router-fingerprint";
14
+ import type { ShareActivityPort } from "../share/share-usage";
15
+ import { ConsoleDomainError } from "../shared/errors";
16
+ import type { ConsoleAccessResolver } from "../auth/access";
17
+ import type { ApiKeyAdmissionService } from "../../security/admission/service";
18
+ import {
19
+ SHARE_POPUP_IMAGE_MAX_BYTES,
20
+ SHARE_POPUP_IMAGE_MIMES,
21
+ sniffSharePopupImageMime,
22
+ type SharePopupImageMime,
23
+ } from "./share-popup-image";
24
+
25
+ // Re-exported so consumers reach the image bounds from this contract module.
26
+ // `share-popup-image.ts` is the pure authority; it stays import-free so the
27
+ // dashboard can read the same bounds its upload control enforces.
28
+ export {
29
+ SHARE_POPUP_IMAGE_MAX_BYTES,
30
+ SHARE_POPUP_IMAGE_MIMES,
31
+ type SharePopupImageMime,
32
+ } from "./share-popup-image";
33
+ export type {
34
+ SharedKeySummary,
35
+ SharedKeyActivityDetail,
36
+ } from "../share/share-usage";
37
+ /** Input accepted when creating or editing a key. Null limits mean unlimited. */
38
+ export interface CreateApiKeyRequest {
39
+ enabled?: boolean;
40
+ label?: string;
41
+ keyMode?: ApiKeyMode;
42
+ scopes?: readonly string[];
43
+ /** Public leading fragment used for generated keys; defaults to the product prefix. */
44
+ keyPrefix?: string;
45
+ /** Owner-supplied raw key value; valid only for personal keys. */
46
+ key?: string;
47
+ requestsPerMinute?: number | null;
48
+ dailyTokenLimit?: number | null;
49
+ monthlyTokenLimit?: number | null;
50
+ lifetimeTokenBudget?: number | null;
51
+ maxConcurrentRequests?: number | null;
52
+ /** Share-template slot cap; only meaningful when keyMode === "share". */
53
+ maxChildren?: number | null;
54
+ modelPrefix?: string;
55
+ modelAccessMode?: ApiKeyModelAccessMode;
56
+ modelList?: readonly string[];
57
+ /** Client-router ids this key refuses; see `client-router-fingerprint.ts`. */
58
+ clientRouterDenylist?: readonly string[];
59
+ notesTitle?: string;
60
+ notesSubtitle?: string;
61
+ notesBody?: string;
62
+ sharePopupEnabled?: boolean | null;
63
+ /**
64
+ * Uploaded popup art as a `data:<mime>;base64,…` URL. `null` clears it and
65
+ * omitting it leaves the stored image alone. There is no remote-URL form: a
66
+ * hotlink leaked each visitor's IP to a third party and rotted on host moves.
67
+ */
68
+ sharePopupImage?: string | null;
69
+ sharePopupTitle?: string;
70
+ sharePopupBody?: string;
71
+ }
72
+
73
+ /** Public key representation; it never contains a secret, hash, or IP-key digest. */
74
+ export interface ApiKeyResponse {
75
+ readonly id: string;
76
+ readonly label: string;
77
+ readonly enabled: boolean;
78
+ readonly keyMode: ApiKeyMode;
79
+ readonly scopes: readonly AccessScope[];
80
+ /** The tenant's mandatory gateway key: rotatable, never revocable. */
81
+ readonly isDefault: boolean;
82
+ readonly keyPrefix?: string;
83
+ readonly parentKeyId?: string;
84
+ readonly requestsPerMinute?: number;
85
+ readonly dailyTokenLimit?: number;
86
+ readonly monthlyTokenLimit?: number;
87
+ readonly lifetimeTokenBudget?: number;
88
+ readonly modelPrefix?: string;
89
+ readonly modelAccessMode?: ApiKeyModelAccessMode;
90
+ readonly modelList?: readonly string[];
91
+ readonly clientRouterDenylist?: readonly string[];
92
+ readonly maxConcurrentRequests?: number;
93
+ readonly maxChildren?: number;
94
+ readonly notesTitle?: string;
95
+ readonly notesSubtitle?: string;
96
+ readonly notesBody?: string;
97
+ readonly sharePopupEnabled?: boolean;
98
+ /** Mime of the stored popup art; its bytes are served by a dedicated route. */
99
+ readonly sharePopupImageMime?: string;
100
+ readonly sharePopupTitle?: string;
101
+ readonly sharePopupBody?: string;
102
+ readonly createdAt: string;
103
+ readonly revokedAt?: string;
104
+ readonly tokensConsumed: number;
105
+ }
106
+ /** Creation result; plaintext is returned only when a personal key is minted. */
107
+ export interface CreateApiKeyResponse extends ApiKeyResponse {
108
+ readonly secret?: string;
109
+ }
110
+
111
+ /** Edit result carries one new secret on personal rotation or template conversion. */
112
+ export interface UpdateApiKeyResponse extends ApiKeyResponse {
113
+ readonly secret?: string;
114
+ }
115
+
116
+ /** API-key route dependency boundary. */
117
+ export interface ApiKeyConfig {
118
+ readonly store: ApiKeyStore;
119
+ readonly accessResolver: ConsoleAccessResolver;
120
+ readonly auditSink?: AuditSink;
121
+ readonly shareStore?: ShareLinkStore;
122
+ readonly shareActivity?: ShareActivityPort;
123
+ readonly admissionService: Pick<ApiKeyAdmissionService, "purgeKey"> & {
124
+ seedBuckets?(request: {
125
+ readonly apiKeyId: string;
126
+ readonly now?: number;
127
+ readonly daily?: number;
128
+ readonly monthly?: number;
129
+ }): Promise<void>;
130
+ };
131
+ /**
132
+ * Reads tokens already spent in the current daily/monthly bucket, so a
133
+ * newly-added limit is enforced against the whole bucket. Optional: without
134
+ * it a new limit simply starts counting from the moment it was set.
135
+ */
136
+ readonly bucketSpend?: (
137
+ keyId: string,
138
+ now: Date,
139
+ ) => Promise<{ daily: number; monthly: number }>;
140
+ }
141
+
142
+ /** Result of minting a share link. The bearer token is returned exactly once. */
143
+ export interface ShareKeyResponse {
144
+ readonly id: string;
145
+ readonly url: string;
146
+ /**
147
+ * The bearer half of the link. Null when the row was written under an
148
+ * encryption key this deployment no longer holds: the page stays reachable
149
+ * from `url`, but the secret cannot be shown again and must be regenerated
150
+ * before the link can authenticate.
151
+ */
152
+ readonly token: string | null;
153
+ readonly kind: ShareLinkKind;
154
+ readonly expiresAt: string | null;
155
+ }
156
+
157
+ /** One share link as shown in the owner's console list. Never carries the token. */
158
+ export interface ShareLinkResponse {
159
+ readonly id: string;
160
+ readonly kind: ShareLinkKind;
161
+ readonly active: boolean;
162
+ readonly createdAt: string;
163
+ readonly expiresAt: string | null;
164
+ readonly lastViewedAt: string | null;
165
+ }
166
+
167
+ /**
168
+ * Default prefix for newly generated API keys.
169
+ *
170
+ * The separator is a hyphen, not an underscore, and the random tail is a fixed
171
+ * 16 characters: `pn-carte-` for personal keys, `sh-carte-` for the ones a
172
+ * share template issues. Both are metadata shown in the console — nothing
173
+ * looks a key up by its prefix — so changing these does not invalidate rows
174
+ * already stored, which keep the prefix they were issued with.
175
+ */
176
+ export const DEFAULT_API_KEY_PREFIX = "pn-carte-";
177
+ /** Default prefix used by share templates and the keys they issue. */
178
+ export const DEFAULT_SHARE_API_KEY_PREFIX = "sh-carte-";
179
+
180
+ /**
181
+ * Bytes of entropy behind the random tail.
182
+ *
183
+ * 12 bytes base64url-encodes to exactly 16 characters with no padding, which
184
+ * is the whole reason for the number: the length in the constants above and
185
+ * the length a generated key actually has cannot disagree.
186
+ */
187
+ const KEY_SECRET_BYTES = 12;
188
+
189
+ /** Label of the default gateway API key seeded at first boot; never revoked. */
190
+ export const DEFAULT_API_KEY_LABEL = "Default Cartethyia API key";
191
+
192
+ /** Recognizes the default gateway API key by its persisted flag. */
193
+ export function isDefaultGatewayApiKey(
194
+ record: Pick<ApiKeyRecord, "isDefault">,
195
+ ): boolean {
196
+ return record.isDefault;
197
+ }
198
+
199
+ /** Generates an inbound key and its one-way hash. */
200
+ export function generateApiKeySecret(prefix = DEFAULT_API_KEY_PREFIX): {
201
+ secret: string;
202
+ hash: string;
203
+ prefix: string;
204
+ } {
205
+ const secret = `${prefix}${randomBytes(KEY_SECRET_BYTES).toString("base64url")}`;
206
+ return { secret, hash: hashSecret(secret), prefix };
207
+ }
208
+
209
+ /** Normalizes a caller-supplied prefix; falls back to the default. */
210
+ export function resolveKeyPrefix(raw: string | undefined): string {
211
+ const trimmed = raw?.trim();
212
+ return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_API_KEY_PREFIX;
213
+ }
214
+
215
+ /** Resolves the share-key prefix; falls back to the default. */
216
+ export function resolveShareKeyPrefix(raw: string | undefined): string {
217
+ const trimmed = raw?.trim();
218
+ return !trimmed ? DEFAULT_SHARE_API_KEY_PREFIX : trimmed;
219
+ }
220
+
221
+ /** Maximum hint the enroll page accepts; the dashboard caps its input there. */
222
+ export const SHARED_CHILD_HINT_MAX_LENGTH = 20;
223
+
224
+ /** Hashes an owner-supplied personal key; persistence encrypts it for CLI handoff. */
225
+ export function prepareCustomKey(secret: string): { secret: string; hash: string } {
226
+ if (secret.trim().length === 0) {
227
+ throw new ConsoleDomainError("invalid_key", 400, "Custom key must be a non-empty string");
228
+ }
229
+ return { secret, hash: hashSecret(secret) };
230
+ }
231
+
232
+ export function finitePositive(value: number | null | undefined, name: string): void {
233
+ // `null` explicitly clears a previously set limit; `undefined` leaves it
234
+ // unchanged. Only a real non-null number must be finite and positive.
235
+ if (value !== undefined && value !== null && (!Number.isFinite(value) || value < 1)) {
236
+ throw new ConsoleDomainError("invalid_limits", 400, `${name} must be a finite positive number`);
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Decodes an uploaded popup image into the bytes and mime to persist.
242
+ *
243
+ * Returns `null` for absent/blank input (nothing to store). Throws
244
+ * `invalid_share_popup` for a non-data URL, an oversized payload, or bytes that
245
+ * are not one of the four raster formats the share page may safely serve.
246
+ */
247
+ export function parseSharePopupImage(
248
+ value: string | null | undefined,
249
+ ): { image: Buffer; mime: SharePopupImageMime } | null {
250
+ if (value === null || value === undefined) return null;
251
+ const raw = value.trim();
252
+ if (raw.length === 0) return null;
253
+ const match =
254
+ /^data:([a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+);base64,([A-Za-z0-9+/=]+)$/i.exec(raw);
255
+ const declared = match?.[1]?.toLowerCase();
256
+ const payload = match?.[2];
257
+ if (!payload || declared === undefined || !(SHARE_POPUP_IMAGE_MIMES as readonly string[]).includes(declared)) {
258
+ throw new ConsoleDomainError(
259
+ "invalid_share_popup",
260
+ 400,
261
+ "sharePopupImage must be an uploaded PNG, JPEG, WebP, or GIF file",
262
+ );
263
+ }
264
+ const image = Buffer.from(payload, "base64");
265
+ if (image.length === 0 || image.length > SHARE_POPUP_IMAGE_MAX_BYTES) {
266
+ throw new ConsoleDomainError(
267
+ "invalid_share_popup",
268
+ 400,
269
+ `sharePopupImage must be at most ${Math.floor(SHARE_POPUP_IMAGE_MAX_BYTES / (1024 * 1024))} MB`,
270
+ );
271
+ }
272
+ const mime = sniffSharePopupImageMime(image);
273
+ if (mime === undefined || mime !== declared) {
274
+ throw new ConsoleDomainError(
275
+ "invalid_share_popup",
276
+ 400,
277
+ "sharePopupImage bytes do not match a supported image format",
278
+ );
279
+ }
280
+ return { image, mime };
281
+ }
282
+
283
+ /** Validates scopes and quota fields before persistence. */
284
+ export function validateApiKeyRequest(request: CreateApiKeyRequest): readonly AccessScope[] {
285
+ const keyMode = request.keyMode ?? "personal";
286
+ if (!(API_KEY_MODES as readonly string[]).includes(keyMode)) {
287
+ throw new ConsoleDomainError("invalid_key_mode", 400, "keyMode must be personal or share");
288
+ }
289
+ if (keyMode === "share" && request.key !== undefined) {
290
+ throw new ConsoleDomainError("invalid_key_mode", 400, "Share templates cannot carry a personal key");
291
+ }
292
+ const scopes = request.scopes ?? ["routing:invoke", "search:invoke"];
293
+ for (const scope of scopes) {
294
+ if (!isValidTenantKeyScope(scope as AccessScope)) {
295
+ throw new ConsoleDomainError("invalid_scope", 400, `Scope is not allowed: ${scope}`);
296
+ }
297
+ }
298
+ finitePositive(request.requestsPerMinute, "requestsPerMinute");
299
+ finitePositive(request.dailyTokenLimit, "dailyTokenLimit");
300
+ finitePositive(request.monthlyTokenLimit, "monthlyTokenLimit");
301
+ finitePositive(request.lifetimeTokenBudget, "lifetimeTokenBudget");
302
+ finitePositive(request.maxConcurrentRequests, "maxConcurrentRequests");
303
+ if (request.maxChildren !== undefined && request.maxChildren !== null) {
304
+ finitePositive(request.maxChildren, "maxChildren");
305
+ if (request.maxChildren > 10_000) {
306
+ throw new ConsoleDomainError("invalid_limits", 400, "maxChildren must not exceed 10000");
307
+ }
308
+ if (keyMode !== "share") {
309
+ throw new ConsoleDomainError("invalid_limits", 400, "maxChildren is only valid for share templates");
310
+ }
311
+ }
312
+ parseSharePopupImage(request.sharePopupImage);
313
+ if (request.sharePopupTitle != null && request.sharePopupTitle.length > 120) {
314
+ throw new ConsoleDomainError("invalid_share_popup", 400, "Share popup title is too long");
315
+ }
316
+ if (request.sharePopupBody != null && request.sharePopupBody.length > 1200) {
317
+ throw new ConsoleDomainError("invalid_share_popup", 400, "Share popup body is too long");
318
+ }
319
+ if (
320
+ request.modelPrefix !== undefined &&
321
+ (typeof request.modelPrefix !== "string" || request.modelPrefix.trim().length === 0)
322
+ ) {
323
+ throw new ConsoleDomainError(
324
+ "invalid_model_prefix",
325
+ 400,
326
+ "modelPrefix must be a non-empty string if provided",
327
+ );
328
+ }
329
+ if (
330
+ request.keyPrefix !== undefined &&
331
+ (typeof request.keyPrefix !== "string" || request.keyPrefix.trim().length === 0)
332
+ ) {
333
+ throw new ConsoleDomainError(
334
+ "invalid_key_prefix",
335
+ 400,
336
+ "keyPrefix must be a non-empty string if provided",
337
+ );
338
+ }
339
+ if (request.clientRouterDenylist !== undefined) {
340
+ if (!Array.isArray(request.clientRouterDenylist)) {
341
+ throw new ConsoleDomainError(
342
+ "invalid_client_router_denylist",
343
+ 400,
344
+ "clientRouterDenylist must be an array of client-router ids",
345
+ );
346
+ }
347
+ // An unrecognised id would persist a rule that can never match — the operator
348
+ // would believe a router is blocked while nothing blocks it. Rejecting it is
349
+ // the only outcome that keeps the stored policy honest.
350
+ for (const id of request.clientRouterDenylist) {
351
+ if (typeof id !== "string" || normalizeClientRouterId(id) === undefined) {
352
+ throw new ConsoleDomainError(
353
+ "invalid_client_router_denylist",
354
+ 400,
355
+ `Unknown client router: ${String(id)}`,
356
+ );
357
+ }
358
+ }
359
+ }
360
+ return scopes as AccessScope[];
361
+ }
362
+
363
+ /** Serializes a persisted record without credential material or IP-key digests. */
364
+ export function sanitizeApiKeyResponse(record: ApiKeyRecord): ApiKeyResponse {
365
+ const keyPrefix =
366
+ record.keyMode === "share" ? resolveShareKeyPrefix(record.keyPrefix) : record.keyPrefix;
367
+ return {
368
+ id: record.id,
369
+ label: record.label,
370
+ enabled: record.enabled,
371
+ keyMode: record.keyMode,
372
+ scopes: record.scopes,
373
+ /**
374
+ * The persisted flag alone decides this: the label is display text, so
375
+ * renaming the key cannot change which key is the default.
376
+ */
377
+ isDefault: isDefaultGatewayApiKey(record),
378
+ ...(keyPrefix === undefined ? {} : { keyPrefix }),
379
+ ...(record.parentKeyId === undefined ? {} : { parentKeyId: record.parentKeyId }),
380
+ ...(record.notesTitle === undefined ? {} : { notesTitle: record.notesTitle }),
381
+ ...(record.notesSubtitle === undefined ? {} : { notesSubtitle: record.notesSubtitle }),
382
+ ...(record.notesBody === undefined ? {} : { notesBody: record.notesBody }),
383
+ ...(record.sharePopupEnabled === undefined ? {} : { sharePopupEnabled: record.sharePopupEnabled }),
384
+ ...(record.sharePopupImageMime === undefined
385
+ ? {}
386
+ : { sharePopupImageMime: record.sharePopupImageMime }),
387
+ ...(record.sharePopupTitle === undefined ? {} : { sharePopupTitle: record.sharePopupTitle }),
388
+ ...(record.sharePopupBody === undefined ? {} : { sharePopupBody: record.sharePopupBody }),
389
+ ...(record.requestsPerMinute === undefined ? {} : { requestsPerMinute: record.requestsPerMinute }),
390
+ ...(record.dailyTokenLimit === undefined ? {} : { dailyTokenLimit: record.dailyTokenLimit }),
391
+ ...(record.monthlyTokenLimit === undefined ? {} : { monthlyTokenLimit: record.monthlyTokenLimit }),
392
+ ...(record.lifetimeTokenBudget === undefined ? {} : { lifetimeTokenBudget: record.lifetimeTokenBudget }),
393
+ ...(record.maxConcurrentRequests === undefined ? {} : { maxConcurrentRequests: record.maxConcurrentRequests }),
394
+ ...(record.maxChildren === undefined ? {} : { maxChildren: record.maxChildren }),
395
+ ...(record.modelPrefix === undefined ? {} : { modelPrefix: record.modelPrefix }),
396
+ ...(record.modelAccessMode === undefined ? {} : { modelAccessMode: record.modelAccessMode }),
397
+ ...(record.modelList === undefined ? {} : { modelList: record.modelList }),
398
+ ...(record.clientRouterDenylist === undefined
399
+ ? {}
400
+ : { clientRouterDenylist: record.clientRouterDenylist }),
401
+ createdAt: record.createdAt.toISOString(),
402
+ ...(record.revokedAt === undefined ? {} : { revokedAt: record.revokedAt.toISOString() }),
403
+ tokensConsumed: record.tokensConsumed,
404
+ };
405
+ }
406
+
407
+ export function parseRequest(value: unknown): CreateApiKeyRequest {
408
+ if (typeof value !== "object" || value === null)
409
+ throw new ConsoleDomainError("invalid_request", 400, "Request body must be an object");
410
+ return value as CreateApiKeyRequest;
411
+ }
412
+
413
+ /** Maps a persisted share link to the console response (ISO timestamps, no token). */
414
+ export function mapShareLinkResponse(link: ShareLinkSummary): ShareLinkResponse {
415
+ return {
416
+ id: link.id,
417
+ kind: link.kind,
418
+ active: link.active,
419
+ createdAt: link.createdAt.toISOString(),
420
+ expiresAt: link.expiresAt === null ? null : link.expiresAt.toISOString(),
421
+ lastViewedAt: link.lastViewedAt === null ? null : link.lastViewedAt.toISOString(),
422
+ };
423
+ }