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,528 +1,556 @@
1
- import { createHash, randomInt } from "node:crypto";
2
-
3
- // Share-link persistence and atomic child-key issuance. The hash is the lookup
4
- // key; the bearer token is retained encrypted so the console can re-display a
5
- // stable link, and is never selected by a public lookup.
6
-
7
- import { and, desc, eq, isNull, sql } from "drizzle-orm";
8
- import type { CartethyiaDatabase } from "../db-handle";
9
- import { lockRows } from "../dialect";
10
- import { apiKeys, shareLinks, sharePopups, type ShareLinkKind, type ApiKeyModelAccessMode } from "../schema";
11
-
12
- /** Hashes a share bearer token for storage and lookup. */
13
- export function hashShareToken(token: string): string {
14
- return createHash("sha256").update(token, "utf8").digest("hex");
15
- }
16
-
17
- /** Newly created share-link metadata. */
18
- export interface ShareLinkRecord {
19
- readonly id: string;
20
- readonly apiKeyId: string;
21
- readonly kind: ShareLinkKind;
22
- readonly createdAt: Date;
23
- readonly expiresAt: Date | null;
24
- }
25
-
26
- /**
27
- * Console-facing view of one link. The caller supplies the decrypted token;
28
- * this record itself never reads the token column.
29
- */
30
- export interface ShareLinkSummary {
31
- readonly id: string;
32
- readonly apiKeyId: string;
33
- readonly kind: ShareLinkKind;
34
- readonly active: boolean;
35
- readonly createdAt: Date;
36
- readonly expiresAt: Date | null;
37
- readonly lastViewedAt: Date | null;
38
- }
39
-
40
- /** A stable link's encrypted token, or null when it predates token retention. */
41
- export interface ShareLinkToken {
42
- readonly id: string;
43
- readonly apiKeyId: string;
44
- readonly kind: ShareLinkKind;
45
- readonly expiresAt: Date | null;
46
- readonly tokenEncrypted: Buffer | null;
47
- }
48
-
49
- /**
50
- * Policy fields a share recipient sees, whichever kind of link carried them.
51
- * Both resolutions project the same columns, so the public payload and the
52
- * model filter read one shape instead of two near-identical ones.
53
- */
54
- export interface ShareLinkPolicy {
55
- readonly id: string;
56
- readonly tenantId: string;
57
- readonly name: string;
58
- readonly keyPrefix: string | null;
59
- readonly requestsPerMinute: number | null;
60
- readonly dailyTokenLimit: number | null;
61
- readonly monthlyTokenLimit: number | null;
62
- readonly lifetimeTokenBudget: number | null;
63
- readonly maxConcurrentRequests: number | null;
64
- readonly modelAccessMode: ApiKeyModelAccessMode | null;
65
- readonly modelList: readonly string[] | null;
66
- readonly modelPrefix: string | null;
67
- readonly notesTitle: string | null;
68
- readonly notesSubtitle: string | null;
69
- readonly notesBody: string | null;
70
- readonly sharePopupEnabled: boolean;
71
- /** Uploaded popup art; the share page fetches it from its own image route. */
72
- readonly sharePopupImage: Buffer | null;
73
- readonly sharePopupImageMime: string | null;
74
- readonly sharePopupTitle: string | null;
75
- readonly sharePopupBody: string | null;
76
- readonly expiresAt: string | null;
77
- }
78
-
79
- /** Public template fields authorized for a valid enrollment-link lookup. */
80
- export interface ShareApiKeyRow extends ShareLinkPolicy {
81
- readonly active: boolean;
82
- readonly createdAt: string;
83
- }
84
-
85
- /** A personal key revealed by its handoff link. */
86
- export interface ShareHandoffRow extends ShareLinkPolicy {
87
- /** Encrypted personal credential; null when the row predates secret storage. */
88
- readonly keyEncrypted: Buffer | null;
89
- }
90
-
91
- /**
92
- * What one bearer token resolves to. A link is exactly one of these, so a
93
- * public lookup answers with a discriminated result instead of the caller
94
- * guessing which endpoint to try.
95
- */
96
- export type ShareLinkResolution =
97
- | { readonly kind: "enroll"; readonly key: ShareApiKeyRow }
98
- | { readonly kind: "handoff"; readonly key: ShareHandoffRow };
99
-
100
- /**
101
- * Why a share link stopped resolving.
102
- *
103
- * A paused key (`disabled`) is not a revoked one and not an expired link, but
104
- * the resolver answered `null` for all three, so the page could only say
105
- * "link unavailable" — which tells an operator nothing about whether to
106
- * regenerate the link, re-enable the key, or issue a new one.
107
- */
108
- export type ShareLinkRefusal = "not_found" | "expired" | "revoked" | "disabled";
109
-
110
- export type ShareLinkOutcome =
111
- | { readonly ok: true; readonly resolution: ShareLinkResolution }
112
- | { readonly ok: false; readonly refusal: ShareLinkRefusal };
113
-
114
- export interface SharedApiKeyMaterial {
115
- readonly keyHash: string;
116
- readonly keyPrefix: string;
117
- readonly clientIp: string;
118
- readonly clientIpKey: string;
119
- /** Recipient-supplied name hint; the store composes the final label from it. */
120
- readonly nameHint?: string;
121
- }
122
-
123
- export type SharedApiKeyIssueResult =
124
- | {
125
- readonly kind: "issued";
126
- readonly apiKeyId: string;
127
- readonly parentKeyId: string;
128
- readonly tenantId: string;
129
- readonly label: string;
130
- readonly keyPrefix: string;
131
- readonly createdAt: Date;
132
- }
133
- | { readonly kind: "link_unavailable" };
134
-
135
- type ApiKeyRow = typeof apiKeys.$inferSelect;
136
- type ShareLinkRow = typeof shareLinks.$inferSelect;
137
-
138
- type PopupRow = typeof sharePopups.$inferSelect;
139
-
140
- function mapShareRow(key: ApiKeyRow, link: ShareLinkRow, popup?: PopupRow): ShareApiKeyRow {
141
- return {
142
- id: key.id,
143
- tenantId: key.tenantId,
144
- name: key.label,
145
- keyPrefix: key.keyPrefix,
146
- active: key.revokedAt === null,
147
- requestsPerMinute: key.requestsPerMinute,
148
- dailyTokenLimit: key.dailyTokenLimit,
149
- monthlyTokenLimit: key.monthlyTokenLimit,
150
- lifetimeTokenBudget: key.lifetimeTokenBudget,
151
- maxConcurrentRequests: key.maxConcurrentRequests,
152
- modelAccessMode: key.modelAccessMode as ApiKeyModelAccessMode | null,
153
- modelList: key.modelList as readonly string[] | null,
154
- modelPrefix: key.modelPrefix,
155
- notesTitle: key.notesTitle,
156
- notesSubtitle: key.notesSubtitle,
157
- notesBody: key.notesBody,
158
- sharePopupEnabled: popup?.enabled ?? false,
159
- sharePopupImage: popup?.image ?? null,
160
- sharePopupImageMime: popup?.imageMime ?? null,
161
- sharePopupTitle: popup?.title ?? null,
162
- sharePopupBody: popup?.body ?? null,
163
- createdAt: key.createdAt.toISOString(),
164
- expiresAt: link.expiresAt?.toISOString() ?? null,
165
- };
166
- }
167
-
168
- function mapHandoffRow(key: ApiKeyRow, link: ShareLinkRow, popup?: PopupRow): ShareHandoffRow {
169
- return {
170
- id: key.id,
171
- tenantId: key.tenantId,
172
- name: key.label,
173
- keyPrefix: key.keyPrefix,
174
- keyEncrypted: key.keyEncrypted,
175
- requestsPerMinute: key.requestsPerMinute,
176
- dailyTokenLimit: key.dailyTokenLimit,
177
- monthlyTokenLimit: key.monthlyTokenLimit,
178
- lifetimeTokenBudget: key.lifetimeTokenBudget,
179
- maxConcurrentRequests: key.maxConcurrentRequests,
180
- modelAccessMode: key.modelAccessMode as ApiKeyModelAccessMode | null,
181
- modelList: key.modelList as readonly string[] | null,
182
- modelPrefix: key.modelPrefix,
183
- notesTitle: key.notesTitle,
184
- notesSubtitle: key.notesSubtitle,
185
- notesBody: key.notesBody,
186
- sharePopupEnabled: popup?.enabled ?? false,
187
- sharePopupImage: popup?.image ?? null,
188
- sharePopupImageMime: popup?.imageMime ?? null,
189
- sharePopupTitle: popup?.title ?? null,
190
- sharePopupBody: popup?.body ?? null,
191
- expiresAt: link.expiresAt?.toISOString() ?? null,
192
- };
193
- }
194
-
195
- /** Persistence boundary for public enrollment links and child API keys. */
196
- export interface ShareLinkStore {
197
- /**
198
- * Establishes the key's single link, or rotates it in place.
199
- *
200
- * One active link per key is the contract: calling this again replaces the
201
- * token so the previous URL stops resolving. `rotate` false returns the
202
- * existing link untouched.
203
- */
204
- create(input: {
205
- apiKeyId: string;
206
- tokenHash: string;
207
- tokenEncrypted: Buffer;
208
- kind: ShareLinkKind;
209
- expiresAt: Date | null;
210
- rotate: boolean;
211
- }): Promise<ShareLinkRecord>;
212
- /**
213
- * Resolves a bearer token to the link it names, in one lookup.
214
- *
215
- * Serving enrollment and handoff through separate methods let the browser
216
- * page and the router disagree about which one a given URL is: a personal
217
- * key's handoff link resolved on one endpoint and 404'd on the other. One
218
- * resolver cannot drift from itself.
219
- */
220
- resolveShareLink(tokenHash: string): Promise<ShareLinkResolution | null>;
221
- /** Same lookup as `resolveShareLink`, but names why a link stopped resolving. */
222
- resolveShareLinkOutcome(tokenHash: string): Promise<ShareLinkOutcome>;
223
- /** The key's active link with its retained token, for console re-display. */
224
- findTokenForApiKey(apiKeyId: string): Promise<ShareLinkToken | null>;
225
- issueSharedApiKey(
226
- tokenHash: string,
227
- material: SharedApiKeyMaterial,
228
- ): Promise<SharedApiKeyIssueResult>;
229
- touchView(tokenHash: string): Promise<void>;
230
- /** All enrollment links for one API key, newest first (never tokens). */
231
- listForApiKey(apiKeyId: string): Promise<readonly ShareLinkSummary[]>;
232
- /** Deactivates one enrollment link scoped to its owning parent API key. */
233
- revoke(apiKeyId: string, shareId: string): Promise<boolean>;
234
- }
235
-
236
- export class DrizzleShareLinkStore implements ShareLinkStore {
237
- constructor(private readonly db: CartethyiaDatabase) {}
238
-
239
- async create(input: {
240
- apiKeyId: string;
241
- tokenHash: string;
242
- tokenEncrypted: Buffer;
243
- kind: ShareLinkKind;
244
- expiresAt: Date | null;
245
- rotate: boolean;
246
- }): Promise<ShareLinkRecord> {
247
- return await this.db.transaction(async (tx) => {
248
- const existing = await lockRows(
249
- tx
250
- .select()
251
- .from(shareLinks)
252
- .where(and(eq(shareLinks.apiKeyId, input.apiKeyId), eq(shareLinks.active, true)))
253
- .orderBy(desc(shareLinks.createdAt))
254
- .limit(1),
255
- );
256
- const current = existing[0];
257
- if (current && !input.rotate) {
258
- return {
259
- id: current.id,
260
- apiKeyId: current.apiKeyId,
261
- kind: current.kind,
262
- createdAt: current.createdAt,
263
- expiresAt: current.expiresAt,
264
- };
265
- }
266
- if (current) {
267
- const updated = await tx
268
- .update(shareLinks)
269
- .set({
270
- tokenHash: input.tokenHash,
271
- tokenEncrypted: input.tokenEncrypted,
272
- expiresAt: input.expiresAt,
273
- lastViewedAt: null,
274
- })
275
- .where(eq(shareLinks.id, current.id))
276
- .returning();
277
- const row = updated[0];
278
- if (!row) throw new Error("share link rotate returned no row");
279
- return {
280
- id: row.id,
281
- apiKeyId: row.apiKeyId,
282
- kind: row.kind,
283
- createdAt: row.createdAt,
284
- expiresAt: row.expiresAt,
285
- };
286
- }
287
- const rows = await tx
288
- .insert(shareLinks)
289
- .values({
290
- apiKeyId: input.apiKeyId,
291
- tokenHash: input.tokenHash,
292
- tokenEncrypted: input.tokenEncrypted,
293
- kind: input.kind,
294
- expiresAt: input.expiresAt,
295
- })
296
- .returning();
297
- const row = rows[0];
298
- if (!row) throw new Error("share link insert returned no row");
299
- return {
300
- id: row.id,
301
- apiKeyId: row.apiKeyId,
302
- kind: row.kind,
303
- createdAt: row.createdAt,
304
- expiresAt: row.expiresAt,
305
- };
306
- });
307
- }
308
-
309
- async resolveShareLink(tokenHash: string): Promise<ShareLinkResolution | null> {
310
- const outcome = await this.resolveShareLinkOutcome(tokenHash);
311
- return outcome.ok ? outcome.resolution : null;
312
- }
313
-
314
- /**
315
- * Same lookup as {@link resolveShareLink}, but reports *why* a link stopped
316
- * resolving instead of collapsing every cause into `null`. The refusal is
317
- * decided from the row that actually exists: dropping the health predicates
318
- * from the `WHERE` and classifying afterwards is the only way to tell an
319
- * expired link from a revoked key from a paused one, since a row failing any
320
- * of them is simply absent from the filtered result.
321
- */
322
- async resolveShareLinkOutcome(tokenHash: string): Promise<ShareLinkOutcome> {
323
- const rows = await this.db
324
- .select({ key: apiKeys, link: shareLinks, popup: sharePopups })
325
- .from(shareLinks)
326
- .innerJoin(apiKeys, eq(shareLinks.apiKeyId, apiKeys.id))
327
- .leftJoin(sharePopups, eq(sharePopups.apiKeyId, apiKeys.id))
328
- .where(and(eq(shareLinks.tokenHash, tokenHash), isNull(apiKeys.parentKeyId)))
329
- .limit(1);
330
- const row = rows[0];
331
- if (row === undefined) return { ok: false, refusal: "not_found" };
332
- // Ordering: a deactivated link was revoked by its owner, not expired —
333
- // "expired" would send them to regenerate a link they deliberately killed.
334
- // Beyond that, report the state the operator can actually change: a key
335
- // can be both paused and expired, and either way the link needs
336
- // regenerating, so the key's own state is the more actionable answer.
337
- if (row.link.active !== true) return { ok: false, refusal: "revoked" };
338
- if (row.key.revokedAt !== null) return { ok: false, refusal: "revoked" };
339
- if (row.key.enabled !== true) return { ok: false, refusal: "disabled" };
340
- if (row.link.expiresAt !== null && row.link.expiresAt.getTime() <= Date.now())
341
- return { ok: false, refusal: "expired" };
342
- // The link's kind and the key's mode must agree: an enrollment link hands
343
- // out child keys from a share template, which has no credential of its own
344
- // to reveal, while a handoff link reveals a personal key. A row where the
345
- // two disagree is a mismatched pair, not a link to serve.
346
- if (row.link.kind === "enroll") {
347
- return row.key.keyMode === "share"
348
- ? { ok: true, resolution: { kind: "enroll", key: mapShareRow(row.key, row.link, row.popup ?? undefined) } }
349
- : { ok: false, refusal: "not_found" };
350
- }
351
- return row.key.keyMode === "personal"
352
- ? { ok: true, resolution: { kind: "handoff", key: mapHandoffRow(row.key, row.link, row.popup ?? undefined) } }
353
- : { ok: false, refusal: "not_found" };
354
- }
355
-
356
- async findTokenForApiKey(apiKeyId: string): Promise<ShareLinkToken | null> {
357
- const rows = await this.db
358
- .select({
359
- id: shareLinks.id,
360
- apiKeyId: shareLinks.apiKeyId,
361
- kind: shareLinks.kind,
362
- expiresAt: shareLinks.expiresAt,
363
- tokenEncrypted: shareLinks.tokenEncrypted,
364
- })
365
- .from(shareLinks)
366
- .where(
367
- and(
368
- eq(shareLinks.apiKeyId, apiKeyId),
369
- eq(shareLinks.active, true),
370
- sql`(${shareLinks.expiresAt} IS NULL OR ${shareLinks.expiresAt} > now())`,
371
- ),
372
- )
373
- .orderBy(desc(shareLinks.createdAt))
374
- .limit(1);
375
- return rows[0] ?? null;
376
- }
377
-
378
- async issueSharedApiKey(
379
- tokenHash: string,
380
- material: SharedApiKeyMaterial,
381
- ): Promise<SharedApiKeyIssueResult> {
382
- return this.db.transaction(async (tx) => {
383
- // Read the link first without locking, then lock its parent before the
384
- // link. Parent revocation uses the same lock order and is atomic with
385
- // child revocation, so a concurrent issue cannot escape the parent.
386
- const [linkRef] = await tx
387
- .select({ id: shareLinks.id, apiKeyId: shareLinks.apiKeyId })
388
- .from(shareLinks)
389
- .where(
390
- and(
391
- eq(shareLinks.tokenHash, tokenHash),
392
- eq(shareLinks.kind, "enroll"),
393
- eq(shareLinks.active, true),
394
- sql`(${shareLinks.expiresAt} IS NULL OR ${shareLinks.expiresAt} > now())`,
395
- ),
396
- )
397
- .limit(1);
398
- if (!linkRef) return { kind: "link_unavailable" };
399
-
400
- const [parent] = await lockRows(
401
- tx
402
- .select()
403
- .from(apiKeys)
404
- .where(
405
- and(
406
- eq(apiKeys.id, linkRef.apiKeyId),
407
- eq(apiKeys.keyMode, "share"),
408
- isNull(apiKeys.parentKeyId),
409
- isNull(apiKeys.revokedAt),
410
- ),
411
- )
412
- .limit(1),
413
- );
414
- if (!parent) return { kind: "link_unavailable" };
415
-
416
- const [activeLink] = await lockRows(
417
- tx
418
- .select({ id: shareLinks.id })
419
- .from(shareLinks)
420
- .where(
421
- and(
422
- eq(shareLinks.id, linkRef.id),
423
- eq(shareLinks.tokenHash, tokenHash),
424
- eq(shareLinks.active, true),
425
- sql`(${shareLinks.expiresAt} IS NULL OR ${shareLinks.expiresAt} > now())`,
426
- ),
427
- )
428
- .limit(1),
429
- );
430
- if (!activeLink) return { kind: "link_unavailable" };
431
-
432
- // The label is `hint + random`, capped at 12 chars — never the
433
- // parent's full label plus a suffix, which grew past every table
434
- // column it rendered in. Built here (not imported from the console
435
- // domain) because persistence never imports console code.
436
- const hint = (material.nameHint ?? "").trim().slice(0, 7);
437
- const suffix = randomInt(1000, 10_000).toString();
438
- const label =
439
- hint.length === 0 ? `key-${suffix}` : `${hint}-${suffix}`.slice(0, 12);
440
- const [child] = await tx
441
- .insert(apiKeys)
442
- .values({
443
- tenantId: parent.tenantId,
444
- keyHash: material.keyHash,
445
- keyMode: "share",
446
- parentKeyId: parent.id,
447
- issuedClientIp: material.clientIp,
448
- issuedClientIpKey: material.clientIpKey,
449
- label,
450
- scopes: parent.scopes,
451
- keyPrefix: material.keyPrefix,
452
- keyEncrypted: null,
453
- notesTitle: parent.notesTitle,
454
- notesSubtitle: parent.notesSubtitle,
455
- notesBody: parent.notesBody,
456
- requestsPerMinute: parent.requestsPerMinute,
457
- dailyTokenLimit: parent.dailyTokenLimit,
458
- monthlyTokenLimit: parent.monthlyTokenLimit,
459
- lifetimeTokenBudget: parent.lifetimeTokenBudget,
460
- maxConcurrentRequests: parent.maxConcurrentRequests,
461
- modelPrefix: parent.modelPrefix,
462
- modelAccessMode: parent.modelAccessMode,
463
- modelList: parent.modelList,
464
- clientRouterDenylist: parent.clientRouterDenylist,
465
- lifetimeTokensConsumed: 0,
466
- })
467
- .returning({ id: apiKeys.id, tenantId: apiKeys.tenantId, createdAt: apiKeys.createdAt });
468
- if (!child) throw new Error("shared API-key insert returned no row");
469
- return {
470
- kind: "issued",
471
- apiKeyId: child.id,
472
- parentKeyId: parent.id,
473
- tenantId: child.tenantId,
474
- label,
475
- keyPrefix: material.keyPrefix,
476
- createdAt: child.createdAt,
477
- };
478
- });
479
- }
480
-
481
- async touchView(tokenHash: string): Promise<void> {
482
- // Expired-but-active links must not bump lastViewedAt: the bump makes
483
- // expiry auditing lie about a link that no longer serves.
484
- await this.db
485
- .update(shareLinks)
486
- .set({ lastViewedAt: new Date() })
487
- .where(
488
- and(
489
- eq(shareLinks.tokenHash, tokenHash),
490
- eq(shareLinks.active, true),
491
- sql`${shareLinks.expiresAt} IS NULL OR ${shareLinks.expiresAt} > NOW()`,
492
- ),
493
- );
494
- }
495
-
496
- async listForApiKey(apiKeyId: string): Promise<readonly ShareLinkSummary[]> {
497
- const rows = await this.db
498
- .select()
499
- .from(shareLinks)
500
- .where(and(eq(shareLinks.apiKeyId, apiKeyId), eq(shareLinks.kind, "enroll")))
501
- .orderBy(desc(shareLinks.createdAt));
502
- return rows.map((row) => ({
503
- id: row.id,
504
- apiKeyId: row.apiKeyId,
505
- kind: "enroll",
506
- active: row.active,
507
- createdAt: row.createdAt,
508
- expiresAt: row.expiresAt,
509
- lastViewedAt: row.lastViewedAt,
510
- }));
511
- }
512
-
513
- async revoke(apiKeyId: string, shareId: string): Promise<boolean> {
514
- const rows = await this.db
515
- .update(shareLinks)
516
- .set({ active: false })
517
- .where(
518
- and(
519
- eq(shareLinks.id, shareId),
520
- eq(shareLinks.apiKeyId, apiKeyId),
521
- eq(shareLinks.kind, "enroll"),
522
- eq(shareLinks.active, true),
523
- ),
524
- )
525
- .returning({ id: shareLinks.id });
526
- return rows.length > 0;
527
- }
528
- }
1
+ import { createHash, randomBytes, randomInt } from "node:crypto";
2
+
3
+ // Share-link persistence and atomic child-key issuance. The hash is the lookup
4
+ // key; the bearer token is retained encrypted so the console can re-display a
5
+ // stable link, and is never selected by a public lookup.
6
+
7
+ import { and, desc, eq, isNull, sql, or,} from "drizzle-orm";
8
+ import type { CartethyiaDatabase } from "../db-handle";
9
+ import { lockRows } from "../dialect";
10
+ import { apiKeys, shareLinks, sharePopups, type ShareLinkKind, type ApiKeyModelAccessMode } from "../schema";
11
+
12
+ /** Hashes a share bearer token for storage and lookup. */
13
+ /**
14
+ * A fresh public slug for a share page.
15
+ *
16
+ * Held in the clear and looked up by index, so it must be unguessable on its
17
+ * own: the same 256 bits of entropy as the bearer token, base64url-encoded.
18
+ */
19
+ export function generateShareSlug(): string {
20
+ return randomBytes(32).toString("base64url");
21
+ }
22
+
23
+ export function hashShareToken(token: string): string {
24
+ return createHash("sha256").update(token, "utf8").digest("hex");
25
+ }
26
+
27
+ /** Newly created share-link metadata. */
28
+ export interface ShareLinkRecord {
29
+ readonly id: string;
30
+ readonly apiKeyId: string;
31
+ readonly kind: ShareLinkKind;
32
+ readonly createdAt: Date;
33
+ readonly expiresAt: Date | null;
34
+ }
35
+
36
+ /**
37
+ * Console-facing view of one link. The caller supplies the decrypted token;
38
+ * this record itself never reads the token column.
39
+ */
40
+ export interface ShareLinkSummary {
41
+ readonly id: string;
42
+ readonly apiKeyId: string;
43
+ readonly kind: ShareLinkKind;
44
+ readonly active: boolean;
45
+ readonly createdAt: Date;
46
+ readonly expiresAt: Date | null;
47
+ readonly lastViewedAt: Date | null;
48
+ }
49
+
50
+ /** A stable link's encrypted token, or null when it predates token retention. */
51
+ export interface ShareLinkToken {
52
+ readonly id: string;
53
+ readonly apiKeyId: string;
54
+ readonly kind: ShareLinkKind;
55
+ readonly expiresAt: Date | null;
56
+ readonly tokenEncrypted: Buffer | null;
57
+ /** Public address fragment, held in the clear; null on pre-migration rows. */
58
+ readonly slug: string | null;
59
+ }
60
+
61
+ /**
62
+ * Policy fields a share recipient sees, whichever kind of link carried them.
63
+ * Both resolutions project the same columns, so the public payload and the
64
+ * model filter read one shape instead of two near-identical ones.
65
+ */
66
+ export interface ShareLinkPolicy {
67
+ readonly id: string;
68
+ readonly tenantId: string;
69
+ readonly name: string;
70
+ readonly keyPrefix: string | null;
71
+ readonly requestsPerMinute: number | null;
72
+ readonly dailyTokenLimit: number | null;
73
+ readonly monthlyTokenLimit: number | null;
74
+ readonly lifetimeTokenBudget: number | null;
75
+ readonly maxConcurrentRequests: number | null;
76
+ readonly modelAccessMode: ApiKeyModelAccessMode | null;
77
+ readonly modelList: readonly string[] | null;
78
+ readonly modelPrefix: string | null;
79
+ readonly notesTitle: string | null;
80
+ readonly notesSubtitle: string | null;
81
+ readonly notesBody: string | null;
82
+ readonly sharePopupEnabled: boolean;
83
+ /** Uploaded popup art; the share page fetches it from its own image route. */
84
+ readonly sharePopupImage: Buffer | null;
85
+ readonly sharePopupImageMime: string | null;
86
+ readonly sharePopupTitle: string | null;
87
+ readonly sharePopupBody: string | null;
88
+ readonly expiresAt: string | null;
89
+ }
90
+
91
+ /** Public template fields authorized for a valid enrollment-link lookup. */
92
+ export interface ShareApiKeyRow extends ShareLinkPolicy {
93
+ readonly active: boolean;
94
+ readonly createdAt: string;
95
+ }
96
+
97
+ /** A personal key revealed by its handoff link. */
98
+ export interface ShareHandoffRow extends ShareLinkPolicy {
99
+ /** Encrypted personal credential; null when the row predates secret storage. */
100
+ readonly keyEncrypted: Buffer | null;
101
+ }
102
+
103
+ /**
104
+ * What one bearer token resolves to. A link is exactly one of these, so a
105
+ * public lookup answers with a discriminated result instead of the caller
106
+ * guessing which endpoint to try.
107
+ */
108
+ export type ShareLinkResolution =
109
+ | { readonly kind: "enroll"; readonly key: ShareApiKeyRow }
110
+ | { readonly kind: "handoff"; readonly key: ShareHandoffRow };
111
+
112
+ /**
113
+ * Why a share link stopped resolving.
114
+ *
115
+ * A paused key (`disabled`) is not a revoked one and not an expired link, but
116
+ * the resolver answered `null` for all three, so the page could only say
117
+ * "link unavailable" — which tells an operator nothing about whether to
118
+ * regenerate the link, re-enable the key, or issue a new one.
119
+ */
120
+ export type ShareLinkRefusal = "not_found" | "expired" | "revoked" | "disabled";
121
+
122
+ export type ShareLinkOutcome =
123
+ | { readonly ok: true; readonly resolution: ShareLinkResolution }
124
+ | { readonly ok: false; readonly refusal: ShareLinkRefusal };
125
+
126
+ export interface SharedApiKeyMaterial {
127
+ readonly keyHash: string;
128
+ readonly keyPrefix: string;
129
+ readonly clientIp: string;
130
+ readonly clientIpKey: string;
131
+ /** Recipient-supplied name hint; the store composes the final label from it. */
132
+ readonly nameHint?: string;
133
+ }
134
+
135
+ export type SharedApiKeyIssueResult =
136
+ | {
137
+ readonly kind: "issued";
138
+ readonly apiKeyId: string;
139
+ readonly parentKeyId: string;
140
+ readonly tenantId: string;
141
+ readonly label: string;
142
+ readonly keyPrefix: string;
143
+ readonly createdAt: Date;
144
+ }
145
+ | { readonly kind: "link_unavailable" };
146
+
147
+ type ApiKeyRow = typeof apiKeys.$inferSelect;
148
+ type ShareLinkRow = typeof shareLinks.$inferSelect;
149
+
150
+ type PopupRow = typeof sharePopups.$inferSelect;
151
+
152
+ function mapShareRow(key: ApiKeyRow, link: ShareLinkRow, popup?: PopupRow): ShareApiKeyRow {
153
+ return {
154
+ id: key.id,
155
+ tenantId: key.tenantId,
156
+ name: key.label,
157
+ keyPrefix: key.keyPrefix,
158
+ active: key.revokedAt === null,
159
+ requestsPerMinute: key.requestsPerMinute,
160
+ dailyTokenLimit: key.dailyTokenLimit,
161
+ monthlyTokenLimit: key.monthlyTokenLimit,
162
+ lifetimeTokenBudget: key.lifetimeTokenBudget,
163
+ maxConcurrentRequests: key.maxConcurrentRequests,
164
+ modelAccessMode: key.modelAccessMode as ApiKeyModelAccessMode | null,
165
+ modelList: key.modelList as readonly string[] | null,
166
+ modelPrefix: key.modelPrefix,
167
+ notesTitle: key.notesTitle,
168
+ notesSubtitle: key.notesSubtitle,
169
+ notesBody: key.notesBody,
170
+ sharePopupEnabled: popup?.enabled ?? false,
171
+ sharePopupImage: popup?.image ?? null,
172
+ sharePopupImageMime: popup?.imageMime ?? null,
173
+ sharePopupTitle: popup?.title ?? null,
174
+ sharePopupBody: popup?.body ?? null,
175
+ createdAt: key.createdAt.toISOString(),
176
+ expiresAt: link.expiresAt?.toISOString() ?? null,
177
+ };
178
+ }
179
+
180
+ function mapHandoffRow(key: ApiKeyRow, link: ShareLinkRow, popup?: PopupRow): ShareHandoffRow {
181
+ return {
182
+ id: key.id,
183
+ tenantId: key.tenantId,
184
+ name: key.label,
185
+ keyPrefix: key.keyPrefix,
186
+ keyEncrypted: key.keyEncrypted,
187
+ requestsPerMinute: key.requestsPerMinute,
188
+ dailyTokenLimit: key.dailyTokenLimit,
189
+ monthlyTokenLimit: key.monthlyTokenLimit,
190
+ lifetimeTokenBudget: key.lifetimeTokenBudget,
191
+ maxConcurrentRequests: key.maxConcurrentRequests,
192
+ modelAccessMode: key.modelAccessMode as ApiKeyModelAccessMode | null,
193
+ modelList: key.modelList as readonly string[] | null,
194
+ modelPrefix: key.modelPrefix,
195
+ notesTitle: key.notesTitle,
196
+ notesSubtitle: key.notesSubtitle,
197
+ notesBody: key.notesBody,
198
+ sharePopupEnabled: popup?.enabled ?? false,
199
+ sharePopupImage: popup?.image ?? null,
200
+ sharePopupImageMime: popup?.imageMime ?? null,
201
+ sharePopupTitle: popup?.title ?? null,
202
+ sharePopupBody: popup?.body ?? null,
203
+ expiresAt: link.expiresAt?.toISOString() ?? null,
204
+ };
205
+ }
206
+
207
+ /** Persistence boundary for public enrollment links and child API keys. */
208
+ export interface ShareLinkStore {
209
+ /**
210
+ * Establishes the key's single link, or rotates it in place.
211
+ *
212
+ * One active link per key is the contract: calling this again replaces the
213
+ * token so the previous URL stops resolving. `rotate` false returns the
214
+ * existing link untouched.
215
+ */
216
+ create(input: {
217
+ apiKeyId: string;
218
+ tokenHash: string;
219
+ tokenEncrypted: Buffer;
220
+ /** Public address fragment; kept across rotates so the URL is stable. */
221
+ slug: string;
222
+ kind: ShareLinkKind;
223
+ expiresAt: Date | null;
224
+ rotate: boolean;
225
+ }): Promise<ShareLinkRecord>;
226
+ /**
227
+ * Resolves a bearer token to the link it names, in one lookup.
228
+ *
229
+ * Serving enrollment and handoff through separate methods let the browser
230
+ * page and the router disagree about which one a given URL is: a personal
231
+ * key's handoff link resolved on one endpoint and 404'd on the other. One
232
+ * resolver cannot drift from itself.
233
+ */
234
+ resolveShareLink(tokenHash: string): Promise<ShareLinkResolution | null>;
235
+ /** Same lookup as `resolveShareLink`, but names why a link stopped resolving. */
236
+ resolveShareLinkOutcome(tokenHash: string): Promise<ShareLinkOutcome>;
237
+ /** The key's active link with its retained token, for console re-display. */
238
+ findTokenForApiKey(apiKeyId: string): Promise<ShareLinkToken | null>;
239
+ issueSharedApiKey(
240
+ tokenHash: string,
241
+ material: SharedApiKeyMaterial,
242
+ ): Promise<SharedApiKeyIssueResult>;
243
+ touchView(tokenHash: string): Promise<void>;
244
+ /** All enrollment links for one API key, newest first (never tokens). */
245
+ listForApiKey(apiKeyId: string): Promise<readonly ShareLinkSummary[]>;
246
+ /** Deactivates one enrollment link scoped to its owning parent API key. */
247
+ revoke(apiKeyId: string, shareId: string): Promise<boolean>;
248
+ }
249
+
250
+ export class DrizzleShareLinkStore implements ShareLinkStore {
251
+ constructor(private readonly db: CartethyiaDatabase) {}
252
+
253
+ async create(input: {
254
+ apiKeyId: string;
255
+ tokenHash: string;
256
+ tokenEncrypted: Buffer;
257
+ slug: string;
258
+ kind: ShareLinkKind;
259
+ expiresAt: Date | null;
260
+ rotate: boolean;
261
+ }): Promise<ShareLinkRecord> {
262
+ return await this.db.transaction(async (tx) => {
263
+ const existing = await lockRows(
264
+ tx
265
+ .select()
266
+ .from(shareLinks)
267
+ .where(and(eq(shareLinks.apiKeyId, input.apiKeyId), eq(shareLinks.active, true)))
268
+ .orderBy(desc(shareLinks.createdAt))
269
+ .limit(1),
270
+ );
271
+ const current = existing[0];
272
+ if (current && !input.rotate) {
273
+ return {
274
+ id: current.id,
275
+ apiKeyId: current.apiKeyId,
276
+ kind: current.kind,
277
+ createdAt: current.createdAt,
278
+ expiresAt: current.expiresAt,
279
+ };
280
+ }
281
+ if (current) {
282
+ const updated = await tx
283
+ .update(shareLinks)
284
+ .set({
285
+ tokenHash: input.tokenHash,
286
+ tokenEncrypted: input.tokenEncrypted,
287
+ // The slug is the address and survives a rotate, so an operator who
288
+ // rotates does not invalidate every copy of the link they shared.
289
+ ...(current.slug === null ? { slug: input.slug } : {}),
290
+ expiresAt: input.expiresAt,
291
+ lastViewedAt: null,
292
+ })
293
+ .where(eq(shareLinks.id, current.id))
294
+ .returning();
295
+ const row = updated[0];
296
+ if (!row) throw new Error("share link rotate returned no row");
297
+ return {
298
+ id: row.id,
299
+ apiKeyId: row.apiKeyId,
300
+ kind: row.kind,
301
+ createdAt: row.createdAt,
302
+ expiresAt: row.expiresAt,
303
+ };
304
+ }
305
+ const rows = await tx
306
+ .insert(shareLinks)
307
+ .values({
308
+ apiKeyId: input.apiKeyId,
309
+ tokenHash: input.tokenHash,
310
+ tokenEncrypted: input.tokenEncrypted,
311
+ slug: input.slug,
312
+ kind: input.kind,
313
+ expiresAt: input.expiresAt,
314
+ })
315
+ .returning();
316
+ const row = rows[0];
317
+ if (!row) throw new Error("share link insert returned no row");
318
+ return {
319
+ id: row.id,
320
+ apiKeyId: row.apiKeyId,
321
+ kind: row.kind,
322
+ createdAt: row.createdAt,
323
+ expiresAt: row.expiresAt,
324
+ };
325
+ });
326
+ }
327
+
328
+ async resolveShareLink(tokenHash: string): Promise<ShareLinkResolution | null> {
329
+ const outcome = await this.resolveShareLinkOutcome(tokenHash);
330
+ return outcome.ok ? outcome.resolution : null;
331
+ }
332
+
333
+ /**
334
+ * Same lookup as {@link resolveShareLink}, but reports *why* a link stopped
335
+ * resolving instead of collapsing every cause into `null`. The refusal is
336
+ * decided from the row that actually exists: dropping the health predicates
337
+ * from the `WHERE` and classifying afterwards is the only way to tell an
338
+ * expired link from a revoked key from a paused one, since a row failing any
339
+ * of them is simply absent from the filtered result.
340
+ */
341
+ async resolveShareLinkOutcome(tokenHash: string): Promise<ShareLinkOutcome> {
342
+ const rows = await this.db
343
+ .select({ key: apiKeys, link: shareLinks, popup: sharePopups })
344
+ .from(shareLinks)
345
+ .innerJoin(apiKeys, eq(shareLinks.apiKeyId, apiKeys.id))
346
+ .leftJoin(sharePopups, eq(sharePopups.apiKeyId, apiKeys.id))
347
+ // The path segment may be the slug or the bearer token: the slug is the
348
+ // address a restored page keeps, the token is what the page was issued
349
+ // with before slugs existed. Either one names the same link.
350
+ .where(
351
+ and(
352
+ or(eq(shareLinks.slug, tokenHash), eq(shareLinks.tokenHash, tokenHash)),
353
+ isNull(apiKeys.parentKeyId),
354
+ ),
355
+ )
356
+ .limit(1);
357
+ const row = rows[0];
358
+ if (row === undefined) return { ok: false, refusal: "not_found" };
359
+ // Ordering: a deactivated link was revoked by its owner, not expired —
360
+ // "expired" would send them to regenerate a link they deliberately killed.
361
+ // Beyond that, report the state the operator can actually change: a key
362
+ // can be both paused and expired, and either way the link needs
363
+ // regenerating, so the key's own state is the more actionable answer.
364
+ if (row.link.active !== true) return { ok: false, refusal: "revoked" };
365
+ if (row.key.revokedAt !== null) return { ok: false, refusal: "revoked" };
366
+ if (row.key.enabled !== true) return { ok: false, refusal: "disabled" };
367
+ if (row.link.expiresAt !== null && row.link.expiresAt.getTime() <= Date.now())
368
+ return { ok: false, refusal: "expired" };
369
+ // The link's kind and the key's mode must agree: an enrollment link hands
370
+ // out child keys from a share template, which has no credential of its own
371
+ // to reveal, while a handoff link reveals a personal key. A row where the
372
+ // two disagree is a mismatched pair, not a link to serve.
373
+ if (row.link.kind === "enroll") {
374
+ return row.key.keyMode === "share"
375
+ ? { ok: true, resolution: { kind: "enroll", key: mapShareRow(row.key, row.link, row.popup ?? undefined) } }
376
+ : { ok: false, refusal: "not_found" };
377
+ }
378
+ return row.key.keyMode === "personal"
379
+ ? { ok: true, resolution: { kind: "handoff", key: mapHandoffRow(row.key, row.link, row.popup ?? undefined) } }
380
+ : { ok: false, refusal: "not_found" };
381
+ }
382
+
383
+ async findTokenForApiKey(apiKeyId: string): Promise<ShareLinkToken | null> {
384
+ const rows = await this.db
385
+ .select({
386
+ id: shareLinks.id,
387
+ apiKeyId: shareLinks.apiKeyId,
388
+ kind: shareLinks.kind,
389
+ expiresAt: shareLinks.expiresAt,
390
+ tokenEncrypted: shareLinks.tokenEncrypted,
391
+ slug: shareLinks.slug,
392
+ })
393
+ .from(shareLinks)
394
+ .where(
395
+ and(
396
+ eq(shareLinks.apiKeyId, apiKeyId),
397
+ eq(shareLinks.active, true),
398
+ sql`(${shareLinks.expiresAt} IS NULL OR ${shareLinks.expiresAt} > now())`,
399
+ ),
400
+ )
401
+ .orderBy(desc(shareLinks.createdAt))
402
+ .limit(1);
403
+ return rows[0] ?? null;
404
+ }
405
+
406
+ async issueSharedApiKey(
407
+ tokenHash: string,
408
+ material: SharedApiKeyMaterial,
409
+ ): Promise<SharedApiKeyIssueResult> {
410
+ return this.db.transaction(async (tx) => {
411
+ // Read the link first without locking, then lock its parent before the
412
+ // link. Parent revocation uses the same lock order and is atomic with
413
+ // child revocation, so a concurrent issue cannot escape the parent.
414
+ const [linkRef] = await tx
415
+ .select({ id: shareLinks.id, apiKeyId: shareLinks.apiKeyId })
416
+ .from(shareLinks)
417
+ .where(
418
+ and(
419
+ eq(shareLinks.tokenHash, tokenHash),
420
+ eq(shareLinks.kind, "enroll"),
421
+ eq(shareLinks.active, true),
422
+ sql`(${shareLinks.expiresAt} IS NULL OR ${shareLinks.expiresAt} > now())`,
423
+ ),
424
+ )
425
+ .limit(1);
426
+ if (!linkRef) return { kind: "link_unavailable" };
427
+
428
+ const [parent] = await lockRows(
429
+ tx
430
+ .select()
431
+ .from(apiKeys)
432
+ .where(
433
+ and(
434
+ eq(apiKeys.id, linkRef.apiKeyId),
435
+ eq(apiKeys.keyMode, "share"),
436
+ isNull(apiKeys.parentKeyId),
437
+ isNull(apiKeys.revokedAt),
438
+ ),
439
+ )
440
+ .limit(1),
441
+ );
442
+ if (!parent) return { kind: "link_unavailable" };
443
+
444
+ const [activeLink] = await lockRows(
445
+ tx
446
+ .select({ id: shareLinks.id })
447
+ .from(shareLinks)
448
+ .where(
449
+ and(
450
+ eq(shareLinks.id, linkRef.id),
451
+ eq(shareLinks.tokenHash, tokenHash),
452
+ eq(shareLinks.active, true),
453
+ sql`(${shareLinks.expiresAt} IS NULL OR ${shareLinks.expiresAt} > now())`,
454
+ ),
455
+ )
456
+ .limit(1),
457
+ );
458
+ if (!activeLink) return { kind: "link_unavailable" };
459
+
460
+ // The label is `hint + random`, capped at 12 chars — never the
461
+ // parent's full label plus a suffix, which grew past every table
462
+ // column it rendered in. Built here (not imported from the console
463
+ // domain) because persistence never imports console code.
464
+ const hint = (material.nameHint ?? "").trim().slice(0, 7);
465
+ const suffix = randomInt(1000, 10_000).toString();
466
+ const label =
467
+ hint.length === 0 ? `key-${suffix}` : `${hint}-${suffix}`.slice(0, 12);
468
+ const [child] = await tx
469
+ .insert(apiKeys)
470
+ .values({
471
+ tenantId: parent.tenantId,
472
+ keyHash: material.keyHash,
473
+ keyMode: "share",
474
+ parentKeyId: parent.id,
475
+ issuedClientIp: material.clientIp,
476
+ issuedClientIpKey: material.clientIpKey,
477
+ label,
478
+ scopes: parent.scopes,
479
+ keyPrefix: material.keyPrefix,
480
+ keyEncrypted: null,
481
+ notesTitle: parent.notesTitle,
482
+ notesSubtitle: parent.notesSubtitle,
483
+ notesBody: parent.notesBody,
484
+ requestsPerMinute: parent.requestsPerMinute,
485
+ dailyTokenLimit: parent.dailyTokenLimit,
486
+ monthlyTokenLimit: parent.monthlyTokenLimit,
487
+ lifetimeTokenBudget: parent.lifetimeTokenBudget,
488
+ maxConcurrentRequests: parent.maxConcurrentRequests,
489
+ modelPrefix: parent.modelPrefix,
490
+ modelAccessMode: parent.modelAccessMode,
491
+ modelList: parent.modelList,
492
+ clientRouterDenylist: parent.clientRouterDenylist,
493
+ lifetimeTokensConsumed: 0,
494
+ })
495
+ .returning({ id: apiKeys.id, tenantId: apiKeys.tenantId, createdAt: apiKeys.createdAt });
496
+ if (!child) throw new Error("shared API-key insert returned no row");
497
+ return {
498
+ kind: "issued",
499
+ apiKeyId: child.id,
500
+ parentKeyId: parent.id,
501
+ tenantId: child.tenantId,
502
+ label,
503
+ keyPrefix: material.keyPrefix,
504
+ createdAt: child.createdAt,
505
+ };
506
+ });
507
+ }
508
+
509
+ async touchView(tokenHash: string): Promise<void> {
510
+ // Expired-but-active links must not bump lastViewedAt: the bump makes
511
+ // expiry auditing lie about a link that no longer serves.
512
+ await this.db
513
+ .update(shareLinks)
514
+ .set({ lastViewedAt: new Date() })
515
+ .where(
516
+ and(
517
+ eq(shareLinks.tokenHash, tokenHash),
518
+ eq(shareLinks.active, true),
519
+ sql`${shareLinks.expiresAt} IS NULL OR ${shareLinks.expiresAt} > NOW()`,
520
+ ),
521
+ );
522
+ }
523
+
524
+ async listForApiKey(apiKeyId: string): Promise<readonly ShareLinkSummary[]> {
525
+ const rows = await this.db
526
+ .select()
527
+ .from(shareLinks)
528
+ .where(and(eq(shareLinks.apiKeyId, apiKeyId), eq(shareLinks.kind, "enroll")))
529
+ .orderBy(desc(shareLinks.createdAt));
530
+ return rows.map((row) => ({
531
+ id: row.id,
532
+ apiKeyId: row.apiKeyId,
533
+ kind: "enroll",
534
+ active: row.active,
535
+ createdAt: row.createdAt,
536
+ expiresAt: row.expiresAt,
537
+ lastViewedAt: row.lastViewedAt,
538
+ }));
539
+ }
540
+
541
+ async revoke(apiKeyId: string, shareId: string): Promise<boolean> {
542
+ const rows = await this.db
543
+ .update(shareLinks)
544
+ .set({ active: false })
545
+ .where(
546
+ and(
547
+ eq(shareLinks.id, shareId),
548
+ eq(shareLinks.apiKeyId, apiKeyId),
549
+ eq(shareLinks.kind, "enroll"),
550
+ eq(shareLinks.active, true),
551
+ ),
552
+ )
553
+ .returning({ id: shareLinks.id });
554
+ return rows.length > 0;
555
+ }
556
+ }