cartethyia 2.4.5 → 2.5.0

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 (415) hide show
  1. package/.env.example +4 -1
  2. package/README.md +63 -13
  3. package/dist/dashboard/assets/AccountCooldown-ChpdzxRH.js.gz +0 -0
  4. package/dist/dashboard/assets/BannedPage-9igfctfZ.js +1 -0
  5. package/dist/dashboard/assets/BannedPage-9igfctfZ.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolDetailPage-BzGevuql.js → CliToolDetailPage-ZRr2K2sF.js} +1 -1
  7. package/dist/dashboard/assets/CliToolDetailPage-ZRr2K2sF.js.gz +0 -0
  8. package/dist/dashboard/assets/{CliToolsPage-BaRM_7We.js → CliToolsPage-D3xqZbN-.js} +1 -1
  9. package/dist/dashboard/assets/CliToolsPage-D3xqZbN-.js.gz +0 -0
  10. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js +1 -0
  11. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js.gz +0 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js +1 -0
  13. package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js.gz +0 -0
  14. package/dist/dashboard/assets/{CustomizationPage-DqUGcoH0.js → CustomizationPage-CTTpCb25.js} +1 -1
  15. package/dist/dashboard/assets/CustomizationPage-CTTpCb25.js.gz +0 -0
  16. package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js +1 -0
  17. package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js.gz +0 -0
  18. package/dist/dashboard/assets/{LoginPage-DbhTGfhb.js → LoginPage-DHoB3Agn.js} +1 -1
  19. package/dist/dashboard/assets/LoginPage-DHoB3Agn.js.gz +0 -0
  20. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js +1 -0
  21. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
  22. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js +1 -0
  23. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js +3 -0
  25. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js.gz +0 -0
  26. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +1 -0
  27. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  28. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js +1 -0
  29. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js.gz +0 -0
  30. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js +7 -0
  31. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js.gz +0 -0
  32. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js +1 -0
  33. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js.gz +0 -0
  34. package/dist/dashboard/assets/{SettingsPage-YxRml8AT.js → SettingsPage-CvjZxHE4.js} +1 -1
  35. package/dist/dashboard/assets/SettingsPage-CvjZxHE4.js.gz +0 -0
  36. package/dist/dashboard/assets/SetupPage-D3nza7LH.js +1 -0
  37. package/dist/dashboard/assets/SetupPage-D3nza7LH.js.gz +0 -0
  38. package/dist/dashboard/assets/{SortableList-Bol1ASmI.js → SortableList-Dtt12WT1.js} +1 -1
  39. package/dist/dashboard/assets/SortableList-Dtt12WT1.js.gz +0 -0
  40. package/dist/dashboard/assets/StudioPage-CbK3hXvU.js +31 -0
  41. package/dist/dashboard/assets/StudioPage-CbK3hXvU.js.gz +0 -0
  42. package/dist/dashboard/assets/ToolIcon-K1ckcDKg.js.gz +0 -0
  43. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js +56 -0
  44. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js.gz +0 -0
  45. package/dist/dashboard/assets/api-keys-BmDbsior.js +1 -0
  46. package/dist/dashboard/assets/api-keys-BmDbsior.js.gz +0 -0
  47. package/dist/dashboard/assets/app-BbpSarVV.css +2 -0
  48. package/dist/dashboard/assets/app-BbpSarVV.css.gz +0 -0
  49. package/dist/dashboard/assets/{app-BDJWMThu.js → app-C-Y4oZT0.js} +3 -3
  50. package/dist/dashboard/assets/app-C-Y4oZT0.js.gz +0 -0
  51. package/dist/dashboard/assets/arrow-left-CpBxkoxI.js.gz +0 -0
  52. package/dist/dashboard/assets/arrow-right-Dl5yKnp8.js.gz +0 -0
  53. package/dist/dashboard/assets/assert-BHfaei-X.js +1 -0
  54. package/dist/dashboard/assets/assert-BHfaei-X.js.gz +0 -0
  55. package/dist/dashboard/assets/backup-file-gdKIlg8U.js.gz +0 -0
  56. package/dist/dashboard/assets/{badge-BwmwEOgA.js → badge-DpXT4Ixt.js} +1 -1
  57. package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
  58. package/dist/dashboard/assets/{banner-BKBfKTqn.js → banner-CSAAWLJv.js} +1 -1
  59. package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
  60. package/dist/dashboard/assets/brain-Cwed_72z.js.gz +0 -0
  61. package/dist/dashboard/assets/{card-BTdbRFFg.js → card-DEhRXXw4.js} +1 -1
  62. package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
  63. package/dist/dashboard/assets/check-D69FzdvC.js.gz +0 -0
  64. package/dist/dashboard/assets/chevron-right-BXIy5l3h.js.gz +0 -0
  65. package/dist/dashboard/assets/circle-alert-CntYHV5v.js.gz +0 -0
  66. package/dist/dashboard/assets/circle-check-BU3v_mFW.js.gz +0 -0
  67. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js +1 -0
  68. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
  69. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js +1 -0
  70. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
  71. package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
  72. package/dist/dashboard/assets/copy-ngmQ9zcZ.js.gz +0 -0
  73. package/dist/dashboard/assets/{dialog-iocKA3jr.js → dialog-BvQeBMfP.js} +1 -1
  74. package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
  75. package/dist/dashboard/assets/dialog-actions-B1axpEg7.js.gz +0 -0
  76. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  77. package/dist/dashboard/assets/download-dRFo5_Wu.js.gz +0 -0
  78. package/dist/dashboard/assets/{drawer-CDKOTZdY.js → drawer-CMwPXwQB.js} +1 -1
  79. package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
  80. package/dist/dashboard/assets/entry-D4O2229X.js +1 -0
  81. package/dist/dashboard/assets/entry-D4O2229X.js.gz +0 -0
  82. package/dist/dashboard/assets/entry-DKvr5Q9K.js +1 -0
  83. package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
  84. package/dist/dashboard/assets/entry-FQCejoDY.js +4 -0
  85. package/dist/dashboard/assets/entry-FQCejoDY.js.gz +0 -0
  86. package/dist/dashboard/assets/eye-C_hawfH4.js.gz +0 -0
  87. package/dist/dashboard/assets/eye-off-Bk5g2luF.js.gz +0 -0
  88. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  89. package/dist/dashboard/assets/gauge-BbIMy7MB.js.gz +0 -0
  90. package/dist/dashboard/assets/github-badge-zliLvBwr.js +1 -0
  91. package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
  92. package/dist/dashboard/assets/image-BPaouViG.js.gz +0 -0
  93. package/dist/dashboard/assets/{index-C4hXfAMs.js → index-CXm3FIyt.js} +2 -2
  94. package/dist/dashboard/assets/index-CXm3FIyt.js.gz +0 -0
  95. package/dist/dashboard/assets/{inline-DCejSLlA.js → inline-D1IlZZ8H.js} +1 -1
  96. package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
  97. package/dist/dashboard/assets/{input-Bn4_kNav.js → input-BpVZdrbc.js} +1 -1
  98. package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
  99. package/dist/dashboard/assets/jsx-runtime-CXUMngI_.js.gz +0 -0
  100. package/dist/dashboard/assets/key-round-UkavsGxp.js.gz +0 -0
  101. package/dist/dashboard/assets/layers-2-glBbjl.js.gz +0 -0
  102. package/dist/dashboard/assets/{layout-Bv-NxqZ5.js → layout-BioLW7gN.js} +1 -1
  103. package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
  104. package/dist/dashboard/assets/{list-row-BLA0sldD.js → list-row-CwVcGAIc.js} +1 -1
  105. package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
  106. package/dist/dashboard/assets/{live-YlifPYox.js → live-yfEJFF9T.js} +1 -1
  107. package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
  108. package/dist/dashboard/assets/log-in-BmRjy60c.js.gz +0 -0
  109. package/dist/dashboard/assets/message-circle-CCxGwRvv.js.gz +0 -0
  110. package/dist/dashboard/assets/model-bans-CT-TxSNX.js +1 -0
  111. package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
  112. package/dist/dashboard/assets/mutation-BkoLM5Ba.js +1 -0
  113. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  114. package/dist/dashboard/assets/numbers-CFL4_Dqc.js.gz +0 -0
  115. package/dist/dashboard/assets/pencil-768ysRAr.js.gz +0 -0
  116. package/dist/dashboard/assets/plus-DIdX_g-x.js.gz +0 -0
  117. package/dist/dashboard/assets/power-off-Yjw9GMJh.js +1 -0
  118. package/dist/dashboard/assets/power-off-Yjw9GMJh.js.gz +0 -0
  119. package/dist/dashboard/assets/privacy-mode-GM8bej85.js +1 -0
  120. package/dist/dashboard/assets/privacy-mode-GM8bej85.js.gz +0 -0
  121. package/dist/dashboard/assets/providers/agentrouter.svg.gz +0 -0
  122. package/dist/dashboard/assets/providers/aihubmix.svg.gz +0 -0
  123. package/dist/dashboard/assets/providers/alibaba-coding-plan.svg.gz +0 -0
  124. package/dist/dashboard/assets/providers/alibaba.svg.gz +0 -0
  125. package/dist/dashboard/assets/providers/antigravity.svg.gz +0 -0
  126. package/dist/dashboard/assets/providers/bai.svg.gz +0 -0
  127. package/dist/dashboard/assets/providers/blackbox.svg.gz +0 -0
  128. package/dist/dashboard/assets/providers/claude-code.svg.gz +0 -0
  129. package/dist/dashboard/assets/providers/cursor.webp +0 -0
  130. package/dist/dashboard/assets/providers/exa.svg.gz +0 -0
  131. package/dist/dashboard/assets/providers/gmi-cloud.svg.gz +0 -0
  132. package/dist/dashboard/assets/providers/inferhub.svg.gz +0 -0
  133. package/dist/dashboard/assets/providers/muse.svg.gz +0 -0
  134. package/dist/dashboard/assets/providers/nous-research.svg.gz +0 -0
  135. package/dist/dashboard/assets/providers/openai-light.svg.gz +0 -0
  136. package/dist/dashboard/assets/providers/parallel.svg.gz +0 -0
  137. package/dist/dashboard/assets/providers/tokenharbor.svg.gz +0 -0
  138. package/dist/dashboard/assets/providers/trae.webp +0 -0
  139. package/dist/dashboard/assets/providers/zai-coding-plan.svg.gz +0 -0
  140. package/dist/dashboard/assets/{query-keys-ROf-g1Wi.js → query-keys-D7wX2vM5.js} +1 -1
  141. package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
  142. package/dist/dashboard/assets/quota-DZGyp0N5.js.gz +0 -0
  143. package/dist/dashboard/assets/refresh-cw-BqJFk4sU.js.gz +0 -0
  144. package/dist/dashboard/assets/rotate-ccw-CufcqITi.js.gz +0 -0
  145. package/dist/dashboard/assets/route-CNSNj9Zm.js.gz +0 -0
  146. package/dist/dashboard/assets/routing-Cs6jl49L.js +1 -0
  147. package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
  148. package/dist/dashboard/assets/scaling-nWE4Cx8f.js.gz +0 -0
  149. package/dist/dashboard/assets/search-JOKus9rb.js.gz +0 -0
  150. package/dist/dashboard/assets/select-C0Rer3gF.js +41 -0
  151. package/dist/dashboard/assets/select-C0Rer3gF.js.gz +0 -0
  152. package/dist/dashboard/assets/settings-CfcTcOyU.js +1 -0
  153. package/dist/dashboard/assets/settings-CfcTcOyU.js.gz +0 -0
  154. package/dist/dashboard/assets/shield-check-DVS0Foqh.js.gz +0 -0
  155. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  156. package/dist/dashboard/assets/{stack-C1kTcOmP.js → stack-C9_uUjaA.js} +1 -1
  157. package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
  158. package/dist/dashboard/assets/{state-DHKdg-pm.js → state-DA0B95UQ.js} +1 -1
  159. package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
  160. package/dist/dashboard/assets/stream-retry-5QnVuSSo.js.gz +0 -0
  161. package/dist/dashboard/assets/{switch-DIDeAiz9.js → switch-Cittxy-x.js} +1 -1
  162. package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
  163. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +1 -0
  164. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  165. package/dist/dashboard/assets/toast-CNt_jzO4.js +2 -0
  166. package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
  167. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  168. package/dist/dashboard/assets/trash-Cppn5c3R.js.gz +0 -0
  169. package/dist/dashboard/assets/use-clipboard-B0Sy03fE.js.gz +0 -0
  170. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  171. package/dist/dashboard/assets/use-reduced-motion-C4B0R6Sz.js.gz +0 -0
  172. package/dist/dashboard/assets/use-timeout-BwcBBRrT.js.gz +0 -0
  173. package/dist/dashboard/assets/{useMutation-DVmwOKU-.js → useMutation-BQKMcJ1H.js} +1 -1
  174. package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
  175. package/dist/dashboard/assets/wrench-B6E1oEuY.js.gz +0 -0
  176. package/dist/dashboard/index.html +1 -1
  177. package/dist/dashboard/index.html.gz +0 -0
  178. package/dist/dashboard/precompressed-assets.json +122 -0
  179. package/migrations/full-postgres/0013_add_providers_tenant_id_index.sql +22 -0
  180. package/migrations/full-postgres/0014_add_provider_accounts_tenant_id_index.sql +21 -0
  181. package/migrations/full-postgres/0015_add_payload_stages.sql +18 -0
  182. package/migrations/full-postgres/0016_add_share_link_slug.sql +20 -0
  183. package/migrations/full-postgres/0017_add_models_pro_only.sql +18 -0
  184. package/migrations/full-postgres/0018_add_accounts_quota_plan.sql +18 -0
  185. package/migrations/full-postgres/0019_add_models_free_tier.sql +15 -0
  186. package/migrations/lite-sqlite3/0000_baseline.sql +4 -2
  187. package/migrations/lite-sqlite3/0013_add_providers_tenant_id_index.sql +4 -0
  188. package/migrations/lite-sqlite3/0014_add_provider_accounts_tenant_id_index.sql +4 -0
  189. package/migrations/lite-sqlite3/0015_add_payload_stages.sql +18 -0
  190. package/migrations/lite-sqlite3/0016_add_share_link_slug.sql +20 -0
  191. package/migrations/lite-sqlite3/0017_add_models_pro_only.sql +18 -0
  192. package/migrations/lite-sqlite3/0018_add_accounts_quota_plan.sql +18 -0
  193. package/migrations/lite-sqlite3/0019_add_models_free_tier.sql +15 -0
  194. package/package.json +26 -30
  195. package/scripts/cli/{carte-launcher.cjs → bin/carte.cjs} +8 -8
  196. package/scripts/cli/{cli-launcher.cjs → bin/cartethyia.cjs} +167 -134
  197. package/scripts/cli/command/admin.ts +65 -0
  198. package/scripts/cli/command/console-menu.ts +246 -0
  199. package/scripts/cli/{doctor.ts → command/doctor.ts} +257 -258
  200. package/scripts/cli/{setup.ts → command/setup.ts} +8 -12
  201. package/scripts/cli/command/start.ts +77 -0
  202. package/scripts/cli/command/tray.ts +165 -0
  203. package/scripts/cli/command/update.ts +23 -0
  204. package/scripts/cli/{runtime-state.ts → gateway/drain-token.ts} +78 -78
  205. package/scripts/cli/gateway/instance-lock.ts +105 -0
  206. package/scripts/cli/gateway/spawn.ts +173 -0
  207. package/scripts/cli/gateway/stop.ts +51 -0
  208. package/scripts/cli/main.ts +94 -0
  209. package/scripts/cli/support/bun.ts +27 -0
  210. package/scripts/cli/support/env.ts +99 -0
  211. package/scripts/cli/support/platform.ts +64 -0
  212. package/scripts/cli/support/services.ts +88 -0
  213. package/scripts/cli/support/startup-signal.ts +23 -0
  214. package/scripts/cli/{version.ts → support/version.ts} +1 -1
  215. package/scripts/cli/tray/hidden-window.vbs +28 -0
  216. package/scripts/cli/{tray.ts → tray/index.ts} +3 -3
  217. package/scripts/cli/{tray.ps1 → tray/windows.ps1} +107 -98
  218. package/scripts/cli/ui/background.ts +110 -0
  219. package/src/boot/app.ts +23 -7
  220. package/src/boot/dependencies.ts +55 -13
  221. package/src/boot/elysia-typebox-bridge.ts +37 -0
  222. package/src/boot/lifecycle.ts +2 -0
  223. package/src/config/cooldowns.ts +138 -138
  224. package/src/config/providers.ts +31 -5
  225. package/src/config/runtime.ts +19 -3
  226. package/src/config/spec.ts +20 -0
  227. package/src/console/api-keys/contracts.ts +414 -391
  228. package/src/console/api-keys/routes.ts +880 -870
  229. package/src/console/auth/account-reset-store.ts +14 -0
  230. package/src/console/auth/account-reset.ts +3 -2
  231. package/src/console/auth/setup-service.ts +6 -3
  232. package/src/console/backup/routes.ts +8 -0
  233. package/src/console/cli-tools/contracts.ts +6 -6
  234. package/src/console/console-router.ts +5 -3
  235. package/src/console/dashboard-assets.ts +81 -11
  236. package/src/console/domain-registration.ts +9 -2
  237. package/src/console/observability/contracts.ts +475 -423
  238. package/src/console/observability/routes.ts +31 -6
  239. package/src/console/observability/store.ts +1015 -912
  240. package/src/console/providers/catalog/contracts.ts +31 -0
  241. package/src/console/providers/catalog/provider-operations.ts +8 -0
  242. package/src/console/providers/catalog/public-model-store.ts +91 -60
  243. package/src/console/providers/catalog/store.ts +1532 -1521
  244. package/src/console/providers/oauth/callback-listener.ts +18 -3
  245. package/src/console/providers/oauth/routes.ts +38 -15
  246. package/src/console/quota/account-quota-tenant.ts +9 -6
  247. package/src/console/quota/refresh.ts +19 -0
  248. package/src/console/response-compression.ts +69 -2
  249. package/src/console/routing/pools/probe-verdict.ts +72 -0
  250. package/src/console/share/share-router.ts +7 -4
  251. package/src/console/share/share-stats.ts +12 -5
  252. package/src/console/shared/read-through-cache.ts +133 -7
  253. package/src/console/updates/repository-updates.ts +39 -34
  254. package/src/console/updates/routes.ts +162 -4
  255. package/src/main.ts +3 -0
  256. package/src/network/http2-bidi.ts +85 -0
  257. package/src/network/outbound-fetch.ts +87 -24
  258. package/src/network/pool/agent.ts +478 -460
  259. package/src/network/pool/resolver.ts +1104 -544
  260. package/src/network/pool-health-machine.ts +12 -5
  261. package/src/observability/event-log.ts +29 -8
  262. package/src/observability/metrics.ts +40 -13
  263. package/src/observability/payload-capture.ts +344 -324
  264. package/src/observability/runtime-metrics.ts +7 -0
  265. package/src/persistence/db-pg.ts +9 -3
  266. package/src/persistence/db-sqlite.ts +497 -495
  267. package/src/persistence/health-event-flush.ts +137 -0
  268. package/src/persistence/migrate.ts +181 -129
  269. package/src/persistence/schema.ts +1360 -1257
  270. package/src/persistence/settings-revision.ts +25 -0
  271. package/src/persistence/shared-cache.ts +217 -0
  272. package/src/persistence/stores/share-store.ts +556 -528
  273. package/src/persistence/usage-flush.ts +61 -1
  274. package/src/protocol/canonical-model.ts +23 -1
  275. package/src/protocol/response/chat.ts +143 -10
  276. package/src/protocol/response/codex.ts +9 -75
  277. package/src/protocol/response/omp-tool-call-markup.ts +221 -0
  278. package/src/protocol/tool-identity.ts +43 -9
  279. package/src/providers/authentication/jwt-validator.ts +130 -187
  280. package/src/providers/authentication/oauth-flow-store.ts +16 -9
  281. package/src/providers/default-registry.ts +177 -1
  282. package/src/providers/discovery/catalog/catalog-format.ts +20 -0
  283. package/src/providers/discovery/models-dev-catalog.ts +39 -22
  284. package/src/providers/discovery/openai-model-discovery.ts +39 -3
  285. package/src/providers/discovery/openrouter-free-roster.ts +98 -0
  286. package/src/providers/discovery/probe-wire.ts +29 -5
  287. package/src/providers/discovery/probing-service.ts +1022 -1001
  288. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +42 -2
  289. package/src/providers/integrations/buddy/buddy-chat-shared.ts +913 -914
  290. package/src/providers/integrations/buddy/buddy-cli-headers.ts +88 -0
  291. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +22 -5
  292. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +33 -1
  293. package/src/providers/integrations/buddy/buddy-identity.ts +46 -0
  294. package/src/providers/integrations/buddy/buddy-oauth-shared.ts +37 -1
  295. package/src/providers/integrations/buddy/buddy-usage-store.ts +40 -5
  296. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +4 -0
  297. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -175
  298. package/src/providers/integrations/buddy/codebuddy/shared.ts +115 -73
  299. package/src/providers/integrations/buddy/workbuddy/cn-provider.ts +5 -2
  300. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -204
  301. package/src/providers/integrations/buddy/workbuddy/quota.ts +172 -159
  302. package/src/providers/integrations/buddy/workbuddy/shared.ts +200 -171
  303. package/src/providers/integrations/claude/claude.ts +606 -582
  304. package/src/providers/integrations/cline/cline-quota.ts +18 -16
  305. package/src/providers/integrations/cline/cline.ts +539 -468
  306. package/src/providers/integrations/codex/codex.ts +5 -8
  307. package/src/providers/integrations/commandcode/commandcode.ts +675 -541
  308. package/src/providers/integrations/commandcode/discovery.ts +116 -0
  309. package/src/providers/integrations/commandcode/quota.ts +160 -0
  310. package/src/providers/integrations/cursor/cursor-auth.ts +418 -0
  311. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -0
  312. package/src/providers/integrations/cursor/cursor-http2.ts +300 -0
  313. package/src/providers/integrations/cursor/cursor-identity.ts +92 -0
  314. package/src/providers/integrations/cursor/cursor-request.ts +471 -0
  315. package/src/providers/integrations/cursor/cursor-session-manager.ts +281 -0
  316. package/src/providers/integrations/cursor/cursor-shared.ts +108 -0
  317. package/src/providers/integrations/cursor/cursor.ts +960 -0
  318. package/src/providers/integrations/cursor/protocol/framing.ts +129 -0
  319. package/src/providers/integrations/cursor/protocol/messages.ts +2355 -0
  320. package/src/providers/integrations/cursor/protocol/struct.ts +305 -0
  321. package/src/providers/integrations/factory/factory-oauth.ts +7 -11
  322. package/src/providers/integrations/github/github.ts +4 -4
  323. package/src/providers/integrations/hermes.ts +79 -8
  324. package/src/providers/integrations/kilo/kilo-discovery.ts +10 -1
  325. package/src/providers/integrations/kilo/kilo.ts +153 -146
  326. package/src/providers/integrations/opencode/opencode-go-quota.ts +130 -0
  327. package/src/providers/integrations/opencode/provider.ts +532 -499
  328. package/src/providers/integrations/openrouter/provider.ts +139 -281
  329. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -0
  330. package/src/providers/integrations/qoder/qoder-oauth.ts +310 -263
  331. package/src/providers/integrations/qoder/qoder-realm.ts +106 -0
  332. package/src/providers/integrations/qoder/qoder-virtual.ts +77 -0
  333. package/src/providers/integrations/qoder/qoder.ts +865 -730
  334. package/src/providers/integrations/shared/install-id.ts +29 -2
  335. package/src/providers/integrations/trae/trae-catalog.ts +190 -0
  336. package/src/providers/integrations/trae/trae-headers.ts +149 -0
  337. package/src/providers/integrations/trae/trae-oauth.ts +773 -0
  338. package/src/providers/integrations/trae/trae-protocol.ts +400 -0
  339. package/src/providers/integrations/trae/trae-realm.ts +220 -0
  340. package/src/providers/integrations/trae/trae.ts +458 -0
  341. package/src/providers/operations/account-health-service.ts +107 -10
  342. package/src/providers/operations/client-versions.ts +143 -6
  343. package/src/providers/operations/model-catalog-cache.ts +18 -3
  344. package/src/providers/operations/provider-catalog-seeder.ts +95 -86
  345. package/src/providers/operations/provider-catalog-service.ts +76 -0
  346. package/src/providers/operations/provider-credential-service.ts +46 -7
  347. package/src/providers/provider-contracts.ts +76 -0
  348. package/src/providers/provider-metadata.ts +78 -6
  349. package/src/providers/provider-model-definition.ts +373 -349
  350. package/src/security/api-key-auth.ts +126 -8
  351. package/src/transport/contracts.ts +3 -0
  352. package/src/transport/dispatch/attempt-finalize.ts +39 -2
  353. package/src/transport/dispatch/attempt-loop.ts +530 -513
  354. package/src/transport/dispatch/empty-stream.ts +17 -26
  355. package/src/transport/dispatch/generation.ts +88 -21
  356. package/src/transport/dispatch/leases.ts +99 -2
  357. package/src/transport/dispatch/proxy-request.ts +60 -3
  358. package/src/transport/dispatch/streaming/finalize.ts +16 -1
  359. package/src/transport/dispatch/streaming/stream.ts +22 -2
  360. package/src/transport/dispatch/upstream.ts +19 -1
  361. package/src/transport/failure-policy.ts +20 -0
  362. package/src/transport/guards/auth-guard.ts +26 -1
  363. package/src/transport/guards/body-policy.ts +5 -4
  364. package/src/transport/request/preparer.ts +103 -0
  365. package/src/transport/request/state.ts +14 -0
  366. package/src/transport/routing/plan-gate.ts +45 -0
  367. package/src/transport/routing/route-catalog.ts +799 -738
  368. package/src/transport/routing/route-model.ts +52 -1
  369. package/src/transport/routing/router.ts +51 -0
  370. package/src/transport/surface/chat/encode.ts +18 -31
  371. package/src/transport/surface/messages/stream.ts +3 -2
  372. package/src/transport/translation/tool-repair.ts +37 -16
  373. package/src/workers/account-identity-sweep.ts +14 -12
  374. package/src/workers/daily-checkin.ts +62 -0
  375. package/src/workers/quota-refresh-sweep.ts +27 -2
  376. package/dist/dashboard/assets/BannedPage-DUrXpp-X.js +0 -1
  377. package/dist/dashboard/assets/CombosPage-B7ETkfVH.js +0 -1
  378. package/dist/dashboard/assets/ConsoleLogPage-AisQzCI1.js +0 -1
  379. package/dist/dashboard/assets/HealthEventsModal-BNflDm_f.js +0 -1
  380. package/dist/dashboard/assets/ModelPicker-CAiPR5Vd.js +0 -1
  381. package/dist/dashboard/assets/OverviewPage-C3NIzsDx.js +0 -1
  382. package/dist/dashboard/assets/ProviderDetailPage-BK8i5B5X.js +0 -3
  383. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js +0 -1
  384. package/dist/dashboard/assets/ProvidersPage-CvCVgm1e.js +0 -1
  385. package/dist/dashboard/assets/ProxyPage-DjabkuzP.js +0 -7
  386. package/dist/dashboard/assets/QuotaPage-Dxe0B0iu.js +0 -1
  387. package/dist/dashboard/assets/SetupPage-BaD4P_jW.js +0 -1
  388. package/dist/dashboard/assets/StudioPage-CLP4le3U.js +0 -31
  389. package/dist/dashboard/assets/UsagePage-Djes-Epy.js +0 -56
  390. package/dist/dashboard/assets/api-keys-CjIGQhe7.js +0 -1
  391. package/dist/dashboard/assets/app-Cjgo4-Lf.css +0 -2
  392. package/dist/dashboard/assets/assert-Bwh6DJTK.js +0 -1
  393. package/dist/dashboard/assets/cli-tools-DccaOfhX.js +0 -1
  394. package/dist/dashboard/assets/clipboard-button-DQmMikNQ.js +0 -1
  395. package/dist/dashboard/assets/entry-Cpi0rph1.js +0 -4
  396. package/dist/dashboard/assets/entry-Dm3PpDCO.js +0 -1
  397. package/dist/dashboard/assets/entry-keNDZ_Wz.js +0 -1
  398. package/dist/dashboard/assets/github-badge-BuAJr8oU.js +0 -1
  399. package/dist/dashboard/assets/model-bans-CVvHZ-qz.js +0 -1
  400. package/dist/dashboard/assets/mutation-BIizFZav.js +0 -1
  401. package/dist/dashboard/assets/privacy-mode-CQjbZ3xk.js +0 -1
  402. package/dist/dashboard/assets/routing-jntw9-Ui.js +0 -1
  403. package/dist/dashboard/assets/select-CGUuTQmj.js +0 -41
  404. package/dist/dashboard/assets/settings-BAS6QbmJ.js +0 -1
  405. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js +0 -1
  406. package/dist/dashboard/assets/terminal-C9mZ4Ugm.js +0 -1
  407. package/dist/dashboard/assets/toast-DKAdP2mo.js +0 -2
  408. package/scripts/cli/cli.ts +0 -771
  409. package/scripts/operations/probe-egress.ts +0 -335
  410. package/scripts/operations/restart.ts +0 -120
  411. package/scripts/shared/env.ts +0 -269
  412. /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.ico +0 -0
  413. /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.png +0 -0
  414. /package/scripts/cli/{menu.ts → ui/prompt.ts} +0 -0
  415. /package/scripts/cli/{wordmark.ts → ui/wordmark.ts} +0 -0
@@ -1,391 +1,414 @@
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
- readonly token: string;
144
- readonly kind: ShareLinkKind;
145
- readonly expiresAt: string | null;
146
- }
147
-
148
- /** One share link as shown in the owner's console list. Never carries the token. */
149
- export interface ShareLinkResponse {
150
- readonly id: string;
151
- readonly kind: ShareLinkKind;
152
- readonly active: boolean;
153
- readonly createdAt: string;
154
- readonly expiresAt: string | null;
155
- readonly lastViewedAt: string | null;
156
- }
157
-
158
- /** Default prefix for newly generated API keys. */
159
- export const DEFAULT_API_KEY_PREFIX = "carte_";
160
- /** Default prefix used by share templates and the keys they issue. */
161
- export const DEFAULT_SHARE_API_KEY_PREFIX = "sh-carte_";
162
-
163
- /** Label of the default gateway API key seeded at first boot; never revoked. */
164
- export const DEFAULT_API_KEY_LABEL = "Default Cartethyia API key";
165
-
166
- /** Recognizes the default key and legacy rows restored before its flag existed. */
167
- export function isDefaultGatewayApiKey(
168
- record: Pick<ApiKeyRecord, "isDefault" | "keyMode" | "label" | "parentKeyId">,
169
- ): boolean {
170
- return record.isDefault || (
171
- record.keyMode === "personal" &&
172
- record.parentKeyId === undefined &&
173
- record.label === DEFAULT_API_KEY_LABEL
174
- );
175
- }
176
-
177
- /** Generates an inbound key and its one-way hash. */
178
- export function generateApiKeySecret(prefix = DEFAULT_API_KEY_PREFIX): {
179
- secret: string;
180
- hash: string;
181
- prefix: string;
182
- } {
183
- const secret = `${prefix}${randomBytes(9).toString("base64url")}`;
184
- return { secret, hash: hashSecret(secret), prefix };
185
- }
186
-
187
- /** Normalizes a caller-supplied prefix; falls back to the default. */
188
- export function resolveKeyPrefix(raw: string | undefined): string {
189
- const trimmed = raw?.trim();
190
- return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_API_KEY_PREFIX;
191
- }
192
-
193
- /** Resolves the share-key prefix and upgrades the legacy default prefix. */
194
- export function resolveShareKeyPrefix(raw: string | undefined): string {
195
- const trimmed = raw?.trim();
196
- return !trimmed || trimmed === "rk_" ? DEFAULT_SHARE_API_KEY_PREFIX : trimmed;
197
- }
198
-
199
- /** Maximum hint the enroll page accepts; the dashboard caps its input there. */
200
- export const SHARED_CHILD_HINT_MAX_LENGTH = 20;
201
-
202
- /** Hashes an owner-supplied personal key; persistence encrypts it for CLI handoff. */
203
- export function prepareCustomKey(secret: string): { secret: string; hash: string } {
204
- if (secret.trim().length === 0) {
205
- throw new ConsoleDomainError("invalid_key", 400, "Custom key must be a non-empty string");
206
- }
207
- return { secret, hash: hashSecret(secret) };
208
- }
209
-
210
- export function finitePositive(value: number | null | undefined, name: string): void {
211
- // `null` explicitly clears a previously set limit; `undefined` leaves it
212
- // unchanged. Only a real non-null number must be finite and positive.
213
- if (value !== undefined && value !== null && (!Number.isFinite(value) || value < 1)) {
214
- throw new ConsoleDomainError("invalid_limits", 400, `${name} must be a finite positive number`);
215
- }
216
- }
217
-
218
- /**
219
- * Decodes an uploaded popup image into the bytes and mime to persist.
220
- *
221
- * Returns `null` for absent/blank input (nothing to store). Throws
222
- * `invalid_share_popup` for a non-data URL, an oversized payload, or bytes that
223
- * are not one of the four raster formats the share page may safely serve.
224
- */
225
- export function parseSharePopupImage(
226
- value: string | null | undefined,
227
- ): { image: Buffer; mime: SharePopupImageMime } | null {
228
- if (value === null || value === undefined) return null;
229
- const raw = value.trim();
230
- if (raw.length === 0) return null;
231
- const match =
232
- /^data:([a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+);base64,([A-Za-z0-9+/=]+)$/i.exec(raw);
233
- const declared = match?.[1]?.toLowerCase();
234
- const payload = match?.[2];
235
- if (!payload || declared === undefined || !(SHARE_POPUP_IMAGE_MIMES as readonly string[]).includes(declared)) {
236
- throw new ConsoleDomainError(
237
- "invalid_share_popup",
238
- 400,
239
- "sharePopupImage must be an uploaded PNG, JPEG, WebP, or GIF file",
240
- );
241
- }
242
- const image = Buffer.from(payload, "base64");
243
- if (image.length === 0 || image.length > SHARE_POPUP_IMAGE_MAX_BYTES) {
244
- throw new ConsoleDomainError(
245
- "invalid_share_popup",
246
- 400,
247
- `sharePopupImage must be at most ${Math.floor(SHARE_POPUP_IMAGE_MAX_BYTES / (1024 * 1024))} MB`,
248
- );
249
- }
250
- const mime = sniffSharePopupImageMime(image);
251
- if (mime === undefined || mime !== declared) {
252
- throw new ConsoleDomainError(
253
- "invalid_share_popup",
254
- 400,
255
- "sharePopupImage bytes do not match a supported image format",
256
- );
257
- }
258
- return { image, mime };
259
- }
260
-
261
- /** Validates scopes and quota fields before persistence. */
262
- export function validateApiKeyRequest(request: CreateApiKeyRequest): readonly AccessScope[] {
263
- const keyMode = request.keyMode ?? "personal";
264
- if (!(API_KEY_MODES as readonly string[]).includes(keyMode)) {
265
- throw new ConsoleDomainError("invalid_key_mode", 400, "keyMode must be personal or share");
266
- }
267
- if (keyMode === "share" && request.key !== undefined) {
268
- throw new ConsoleDomainError("invalid_key_mode", 400, "Share templates cannot carry a personal key");
269
- }
270
- const scopes = request.scopes ?? ["routing:invoke", "search:invoke"];
271
- for (const scope of scopes) {
272
- if (!isValidTenantKeyScope(scope as AccessScope)) {
273
- throw new ConsoleDomainError("invalid_scope", 400, `Scope is not allowed: ${scope}`);
274
- }
275
- }
276
- finitePositive(request.requestsPerMinute, "requestsPerMinute");
277
- finitePositive(request.dailyTokenLimit, "dailyTokenLimit");
278
- finitePositive(request.monthlyTokenLimit, "monthlyTokenLimit");
279
- finitePositive(request.lifetimeTokenBudget, "lifetimeTokenBudget");
280
- finitePositive(request.maxConcurrentRequests, "maxConcurrentRequests");
281
- parseSharePopupImage(request.sharePopupImage);
282
- if (request.sharePopupTitle != null && request.sharePopupTitle.length > 120) {
283
- throw new ConsoleDomainError("invalid_share_popup", 400, "Share popup title is too long");
284
- }
285
- if (request.sharePopupBody != null && request.sharePopupBody.length > 1200) {
286
- throw new ConsoleDomainError("invalid_share_popup", 400, "Share popup body is too long");
287
- }
288
- if (
289
- request.modelPrefix !== undefined &&
290
- (typeof request.modelPrefix !== "string" || request.modelPrefix.trim().length === 0)
291
- ) {
292
- throw new ConsoleDomainError(
293
- "invalid_model_prefix",
294
- 400,
295
- "modelPrefix must be a non-empty string if provided",
296
- );
297
- }
298
- if (
299
- request.keyPrefix !== undefined &&
300
- (typeof request.keyPrefix !== "string" || request.keyPrefix.trim().length === 0)
301
- ) {
302
- throw new ConsoleDomainError(
303
- "invalid_key_prefix",
304
- 400,
305
- "keyPrefix must be a non-empty string if provided",
306
- );
307
- }
308
- if (request.clientRouterDenylist !== undefined) {
309
- if (!Array.isArray(request.clientRouterDenylist)) {
310
- throw new ConsoleDomainError(
311
- "invalid_client_router_denylist",
312
- 400,
313
- "clientRouterDenylist must be an array of client-router ids",
314
- );
315
- }
316
- // An unrecognised id would persist a rule that can never match — the operator
317
- // would believe a router is blocked while nothing blocks it. Rejecting it is
318
- // the only outcome that keeps the stored policy honest.
319
- for (const id of request.clientRouterDenylist) {
320
- if (typeof id !== "string" || normalizeClientRouterId(id) === undefined) {
321
- throw new ConsoleDomainError(
322
- "invalid_client_router_denylist",
323
- 400,
324
- `Unknown client router: ${String(id)}`,
325
- );
326
- }
327
- }
328
- }
329
- return scopes as AccessScope[];
330
- }
331
-
332
- /** Serializes a persisted record without credential material or IP-key digests. */
333
- export function sanitizeApiKeyResponse(record: ApiKeyRecord): ApiKeyResponse {
334
- const keyPrefix =
335
- record.keyMode === "share" ? resolveShareKeyPrefix(record.keyPrefix) : record.keyPrefix;
336
- return {
337
- id: record.id,
338
- label: record.label,
339
- enabled: record.enabled,
340
- keyMode: record.keyMode,
341
- scopes: record.scopes,
342
- /**
343
- * Current keys use the persisted flag. The canonical label is a compatibility
344
- * marker for legacy restores and is promoted to the flag on the next update.
345
- */
346
- isDefault: isDefaultGatewayApiKey(record),
347
- ...(keyPrefix === undefined ? {} : { keyPrefix }),
348
- ...(record.parentKeyId === undefined ? {} : { parentKeyId: record.parentKeyId }),
349
- ...(record.notesTitle === undefined ? {} : { notesTitle: record.notesTitle }),
350
- ...(record.notesSubtitle === undefined ? {} : { notesSubtitle: record.notesSubtitle }),
351
- ...(record.notesBody === undefined ? {} : { notesBody: record.notesBody }),
352
- ...(record.sharePopupEnabled === undefined ? {} : { sharePopupEnabled: record.sharePopupEnabled }),
353
- ...(record.sharePopupImageMime === undefined
354
- ? {}
355
- : { sharePopupImageMime: record.sharePopupImageMime }),
356
- ...(record.sharePopupTitle === undefined ? {} : { sharePopupTitle: record.sharePopupTitle }),
357
- ...(record.sharePopupBody === undefined ? {} : { sharePopupBody: record.sharePopupBody }),
358
- ...(record.requestsPerMinute === undefined ? {} : { requestsPerMinute: record.requestsPerMinute }),
359
- ...(record.dailyTokenLimit === undefined ? {} : { dailyTokenLimit: record.dailyTokenLimit }),
360
- ...(record.monthlyTokenLimit === undefined ? {} : { monthlyTokenLimit: record.monthlyTokenLimit }),
361
- ...(record.lifetimeTokenBudget === undefined ? {} : { lifetimeTokenBudget: record.lifetimeTokenBudget }),
362
- ...(record.maxConcurrentRequests === undefined ? {} : { maxConcurrentRequests: record.maxConcurrentRequests }),
363
- ...(record.modelPrefix === undefined ? {} : { modelPrefix: record.modelPrefix }),
364
- ...(record.modelAccessMode === undefined ? {} : { modelAccessMode: record.modelAccessMode }),
365
- ...(record.modelList === undefined ? {} : { modelList: record.modelList }),
366
- ...(record.clientRouterDenylist === undefined
367
- ? {}
368
- : { clientRouterDenylist: record.clientRouterDenylist }),
369
- createdAt: record.createdAt.toISOString(),
370
- ...(record.revokedAt === undefined ? {} : { revokedAt: record.revokedAt.toISOString() }),
371
- tokensConsumed: record.tokensConsumed,
372
- };
373
- }
374
-
375
- export function parseRequest(value: unknown): CreateApiKeyRequest {
376
- if (typeof value !== "object" || value === null)
377
- throw new ConsoleDomainError("invalid_request", 400, "Request body must be an object");
378
- return value as CreateApiKeyRequest;
379
- }
380
-
381
- /** Maps a persisted share link to the console response (ISO timestamps, no token). */
382
- export function mapShareLinkResponse(link: ShareLinkSummary): ShareLinkResponse {
383
- return {
384
- id: link.id,
385
- kind: link.kind,
386
- active: link.active,
387
- createdAt: link.createdAt.toISOString(),
388
- expiresAt: link.expiresAt === null ? null : link.expiresAt.toISOString(),
389
- lastViewedAt: link.lastViewedAt === null ? null : link.lastViewedAt.toISOString(),
390
- };
391
- }
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 key and legacy rows restored before its flag existed. */
190
+ export function isDefaultGatewayApiKey(
191
+ record: Pick<ApiKeyRecord, "isDefault" | "keyMode" | "label" | "parentKeyId">,
192
+ ): boolean {
193
+ return record.isDefault || (
194
+ record.keyMode === "personal" &&
195
+ record.parentKeyId === undefined &&
196
+ record.label === DEFAULT_API_KEY_LABEL
197
+ );
198
+ }
199
+
200
+ /** Generates an inbound key and its one-way hash. */
201
+ export function generateApiKeySecret(prefix = DEFAULT_API_KEY_PREFIX): {
202
+ secret: string;
203
+ hash: string;
204
+ prefix: string;
205
+ } {
206
+ const secret = `${prefix}${randomBytes(KEY_SECRET_BYTES).toString("base64url")}`;
207
+ return { secret, hash: hashSecret(secret), prefix };
208
+ }
209
+
210
+ /** Normalizes a caller-supplied prefix; falls back to the default. */
211
+ export function resolveKeyPrefix(raw: string | undefined): string {
212
+ const trimmed = raw?.trim();
213
+ return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_API_KEY_PREFIX;
214
+ }
215
+
216
+ /** Resolves the share-key prefix and upgrades the legacy default prefix. */
217
+ export function resolveShareKeyPrefix(raw: string | undefined): string {
218
+ const trimmed = raw?.trim();
219
+ return !trimmed || trimmed === "rk_" ? DEFAULT_SHARE_API_KEY_PREFIX : trimmed;
220
+ }
221
+
222
+ /** Maximum hint the enroll page accepts; the dashboard caps its input there. */
223
+ export const SHARED_CHILD_HINT_MAX_LENGTH = 20;
224
+
225
+ /** Hashes an owner-supplied personal key; persistence encrypts it for CLI handoff. */
226
+ export function prepareCustomKey(secret: string): { secret: string; hash: string } {
227
+ if (secret.trim().length === 0) {
228
+ throw new ConsoleDomainError("invalid_key", 400, "Custom key must be a non-empty string");
229
+ }
230
+ return { secret, hash: hashSecret(secret) };
231
+ }
232
+
233
+ export function finitePositive(value: number | null | undefined, name: string): void {
234
+ // `null` explicitly clears a previously set limit; `undefined` leaves it
235
+ // unchanged. Only a real non-null number must be finite and positive.
236
+ if (value !== undefined && value !== null && (!Number.isFinite(value) || value < 1)) {
237
+ throw new ConsoleDomainError("invalid_limits", 400, `${name} must be a finite positive number`);
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Decodes an uploaded popup image into the bytes and mime to persist.
243
+ *
244
+ * Returns `null` for absent/blank input (nothing to store). Throws
245
+ * `invalid_share_popup` for a non-data URL, an oversized payload, or bytes that
246
+ * are not one of the four raster formats the share page may safely serve.
247
+ */
248
+ export function parseSharePopupImage(
249
+ value: string | null | undefined,
250
+ ): { image: Buffer; mime: SharePopupImageMime } | null {
251
+ if (value === null || value === undefined) return null;
252
+ const raw = value.trim();
253
+ if (raw.length === 0) return null;
254
+ const match =
255
+ /^data:([a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+);base64,([A-Za-z0-9+/=]+)$/i.exec(raw);
256
+ const declared = match?.[1]?.toLowerCase();
257
+ const payload = match?.[2];
258
+ if (!payload || declared === undefined || !(SHARE_POPUP_IMAGE_MIMES as readonly string[]).includes(declared)) {
259
+ throw new ConsoleDomainError(
260
+ "invalid_share_popup",
261
+ 400,
262
+ "sharePopupImage must be an uploaded PNG, JPEG, WebP, or GIF file",
263
+ );
264
+ }
265
+ const image = Buffer.from(payload, "base64");
266
+ if (image.length === 0 || image.length > SHARE_POPUP_IMAGE_MAX_BYTES) {
267
+ throw new ConsoleDomainError(
268
+ "invalid_share_popup",
269
+ 400,
270
+ `sharePopupImage must be at most ${Math.floor(SHARE_POPUP_IMAGE_MAX_BYTES / (1024 * 1024))} MB`,
271
+ );
272
+ }
273
+ const mime = sniffSharePopupImageMime(image);
274
+ if (mime === undefined || mime !== declared) {
275
+ throw new ConsoleDomainError(
276
+ "invalid_share_popup",
277
+ 400,
278
+ "sharePopupImage bytes do not match a supported image format",
279
+ );
280
+ }
281
+ return { image, mime };
282
+ }
283
+
284
+ /** Validates scopes and quota fields before persistence. */
285
+ export function validateApiKeyRequest(request: CreateApiKeyRequest): readonly AccessScope[] {
286
+ const keyMode = request.keyMode ?? "personal";
287
+ if (!(API_KEY_MODES as readonly string[]).includes(keyMode)) {
288
+ throw new ConsoleDomainError("invalid_key_mode", 400, "keyMode must be personal or share");
289
+ }
290
+ if (keyMode === "share" && request.key !== undefined) {
291
+ throw new ConsoleDomainError("invalid_key_mode", 400, "Share templates cannot carry a personal key");
292
+ }
293
+ const scopes = request.scopes ?? ["routing:invoke", "search:invoke"];
294
+ for (const scope of scopes) {
295
+ if (!isValidTenantKeyScope(scope as AccessScope)) {
296
+ throw new ConsoleDomainError("invalid_scope", 400, `Scope is not allowed: ${scope}`);
297
+ }
298
+ }
299
+ finitePositive(request.requestsPerMinute, "requestsPerMinute");
300
+ finitePositive(request.dailyTokenLimit, "dailyTokenLimit");
301
+ finitePositive(request.monthlyTokenLimit, "monthlyTokenLimit");
302
+ finitePositive(request.lifetimeTokenBudget, "lifetimeTokenBudget");
303
+ finitePositive(request.maxConcurrentRequests, "maxConcurrentRequests");
304
+ parseSharePopupImage(request.sharePopupImage);
305
+ if (request.sharePopupTitle != null && request.sharePopupTitle.length > 120) {
306
+ throw new ConsoleDomainError("invalid_share_popup", 400, "Share popup title is too long");
307
+ }
308
+ if (request.sharePopupBody != null && request.sharePopupBody.length > 1200) {
309
+ throw new ConsoleDomainError("invalid_share_popup", 400, "Share popup body is too long");
310
+ }
311
+ if (
312
+ request.modelPrefix !== undefined &&
313
+ (typeof request.modelPrefix !== "string" || request.modelPrefix.trim().length === 0)
314
+ ) {
315
+ throw new ConsoleDomainError(
316
+ "invalid_model_prefix",
317
+ 400,
318
+ "modelPrefix must be a non-empty string if provided",
319
+ );
320
+ }
321
+ if (
322
+ request.keyPrefix !== undefined &&
323
+ (typeof request.keyPrefix !== "string" || request.keyPrefix.trim().length === 0)
324
+ ) {
325
+ throw new ConsoleDomainError(
326
+ "invalid_key_prefix",
327
+ 400,
328
+ "keyPrefix must be a non-empty string if provided",
329
+ );
330
+ }
331
+ if (request.clientRouterDenylist !== undefined) {
332
+ if (!Array.isArray(request.clientRouterDenylist)) {
333
+ throw new ConsoleDomainError(
334
+ "invalid_client_router_denylist",
335
+ 400,
336
+ "clientRouterDenylist must be an array of client-router ids",
337
+ );
338
+ }
339
+ // An unrecognised id would persist a rule that can never match — the operator
340
+ // would believe a router is blocked while nothing blocks it. Rejecting it is
341
+ // the only outcome that keeps the stored policy honest.
342
+ for (const id of request.clientRouterDenylist) {
343
+ if (typeof id !== "string" || normalizeClientRouterId(id) === undefined) {
344
+ throw new ConsoleDomainError(
345
+ "invalid_client_router_denylist",
346
+ 400,
347
+ `Unknown client router: ${String(id)}`,
348
+ );
349
+ }
350
+ }
351
+ }
352
+ return scopes as AccessScope[];
353
+ }
354
+
355
+ /** Serializes a persisted record without credential material or IP-key digests. */
356
+ export function sanitizeApiKeyResponse(record: ApiKeyRecord): ApiKeyResponse {
357
+ const keyPrefix =
358
+ record.keyMode === "share" ? resolveShareKeyPrefix(record.keyPrefix) : record.keyPrefix;
359
+ return {
360
+ id: record.id,
361
+ label: record.label,
362
+ enabled: record.enabled,
363
+ keyMode: record.keyMode,
364
+ scopes: record.scopes,
365
+ /**
366
+ * Current keys use the persisted flag. The canonical label is a compatibility
367
+ * marker for legacy restores and is promoted to the flag on the next update.
368
+ */
369
+ isDefault: isDefaultGatewayApiKey(record),
370
+ ...(keyPrefix === undefined ? {} : { keyPrefix }),
371
+ ...(record.parentKeyId === undefined ? {} : { parentKeyId: record.parentKeyId }),
372
+ ...(record.notesTitle === undefined ? {} : { notesTitle: record.notesTitle }),
373
+ ...(record.notesSubtitle === undefined ? {} : { notesSubtitle: record.notesSubtitle }),
374
+ ...(record.notesBody === undefined ? {} : { notesBody: record.notesBody }),
375
+ ...(record.sharePopupEnabled === undefined ? {} : { sharePopupEnabled: record.sharePopupEnabled }),
376
+ ...(record.sharePopupImageMime === undefined
377
+ ? {}
378
+ : { sharePopupImageMime: record.sharePopupImageMime }),
379
+ ...(record.sharePopupTitle === undefined ? {} : { sharePopupTitle: record.sharePopupTitle }),
380
+ ...(record.sharePopupBody === undefined ? {} : { sharePopupBody: record.sharePopupBody }),
381
+ ...(record.requestsPerMinute === undefined ? {} : { requestsPerMinute: record.requestsPerMinute }),
382
+ ...(record.dailyTokenLimit === undefined ? {} : { dailyTokenLimit: record.dailyTokenLimit }),
383
+ ...(record.monthlyTokenLimit === undefined ? {} : { monthlyTokenLimit: record.monthlyTokenLimit }),
384
+ ...(record.lifetimeTokenBudget === undefined ? {} : { lifetimeTokenBudget: record.lifetimeTokenBudget }),
385
+ ...(record.maxConcurrentRequests === undefined ? {} : { maxConcurrentRequests: record.maxConcurrentRequests }),
386
+ ...(record.modelPrefix === undefined ? {} : { modelPrefix: record.modelPrefix }),
387
+ ...(record.modelAccessMode === undefined ? {} : { modelAccessMode: record.modelAccessMode }),
388
+ ...(record.modelList === undefined ? {} : { modelList: record.modelList }),
389
+ ...(record.clientRouterDenylist === undefined
390
+ ? {}
391
+ : { clientRouterDenylist: record.clientRouterDenylist }),
392
+ createdAt: record.createdAt.toISOString(),
393
+ ...(record.revokedAt === undefined ? {} : { revokedAt: record.revokedAt.toISOString() }),
394
+ tokensConsumed: record.tokensConsumed,
395
+ };
396
+ }
397
+
398
+ export function parseRequest(value: unknown): CreateApiKeyRequest {
399
+ if (typeof value !== "object" || value === null)
400
+ throw new ConsoleDomainError("invalid_request", 400, "Request body must be an object");
401
+ return value as CreateApiKeyRequest;
402
+ }
403
+
404
+ /** Maps a persisted share link to the console response (ISO timestamps, no token). */
405
+ export function mapShareLinkResponse(link: ShareLinkSummary): ShareLinkResponse {
406
+ return {
407
+ id: link.id,
408
+ kind: link.kind,
409
+ active: link.active,
410
+ createdAt: link.createdAt.toISOString(),
411
+ expiresAt: link.expiresAt === null ? null : link.expiresAt.toISOString(),
412
+ lastViewedAt: link.lastViewedAt === null ? null : link.lastViewedAt.toISOString(),
413
+ };
414
+ }