cartethyia 2.4.5 → 2.4.6

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 (303) hide show
  1. package/.env.example +4 -1
  2. package/README.md +18 -0
  3. package/dist/dashboard/assets/AccountCooldown-ChpdzxRH.js.gz +0 -0
  4. package/dist/dashboard/assets/{BannedPage-DUrXpp-X.js → BannedPage-QhvbJB9o.js} +1 -1
  5. package/dist/dashboard/assets/BannedPage-QhvbJB9o.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolDetailPage-BzGevuql.js → CliToolDetailPage-Bcy150Jo.js} +1 -1
  7. package/dist/dashboard/assets/CliToolDetailPage-Bcy150Jo.js.gz +0 -0
  8. package/dist/dashboard/assets/{CliToolsPage-BaRM_7We.js → CliToolsPage-DyNXefph.js} +1 -1
  9. package/dist/dashboard/assets/CliToolsPage-DyNXefph.js.gz +0 -0
  10. package/dist/dashboard/assets/CombosPage-Cak0ljnO.js +1 -0
  11. package/dist/dashboard/assets/CombosPage-Cak0ljnO.js.gz +0 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-CATpAEOz.js +1 -0
  13. package/dist/dashboard/assets/ConsoleLogPage-CATpAEOz.js.gz +0 -0
  14. package/dist/dashboard/assets/{CustomizationPage-DqUGcoH0.js → CustomizationPage-D6jEU7nL.js} +1 -1
  15. package/dist/dashboard/assets/CustomizationPage-D6jEU7nL.js.gz +0 -0
  16. package/dist/dashboard/assets/HealthEventsModal-CZkpn0a7.js +1 -0
  17. package/dist/dashboard/assets/HealthEventsModal-CZkpn0a7.js.gz +0 -0
  18. package/dist/dashboard/assets/{LoginPage-DbhTGfhb.js → LoginPage-BJlMVN4U.js} +1 -1
  19. package/dist/dashboard/assets/LoginPage-BJlMVN4U.js.gz +0 -0
  20. package/dist/dashboard/assets/ModelPicker-Br3CXEau.js +1 -0
  21. package/dist/dashboard/assets/ModelPicker-Br3CXEau.js.gz +0 -0
  22. package/dist/dashboard/assets/{OverviewPage-C3NIzsDx.js → OverviewPage-CQxXbo2g.js} +1 -1
  23. package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js +3 -0
  25. package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js.gz +0 -0
  26. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js.gz +0 -0
  27. package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js +1 -0
  28. package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js.gz +0 -0
  29. package/dist/dashboard/assets/ProxyPage-CXiumA53.js +7 -0
  30. package/dist/dashboard/assets/ProxyPage-CXiumA53.js.gz +0 -0
  31. package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js +1 -0
  32. package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js.gz +0 -0
  33. package/dist/dashboard/assets/{SettingsPage-YxRml8AT.js → SettingsPage-BHW4Zt06.js} +1 -1
  34. package/dist/dashboard/assets/SettingsPage-BHW4Zt06.js.gz +0 -0
  35. package/dist/dashboard/assets/SetupPage-VOYC_lIa.js +1 -0
  36. package/dist/dashboard/assets/SetupPage-VOYC_lIa.js.gz +0 -0
  37. package/dist/dashboard/assets/SortableList-DpOTnGuH.js +1 -0
  38. package/dist/dashboard/assets/SortableList-DpOTnGuH.js.gz +0 -0
  39. package/dist/dashboard/assets/StudioPage-BNIDLiU6.js +31 -0
  40. package/dist/dashboard/assets/StudioPage-BNIDLiU6.js.gz +0 -0
  41. package/dist/dashboard/assets/ToolIcon-K1ckcDKg.js.gz +0 -0
  42. package/dist/dashboard/assets/{UsagePage-Djes-Epy.js → UsagePage-BAFaSDQV.js} +14 -14
  43. package/dist/dashboard/assets/UsagePage-BAFaSDQV.js.gz +0 -0
  44. package/dist/dashboard/assets/api-keys-BETfaMYZ.js +1 -0
  45. package/dist/dashboard/assets/api-keys-BETfaMYZ.js.gz +0 -0
  46. package/dist/dashboard/assets/app-BLCvGdzb.css +2 -0
  47. package/dist/dashboard/assets/app-BLCvGdzb.css.gz +0 -0
  48. package/dist/dashboard/assets/{app-BDJWMThu.js → app-Cjq7nrwu.js} +3 -3
  49. package/dist/dashboard/assets/app-Cjq7nrwu.js.gz +0 -0
  50. package/dist/dashboard/assets/arrow-left-CpBxkoxI.js.gz +0 -0
  51. package/dist/dashboard/assets/arrow-right-Dl5yKnp8.js.gz +0 -0
  52. package/dist/dashboard/assets/{assert-Bwh6DJTK.js → assert-7J6_LVD3.js} +1 -1
  53. package/dist/dashboard/assets/assert-7J6_LVD3.js.gz +0 -0
  54. package/dist/dashboard/assets/backup-file-gdKIlg8U.js.gz +0 -0
  55. package/dist/dashboard/assets/{badge-BwmwEOgA.js → badge-BTyGk_DL.js} +1 -1
  56. package/dist/dashboard/assets/badge-BTyGk_DL.js.gz +0 -0
  57. package/dist/dashboard/assets/{banner-BKBfKTqn.js → banner-bujTWYM4.js} +1 -1
  58. package/dist/dashboard/assets/banner-bujTWYM4.js.gz +0 -0
  59. package/dist/dashboard/assets/brain-Cwed_72z.js.gz +0 -0
  60. package/dist/dashboard/assets/{card-BTdbRFFg.js → card-B0Bnx5CA.js} +1 -1
  61. package/dist/dashboard/assets/card-B0Bnx5CA.js.gz +0 -0
  62. package/dist/dashboard/assets/check-D69FzdvC.js.gz +0 -0
  63. package/dist/dashboard/assets/chevron-right-BXIy5l3h.js.gz +0 -0
  64. package/dist/dashboard/assets/circle-alert-CntYHV5v.js.gz +0 -0
  65. package/dist/dashboard/assets/circle-check-BU3v_mFW.js.gz +0 -0
  66. package/dist/dashboard/assets/cli-tools-DE2ug812.js +1 -0
  67. package/dist/dashboard/assets/cli-tools-DE2ug812.js.gz +0 -0
  68. package/dist/dashboard/assets/clipboard-button-R_rArH8S.js +1 -0
  69. package/dist/dashboard/assets/clipboard-button-R_rArH8S.js.gz +0 -0
  70. package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
  71. package/dist/dashboard/assets/copy-ngmQ9zcZ.js.gz +0 -0
  72. package/dist/dashboard/assets/{dialog-iocKA3jr.js → dialog-BWIP_EvW.js} +1 -1
  73. package/dist/dashboard/assets/dialog-BWIP_EvW.js.gz +0 -0
  74. package/dist/dashboard/assets/dialog-actions-B1axpEg7.js.gz +0 -0
  75. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  76. package/dist/dashboard/assets/download-dRFo5_Wu.js.gz +0 -0
  77. package/dist/dashboard/assets/{drawer-CDKOTZdY.js → drawer-BKcgVugF.js} +1 -1
  78. package/dist/dashboard/assets/drawer-BKcgVugF.js.gz +0 -0
  79. package/dist/dashboard/assets/entry-7dpgvpJ3.js +4 -0
  80. package/dist/dashboard/assets/entry-7dpgvpJ3.js.gz +0 -0
  81. package/dist/dashboard/assets/entry-CcWkGR4a.js +1 -0
  82. package/dist/dashboard/assets/entry-CcWkGR4a.js.gz +0 -0
  83. package/dist/dashboard/assets/entry-kTiTFrvh.js +1 -0
  84. package/dist/dashboard/assets/entry-kTiTFrvh.js.gz +0 -0
  85. package/dist/dashboard/assets/eye-C_hawfH4.js.gz +0 -0
  86. package/dist/dashboard/assets/eye-off-Bk5g2luF.js.gz +0 -0
  87. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  88. package/dist/dashboard/assets/gauge-BbIMy7MB.js.gz +0 -0
  89. package/dist/dashboard/assets/github-badge-Booc5mNe.js +1 -0
  90. package/dist/dashboard/assets/github-badge-Booc5mNe.js.gz +0 -0
  91. package/dist/dashboard/assets/image-BPaouViG.js.gz +0 -0
  92. package/dist/dashboard/assets/{index-C4hXfAMs.js → index-D70pChfn.js} +2 -2
  93. package/dist/dashboard/assets/index-D70pChfn.js.gz +0 -0
  94. package/dist/dashboard/assets/{inline-DCejSLlA.js → inline-D1TCqM2D.js} +1 -1
  95. package/dist/dashboard/assets/inline-D1TCqM2D.js.gz +0 -0
  96. package/dist/dashboard/assets/{input-Bn4_kNav.js → input-B9oyU93y.js} +1 -1
  97. package/dist/dashboard/assets/input-B9oyU93y.js.gz +0 -0
  98. package/dist/dashboard/assets/jsx-runtime-CXUMngI_.js.gz +0 -0
  99. package/dist/dashboard/assets/key-round-UkavsGxp.js.gz +0 -0
  100. package/dist/dashboard/assets/layers-2-glBbjl.js.gz +0 -0
  101. package/dist/dashboard/assets/{layout-Bv-NxqZ5.js → layout-BArzXirQ.js} +1 -1
  102. package/dist/dashboard/assets/layout-BArzXirQ.js.gz +0 -0
  103. package/dist/dashboard/assets/{list-row-BLA0sldD.js → list-row-BVZzaoHF.js} +1 -1
  104. package/dist/dashboard/assets/list-row-BVZzaoHF.js.gz +0 -0
  105. package/dist/dashboard/assets/{live-YlifPYox.js → live-EmuIayqp.js} +1 -1
  106. package/dist/dashboard/assets/live-EmuIayqp.js.gz +0 -0
  107. package/dist/dashboard/assets/log-in-BmRjy60c.js.gz +0 -0
  108. package/dist/dashboard/assets/message-circle-CCxGwRvv.js.gz +0 -0
  109. package/dist/dashboard/assets/{model-bans-CVvHZ-qz.js → model-bans-CSPQ9xB0.js} +1 -1
  110. package/dist/dashboard/assets/model-bans-CSPQ9xB0.js.gz +0 -0
  111. package/dist/dashboard/assets/mutation-BkoLM5Ba.js +1 -0
  112. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  113. package/dist/dashboard/assets/numbers-CFL4_Dqc.js.gz +0 -0
  114. package/dist/dashboard/assets/pencil-768ysRAr.js.gz +0 -0
  115. package/dist/dashboard/assets/plus-DIdX_g-x.js.gz +0 -0
  116. package/dist/dashboard/assets/power-off-Yjw9GMJh.js +1 -0
  117. package/dist/dashboard/assets/power-off-Yjw9GMJh.js.gz +0 -0
  118. package/dist/dashboard/assets/privacy-mode-GM8bej85.js +1 -0
  119. package/dist/dashboard/assets/privacy-mode-GM8bej85.js.gz +0 -0
  120. package/dist/dashboard/assets/providers/agentrouter.svg.gz +0 -0
  121. package/dist/dashboard/assets/providers/aihubmix.svg.gz +0 -0
  122. package/dist/dashboard/assets/providers/alibaba-coding-plan.svg.gz +0 -0
  123. package/dist/dashboard/assets/providers/alibaba.svg.gz +0 -0
  124. package/dist/dashboard/assets/providers/antigravity.svg.gz +0 -0
  125. package/dist/dashboard/assets/providers/bai.svg.gz +0 -0
  126. package/dist/dashboard/assets/providers/blackbox.svg.gz +0 -0
  127. package/dist/dashboard/assets/providers/claude-code.svg.gz +0 -0
  128. package/dist/dashboard/assets/providers/exa.svg.gz +0 -0
  129. package/dist/dashboard/assets/providers/gmi-cloud.svg.gz +0 -0
  130. package/dist/dashboard/assets/providers/inferhub.svg.gz +0 -0
  131. package/dist/dashboard/assets/providers/muse.svg.gz +0 -0
  132. package/dist/dashboard/assets/providers/nous-research.svg.gz +0 -0
  133. package/dist/dashboard/assets/providers/openai-light.svg.gz +0 -0
  134. package/dist/dashboard/assets/providers/parallel.svg.gz +0 -0
  135. package/dist/dashboard/assets/providers/tokenharbor.svg.gz +0 -0
  136. package/dist/dashboard/assets/providers/zai-coding-plan.svg.gz +0 -0
  137. package/dist/dashboard/assets/{query-keys-ROf-g1Wi.js → query-keys-D7wX2vM5.js} +1 -1
  138. package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
  139. package/dist/dashboard/assets/quota-DZGyp0N5.js.gz +0 -0
  140. package/dist/dashboard/assets/refresh-cw-BqJFk4sU.js.gz +0 -0
  141. package/dist/dashboard/assets/rotate-ccw-CufcqITi.js.gz +0 -0
  142. package/dist/dashboard/assets/route-CNSNj9Zm.js.gz +0 -0
  143. package/dist/dashboard/assets/{routing-jntw9-Ui.js → routing-BExgxxBU.js} +1 -1
  144. package/dist/dashboard/assets/routing-BExgxxBU.js.gz +0 -0
  145. package/dist/dashboard/assets/scaling-nWE4Cx8f.js.gz +0 -0
  146. package/dist/dashboard/assets/search-JOKus9rb.js.gz +0 -0
  147. package/dist/dashboard/assets/{select-CGUuTQmj.js → select-C9mMoEpJ.js} +1 -1
  148. package/dist/dashboard/assets/select-C9mMoEpJ.js.gz +0 -0
  149. package/dist/dashboard/assets/settings-C4_Gn_wq.js +1 -0
  150. package/dist/dashboard/assets/settings-C4_Gn_wq.js.gz +0 -0
  151. package/dist/dashboard/assets/shield-check-DVS0Foqh.js.gz +0 -0
  152. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js.gz +0 -0
  153. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  154. package/dist/dashboard/assets/{stack-C1kTcOmP.js → stack-Ca5HxI5i.js} +1 -1
  155. package/dist/dashboard/assets/stack-Ca5HxI5i.js.gz +0 -0
  156. package/dist/dashboard/assets/{state-DHKdg-pm.js → state-CzUPfLXo.js} +1 -1
  157. package/dist/dashboard/assets/state-CzUPfLXo.js.gz +0 -0
  158. package/dist/dashboard/assets/stream-retry-5QnVuSSo.js.gz +0 -0
  159. package/dist/dashboard/assets/{switch-DIDeAiz9.js → switch-DOUIky_i.js} +1 -1
  160. package/dist/dashboard/assets/switch-DOUIky_i.js.gz +0 -0
  161. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +1 -0
  162. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  163. package/dist/dashboard/assets/toast-C76WrMhc.js +2 -0
  164. package/dist/dashboard/assets/toast-C76WrMhc.js.gz +0 -0
  165. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  166. package/dist/dashboard/assets/trash-Cppn5c3R.js.gz +0 -0
  167. package/dist/dashboard/assets/use-clipboard-B0Sy03fE.js.gz +0 -0
  168. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  169. package/dist/dashboard/assets/use-reduced-motion-C4B0R6Sz.js.gz +0 -0
  170. package/dist/dashboard/assets/use-timeout-BwcBBRrT.js.gz +0 -0
  171. package/dist/dashboard/assets/{useMutation-DVmwOKU-.js → useMutation-D87FZwH0.js} +1 -1
  172. package/dist/dashboard/assets/useMutation-D87FZwH0.js.gz +0 -0
  173. package/dist/dashboard/assets/wrench-B6E1oEuY.js.gz +0 -0
  174. package/dist/dashboard/index.html +1 -1
  175. package/dist/dashboard/index.html.gz +0 -0
  176. package/dist/dashboard/precompressed-assets.json +123 -0
  177. package/migrations/full-postgres/0013_add_providers_tenant_id_index.sql +22 -0
  178. package/migrations/full-postgres/0014_add_provider_accounts_tenant_id_index.sql +21 -0
  179. package/migrations/lite-sqlite3/0000_baseline.sql +4 -2
  180. package/migrations/lite-sqlite3/0013_add_providers_tenant_id_index.sql +4 -0
  181. package/migrations/lite-sqlite3/0014_add_provider_accounts_tenant_id_index.sql +4 -0
  182. package/package.json +25 -30
  183. package/scripts/cli/{carte-launcher.cjs → bin/carte.cjs} +8 -8
  184. package/scripts/cli/{cli-launcher.cjs → bin/cartethyia.cjs} +167 -134
  185. package/scripts/cli/command/admin.ts +65 -0
  186. package/scripts/cli/command/console-menu.ts +227 -0
  187. package/scripts/cli/{doctor.ts → command/doctor.ts} +257 -258
  188. package/scripts/cli/{setup.ts → command/setup.ts} +8 -12
  189. package/scripts/cli/command/start.ts +13 -0
  190. package/scripts/cli/command/tray.ts +165 -0
  191. package/scripts/cli/command/update.ts +23 -0
  192. package/scripts/cli/{runtime-state.ts → gateway/drain-token.ts} +78 -78
  193. package/scripts/cli/gateway/spawn.ts +101 -0
  194. package/scripts/cli/gateway/stop.ts +51 -0
  195. package/scripts/cli/main.ts +94 -0
  196. package/scripts/cli/support/bun.ts +27 -0
  197. package/scripts/cli/support/env.ts +99 -0
  198. package/scripts/cli/support/platform.ts +64 -0
  199. package/scripts/cli/support/services.ts +88 -0
  200. package/scripts/cli/support/startup-signal.ts +23 -0
  201. package/scripts/cli/{version.ts → support/version.ts} +1 -1
  202. package/scripts/cli/tray/hidden-window.vbs +28 -0
  203. package/scripts/cli/{tray.ts → tray/index.ts} +3 -3
  204. package/scripts/cli/{tray.ps1 → tray/windows.ps1} +107 -98
  205. package/scripts/cli/ui/background.ts +49 -0
  206. package/src/boot/app.ts +3 -1
  207. package/src/boot/dependencies.ts +36 -13
  208. package/src/config/providers.ts +27 -1
  209. package/src/config/spec.ts +11 -0
  210. package/src/console/backup/routes.ts +8 -0
  211. package/src/console/cli-tools/contracts.ts +6 -6
  212. package/src/console/console-router.ts +5 -3
  213. package/src/console/dashboard-assets.ts +81 -11
  214. package/src/console/observability/contracts.ts +54 -22
  215. package/src/console/observability/routes.ts +31 -6
  216. package/src/console/observability/store.ts +68 -28
  217. package/src/console/providers/catalog/public-model-store.ts +91 -60
  218. package/src/console/providers/catalog/store.ts +5 -1
  219. package/src/console/quota/account-quota-tenant.ts +9 -6
  220. package/src/console/response-compression.ts +69 -2
  221. package/src/console/routing/pools/probe-verdict.ts +72 -0
  222. package/src/console/share/share-router.ts +7 -4
  223. package/src/console/share/share-stats.ts +12 -5
  224. package/src/console/shared/read-through-cache.ts +133 -7
  225. package/src/console/updates/repository-updates.ts +39 -34
  226. package/src/network/outbound-fetch.ts +113 -25
  227. package/src/network/tls/bun-client-hello.ts +112 -0
  228. package/src/observability/event-log.ts +29 -8
  229. package/src/observability/metrics.ts +40 -13
  230. package/src/observability/runtime-metrics.ts +7 -0
  231. package/src/persistence/schema.ts +40 -2
  232. package/src/protocol/response/codex.ts +9 -75
  233. package/src/protocol/tool-identity.ts +43 -9
  234. package/src/providers/authentication/jwt-validator.ts +130 -187
  235. package/src/providers/authentication/oauth-flow-store.ts +16 -9
  236. package/src/providers/default-registry.ts +4 -0
  237. package/src/providers/discovery/catalog/catalog-format.ts +20 -0
  238. package/src/providers/discovery/models-dev-catalog.ts +39 -22
  239. package/src/providers/discovery/probe-wire.ts +29 -5
  240. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +3 -2
  241. package/src/providers/integrations/buddy/buddy-cli-headers.ts +88 -0
  242. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +4 -0
  243. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +33 -1
  244. package/src/providers/integrations/buddy/buddy-identity.ts +46 -0
  245. package/src/providers/integrations/buddy/buddy-oauth-shared.ts +37 -1
  246. package/src/providers/integrations/buddy/buddy-usage-store.ts +40 -5
  247. package/src/providers/integrations/buddy/codebuddy/provider.ts +2 -2
  248. package/src/providers/integrations/buddy/codebuddy/shared.ts +106 -73
  249. package/src/providers/integrations/buddy/workbuddy/cn-provider.ts +5 -2
  250. package/src/providers/integrations/buddy/workbuddy/quota.ts +172 -159
  251. package/src/providers/integrations/buddy/workbuddy/shared.ts +200 -171
  252. package/src/providers/integrations/codex/codex.ts +5 -8
  253. package/src/providers/integrations/factory/factory-oauth.ts +7 -11
  254. package/src/providers/integrations/opencode/opencode-go-quota.ts +130 -0
  255. package/src/providers/integrations/opencode/provider.ts +42 -3
  256. package/src/providers/integrations/qoder/qoder-oauth.ts +3 -9
  257. package/src/providers/integrations/shared/install-id.ts +29 -2
  258. package/src/providers/operations/client-versions.ts +11 -1
  259. package/src/providers/operations/model-catalog-cache.ts +18 -3
  260. package/src/providers/operations/provider-catalog-service.ts +76 -0
  261. package/src/transport/dispatch/attempt-finalize.ts +7 -1
  262. package/src/transport/dispatch/attempt-loop.ts +9 -7
  263. package/src/transport/dispatch/empty-stream.ts +17 -26
  264. package/src/transport/failure-policy.ts +20 -0
  265. package/src/transport/routing/route-catalog.ts +42 -3
  266. package/src/transport/surface/chat/encode.ts +18 -31
  267. package/src/transport/surface/messages/stream.ts +3 -2
  268. package/src/transport/translation/tool-repair.ts +37 -16
  269. package/src/workers/account-identity-sweep.ts +14 -12
  270. package/src/workers/daily-checkin.ts +62 -0
  271. package/src/workers/quota-refresh-sweep.ts +27 -2
  272. package/dist/dashboard/assets/CombosPage-B7ETkfVH.js +0 -1
  273. package/dist/dashboard/assets/ConsoleLogPage-AisQzCI1.js +0 -1
  274. package/dist/dashboard/assets/HealthEventsModal-BNflDm_f.js +0 -1
  275. package/dist/dashboard/assets/ModelPicker-CAiPR5Vd.js +0 -1
  276. package/dist/dashboard/assets/ProviderDetailPage-BK8i5B5X.js +0 -3
  277. package/dist/dashboard/assets/ProvidersPage-CvCVgm1e.js +0 -1
  278. package/dist/dashboard/assets/ProxyPage-DjabkuzP.js +0 -7
  279. package/dist/dashboard/assets/QuotaPage-Dxe0B0iu.js +0 -1
  280. package/dist/dashboard/assets/SetupPage-BaD4P_jW.js +0 -1
  281. package/dist/dashboard/assets/SortableList-Bol1ASmI.js +0 -1
  282. package/dist/dashboard/assets/StudioPage-CLP4le3U.js +0 -31
  283. package/dist/dashboard/assets/api-keys-CjIGQhe7.js +0 -1
  284. package/dist/dashboard/assets/app-Cjgo4-Lf.css +0 -2
  285. package/dist/dashboard/assets/cli-tools-DccaOfhX.js +0 -1
  286. package/dist/dashboard/assets/clipboard-button-DQmMikNQ.js +0 -1
  287. package/dist/dashboard/assets/entry-Cpi0rph1.js +0 -4
  288. package/dist/dashboard/assets/entry-Dm3PpDCO.js +0 -1
  289. package/dist/dashboard/assets/entry-keNDZ_Wz.js +0 -1
  290. package/dist/dashboard/assets/github-badge-BuAJr8oU.js +0 -1
  291. package/dist/dashboard/assets/mutation-BIizFZav.js +0 -1
  292. package/dist/dashboard/assets/privacy-mode-CQjbZ3xk.js +0 -1
  293. package/dist/dashboard/assets/settings-BAS6QbmJ.js +0 -1
  294. package/dist/dashboard/assets/terminal-C9mZ4Ugm.js +0 -1
  295. package/dist/dashboard/assets/toast-DKAdP2mo.js +0 -2
  296. package/scripts/cli/cli.ts +0 -771
  297. package/scripts/operations/probe-egress.ts +0 -335
  298. package/scripts/operations/restart.ts +0 -120
  299. package/scripts/shared/env.ts +0 -269
  300. /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.ico +0 -0
  301. /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.png +0 -0
  302. /package/scripts/cli/{menu.ts → ui/prompt.ts} +0 -0
  303. /package/scripts/cli/{wordmark.ts → ui/wordmark.ts} +0 -0
@@ -227,12 +227,19 @@ const TOP_IPS_LIMIT = 50;
227
227
  const HOURS_WINDOW = 24;
228
228
 
229
229
  /**
230
- * Cache lifetime for one family's rollup. Equal to the share page's 2 s SSE
231
- * tick: a viewer cannot observe staleness longer than the refresh it already
232
- * accepts, while every viewer of one link shares a single computation. Declared
233
- * here rather than imported from the router so this port does not depend on it.
230
+ * Cache lifetime for one family's rollup.
231
+ *
232
+ * Matched to the share page's SSE tick, which was the point of the old 2 s
233
+ * value — but at 2 s the cache expired on every tick, so every single push was
234
+ * a full parse of the retained telemetry window. A share page's numbers move
235
+ * when a request does, not every 2 s; 10 s still reads as live, and cuts the
236
+ * parse rate by five.
237
+ *
238
+ * Declared here rather than imported from the router so this port does not
239
+ * depend on it. `SHARE_STATS_STREAM_INTERVAL_MS` must be raised with it, or
240
+ * the two drift apart and the cache expires between ticks for no reason.
234
241
  */
235
- export const SHARE_STATS_CACHE_TTL_SECONDS = 2;
242
+ export const SHARE_STATS_CACHE_TTL_SECONDS = 10;
236
243
 
237
244
  /**
238
245
  * Cache key for one family's rollup. Both inputs change the payload — `keyIds`
@@ -10,8 +10,26 @@
10
10
  * A Redis that is slow or down must never fail a request: every cache step
11
11
  * falls through to computing the answer, which is exactly what the request
12
12
  * would have done before there was a cache.
13
+ *
14
+ * ## Validators
15
+ *
16
+ * The endpoints answer dashboard polls that repeat every few seconds while the
17
+ * underlying telemetry window changes every minute. Without a validator every
18
+ * poll ships the whole payload — 8.8 KiB to 150 KiB of it — even when nothing
19
+ * changed. An ETag turns that into a ~200-byte `304`.
20
+ *
21
+ * Strong, not weak: the hash covers the exact serialized bytes the client
22
+ * would otherwise have received, so a matching validator means byte-identical
23
+ * content. A weaker or unstable hash would answer `304` for changed data and
24
+ * leave the dashboard showing stale numbers, which is worse than the bytes it
25
+ * saves.
26
+ *
27
+ * The ETag is computed **once per write**, never per read: hashing a 150 KiB
28
+ * body on every poll would move the cost off the wire and onto the CPU.
13
29
  */
14
30
 
31
+ import { createHash } from "node:crypto";
32
+
15
33
  /** The slice of Redis this cache needs, so it can be tested without a server. */
16
34
  export interface ReadThroughCacheClient {
17
35
  get(key: string): Promise<string | null>;
@@ -25,25 +43,133 @@ export interface ReadThroughCacheClient {
25
43
  */
26
44
  export const READ_THROUGH_DEFAULT_TTL_SECONDS = 10;
27
45
 
28
- /** `undefined` when the row was never cached, so a computed `null` is cacheable. */
29
- export async function cachedRead<T>(
46
+ /** The cached value plus the strong validator for the bytes it serializes to. */
47
+ export interface CachedResult<T> {
48
+ readonly value: T;
49
+ readonly etag: string;
50
+ }
51
+
52
+ /** Quoted per RFC 9110: an entity-tag is always `"…"`. */
53
+ function etagFor(bytes: string): string {
54
+ // SHA-256 over the exact serialized bytes. Not the FNV-1a in
55
+ // `payload-store.ts` — that one is module-private, and adding a second hash
56
+ // implementation would risk two different collision profiles for one
57
+ // purpose. SHA-256 is already used in `share-stats.ts` for cache identity.
58
+ return `"${createHash("sha256").update(bytes, "utf8").digest("hex").slice(0, 32)}"`;
59
+ }
60
+
61
+ /** The sibling key holding the validator, so a cached body is never re-hashed. */
62
+ export function etagKey(key: string): string {
63
+ return `${key}:etag`;
64
+ }
65
+
66
+ /**
67
+ * Reads through the cache and returns a strong validator alongside the value.
68
+ *
69
+ * The validator is computed once, at the moment the body is written, and read
70
+ * back from its own key: a hit never hashes the payload. On a validator miss
71
+ * with a body hit — a partial eviction, or a row written by an older build —
72
+ * the body is re-hashed rather than discarded, so the pair cannot disagree.
73
+ *
74
+ * With no cache configured (`client === undefined`) the value is computed and
75
+ * the validator is still returned, so conditional requests keep working for
76
+ * the length of the client's own polling loop; only the cross-instance sharing
77
+ * is lost.
78
+ */
79
+ export async function cachedReadWithEtag<T>(
30
80
  client: ReadThroughCacheClient | undefined,
31
81
  key: string,
32
82
  compute: () => Promise<T>,
33
83
  ttlSeconds: number = READ_THROUGH_DEFAULT_TTL_SECONDS,
34
- ): Promise<T> {
35
- if (client === undefined) return compute();
84
+ ): Promise<CachedResult<T>> {
85
+ if (client === undefined) {
86
+ const fresh = await compute();
87
+ return { value: fresh, etag: etagFor(JSON.stringify(fresh) ?? "null") };
88
+ }
36
89
  try {
37
90
  const hit = await client.get(key);
38
- if (hit !== null) return JSON.parse(hit) as T;
91
+ if (hit !== null) {
92
+ const value = JSON.parse(hit) as T;
93
+ try {
94
+ const stored = await client.get(etagKey(key));
95
+ return { value, etag: stored ?? etagFor(hit) };
96
+ } catch {
97
+ return { value, etag: etagFor(hit) };
98
+ }
99
+ }
39
100
  } catch {
40
- return compute();
101
+ // A cache read must never fail a request; compute instead.
41
102
  }
42
103
  const value = await compute();
104
+ const bytes = JSON.stringify(value) ?? "null";
105
+ const etag = etagFor(bytes);
43
106
  try {
44
- await client.set(key, JSON.stringify(value), "EX", ttlSeconds);
107
+ await client.set(key, bytes, "EX", ttlSeconds);
108
+ await client.set(etagKey(key), etag, "EX", ttlSeconds);
45
109
  } catch {
46
110
  // A cache write must never fail a request that already has its answer.
47
111
  }
112
+ return { value, etag };
113
+ }
114
+
115
+ /** `undefined` when the row was never cached, so a computed `null` is cacheable. */
116
+ export async function cachedRead<T>(
117
+ client: ReadThroughCacheClient | undefined,
118
+ key: string,
119
+ compute: () => Promise<T>,
120
+ ttlSeconds: number = READ_THROUGH_DEFAULT_TTL_SECONDS,
121
+ ): Promise<T> {
122
+ const { value } = await cachedReadWithEtag(client, key, compute, ttlSeconds);
48
123
  return value;
49
124
  }
125
+
126
+ /**
127
+ * `cache-control` for a cached console read.
128
+ *
129
+ * `no-cache`, not `no-store`: the response may be stored, it simply must be
130
+ * revalidated, which is exactly what the ETag is for. `no-store` would forbid
131
+ * storing and so forbid the `304` this exists to enable.
132
+ */
133
+ const CACHE_CONTROL = "no-cache";
134
+
135
+ /**
136
+ * Answers a conditional GET from a {@link CachedResult}.
137
+ *
138
+ * A match returns a bodiless `304`. An `If-None-Match` that does not match, or
139
+ * a request that sent none, returns the body with the validator attached so
140
+ * the next poll can be the cheap one.
141
+ *
142
+ * `If-None-Match` may carry several validators or the `*` form, so the header
143
+ * is parsed rather than compared as one string.
144
+ */
145
+ export function conditionalResponse<T>(
146
+ ifNoneMatch: string | null,
147
+ result: CachedResult<T>,
148
+ body: unknown,
149
+ ): Response {
150
+ if (matchesEtag(ifNoneMatch, result.etag)) {
151
+ return new Response(null, {
152
+ status: 304,
153
+ headers: { etag: result.etag, "cache-control": CACHE_CONTROL },
154
+ });
155
+ }
156
+ const response = Response.json(body);
157
+ response.headers.set("etag", result.etag);
158
+ response.headers.set("cache-control", CACHE_CONTROL);
159
+ return response;
160
+ }
161
+
162
+ /** True when an `If-None-Match` header names this validator. */
163
+ export function matchesEtag(header: string | null, etag: string): boolean {
164
+ if (header === null) return false;
165
+ const trimmed = header.trim();
166
+ if (trimmed === "*") return true;
167
+ for (const part of trimmed.split(",")) {
168
+ // The weak prefix is stripped: a weak validator still names the same
169
+ // representation, and the client that sent it asked to be told if the
170
+ // current one matches.
171
+ const candidate = part.trim().replace(/^W\//, "");
172
+ if (candidate === etag) return true;
173
+ }
174
+ return false;
175
+ }
@@ -1,34 +1,39 @@
1
- /**
2
- * Public release discovery for the console's update indicator.
3
- *
4
- * The browser never talks to GitHub directly: a dashboard-side fetch would put
5
- * every console user's GitHub rate limit behind one shared, unauthenticated
6
- * origin, and a CORS or rate-limit failure would surface as a console error
7
- * nobody can act on. The request is made here, server-side, and any failure
8
- * degrades to "no update known" rather than an error the operator must read.
9
- */
10
- const REPOSITORY = "risunCode/Cartethyia";
11
-
12
- /** Timeout for one GitHub read. A hung read must not hold a console query open. */
13
- const REQUEST_TIMEOUT_MS = 10_000;
14
-
15
- export interface LatestRelease {
16
- readonly tag_name: string;
17
- readonly html_url: string;
18
- }
19
-
20
- /** Fetches the latest public release. Returns `null` when none is known. */
21
- export async function fetchLatestRelease(): Promise<LatestRelease | null> {
22
- try {
23
- const response = await fetch(`https://api.github.com/repos/${REPOSITORY}/releases/latest`, {
24
- headers: { accept: "application/vnd.github+json", "user-agent": "Cartethyia-updater" },
25
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
26
- });
27
- if (!response.ok) return null;
28
- const value = (await response.json()) as { readonly tag_name?: unknown; readonly html_url?: unknown };
29
- if (typeof value.tag_name !== "string" || typeof value.html_url !== "string") return null;
30
- return { tag_name: value.tag_name, html_url: value.html_url };
31
- } catch {
32
- return null;
33
- }
34
- }
1
+ /**
2
+ * Public release discovery for the console's update indicator.
3
+ *
4
+ * The browser never talks to GitHub directly: a dashboard-side fetch would put
5
+ * every console user's GitHub rate limit behind one shared, unauthenticated
6
+ * origin, and a CORS or rate-limit failure would surface as a console error
7
+ * nobody can act on. The request is made here, server-side, and any failure
8
+ * degrades to "no update known" rather than an error the operator must read.
9
+ *
10
+ * Releases are read from the ALTERNATIVE repository (`risunCodex`), not the
11
+ * primary: the primary repository is hidden from public view, so its releases
12
+ * endpoint answers 404 and the console would report "no update known" forever —
13
+ * a silent failure that reads exactly like "you are up to date".
14
+ */
15
+ const REPOSITORY = "risunCodex/Cartethyia";
16
+
17
+ /** Timeout for one GitHub read. A hung read must not hold a console query open. */
18
+ const REQUEST_TIMEOUT_MS = 10_000;
19
+
20
+ export interface LatestRelease {
21
+ readonly tag_name: string;
22
+ readonly html_url: string;
23
+ }
24
+
25
+ /** Fetches the latest public release. Returns `null` when none is known. */
26
+ export async function fetchLatestRelease(): Promise<LatestRelease | null> {
27
+ try {
28
+ const response = await fetch(`https://api.github.com/repos/${REPOSITORY}/releases/latest`, {
29
+ headers: { accept: "application/vnd.github+json", "user-agent": "Cartethyia-updater" },
30
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
31
+ });
32
+ if (!response.ok) return null;
33
+ const value = (await response.json()) as { readonly tag_name?: unknown; readonly html_url?: unknown };
34
+ if (typeof value.tag_name !== "string" || typeof value.html_url !== "string") return null;
35
+ return { tag_name: value.tag_name, html_url: value.html_url };
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
@@ -9,6 +9,7 @@ import type { SsrfPolicy } from "../config/network";
9
9
  import { isProxyAgentPair, type ProxyAgentPair } from "./pool/agent";
10
10
  import { metrics } from "../observability/metrics";
11
11
  import { trackNetworkCall } from "../observability/performance-metrics";
12
+ import { bunClientHelloOptions } from "./tls/bun-client-hello";
12
13
  import { stripResponseHeaders } from "./response-headers";
13
14
  // Validated fetch — single egress with ProxyAgent support
14
15
  export interface ValidatedFetchOptions {
@@ -17,6 +18,14 @@ export interface ValidatedFetchOptions {
17
18
  readonly maxRedirects?: number;
18
19
  readonly resolveFn?: (hostname: string, signal: AbortSignal) => Promise<readonly string[]>;
19
20
  readonly agent?: HttpAgent | HttpsAgent | ProxyAgentPair;
21
+ /**
22
+ * Handshake with Bun's cipher list, curve list and ALPN instead of
23
+ * OpenSSL's defaults. Opt-in, and off by default: see
24
+ * `network/tls/bun-client-hello.ts` — ciphers and curves match exactly, but
25
+ * the extension list cannot be shaped from Node, so this is a closer hello
26
+ * rather than an identical one.
27
+ */
28
+ readonly bunClientHello?: boolean;
20
29
  }
21
30
  export type ValidatedFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
22
31
 
@@ -34,6 +43,57 @@ function agentForTarget(
34
43
  return agent;
35
44
  }
36
45
 
46
+ /**
47
+ * Keep-alive options for the direct (non-pool) egress agents.
48
+ *
49
+ * Matched to `network/pool/agent.ts`, which builds pool agents from
50
+ * `resolveProxyMaxSockets()` / `resolveProxyMaxFreeSockets()` and
51
+ * `PROXY_KEEP_ALIVE_TIMEOUT_MS`. Direct egress has no per-pool config to
52
+ * read, so it uses the documented defaults those resolvers return.
53
+ */
54
+ const DIRECT_AGENT_OPTIONS = {
55
+ keepAlive: true,
56
+ maxSockets: 100,
57
+ maxFreeSockets: 20,
58
+ timeout: 60_000,
59
+ } as const;
60
+
61
+ /**
62
+ * An agent whose socket key includes the SSRF-validated address.
63
+ *
64
+ * The direct (non-pool) path pins egress by passing a `lookup` that returns the
65
+ * one validated address, but `Agent#getName` — the keep-alive socket key —
66
+ * hashes only host/port/localAddress/family. Two requests to the same
67
+ * `host:port` that validated to *different* addresses therefore share a pooled
68
+ * socket, and the second one silently egresses to the first one's address:
69
+ * the pin is bypassed without any error surfacing.
70
+ *
71
+ * Including the address in the key makes each validated address its own socket
72
+ * bucket, so a re-resolved address always dials fresh while the *same* address
73
+ * still reuses its socket (keep-alive is preserved, not defeated).
74
+ *
75
+ * This is a real gap, not a theoretical one: with no agent at all the request
76
+ * falls back to the global agent, which has the same key shape *and* is shared
77
+ * with unrelated traffic.
78
+ */
79
+ class PinnedAddressAgent extends HttpAgent {
80
+ override getName(options: Record<string, unknown> = {}): string {
81
+ const base = super.getName(options as never);
82
+ const pinned = (options["lookup"] as { pinnedAddress?: string } | undefined)?.pinnedAddress;
83
+ return `${base}:${pinned ?? "-"}`;
84
+ }
85
+ }
86
+ class PinnedAddressHttpsAgent extends HttpsAgent {
87
+ override getName(options: Record<string, unknown> = {}): string {
88
+ const base = super.getName(options as never);
89
+ const pinned = (options["lookup"] as { pinnedAddress?: string } | undefined)?.pinnedAddress;
90
+ return `${base}:${pinned ?? "-"}`;
91
+ }
92
+ }
93
+
94
+ const DIRECT_HTTP_AGENT = new PinnedAddressAgent(DIRECT_AGENT_OPTIONS);
95
+ const DIRECT_HTTPS_AGENT = new PinnedAddressHttpsAgent(DIRECT_AGENT_OPTIONS);
96
+
37
97
  /**
38
98
  * Builds the application-relay request used by hosted relay front doors.
39
99
  * These endpoints intentionally do not receive CONNECT: they accept the
@@ -99,10 +159,12 @@ function decompressBody(
99
159
  return { stream: body.pipe(decoder), encoding: undefined };
100
160
  }
101
161
 
102
- async function pinnedFetch( url: URL,
162
+ async function pinnedFetch(
163
+ url: URL,
103
164
  init: RequestInit,
104
165
  resolvedAddress: string | undefined,
105
166
  agent?: HttpAgent | HttpsAgent,
167
+ useBunHello = false,
106
168
  ): Promise<Response> {
107
169
  const request = url.protocol === "https:" ? httpsRequest : httpRequest;
108
170
  const headers = new Headers(init.headers);
@@ -126,27 +188,38 @@ async function pinnedFetch( url: URL,
126
188
  outboundHeaders["content-length"] =
127
189
  typeof body === "string" ? String(Buffer.byteLength(body, "utf8")) : String(body.byteLength);
128
190
  }
129
- const lookup = ((
130
- _hostname: string,
131
- options: { all?: boolean; family?: number } | number,
132
- callback: (
133
- error: Error | null,
134
- address?: string | Array<{ address: string; family: number }>,
135
- family?: number,
136
- ) => void,
137
- ): void => {
138
- if (resolvedAddress === undefined) {
139
- callback(new Error("no validated address available"));
140
- return;
141
- }
142
- const opts = typeof options === "number" ? { family: options } : options;
143
- const family = resolvedAddress.includes(":") ? 6 : 4;
144
- if (opts?.all) {
145
- callback(null, [{ address: resolvedAddress, family }] as never);
146
- return;
147
- }
148
- callback(null, resolvedAddress, family);
149
- }) as unknown as NonNullable<RequestOptions["lookup"]>;
191
+ const lookup = Object.assign(
192
+ (
193
+ _hostname: string,
194
+ options: { all?: boolean; family?: number } | number,
195
+ callback: (
196
+ error: Error | null,
197
+ address?: string | Array<{ address: string; family: number }>,
198
+ family?: number,
199
+ ) => void,
200
+ ): void => {
201
+ if (resolvedAddress === undefined) {
202
+ callback(new Error("no validated address available"));
203
+ return;
204
+ }
205
+ const opts = typeof options === "number" ? { family: options } : options;
206
+ const family = resolvedAddress.includes(":") ? 6 : 4;
207
+ if (opts?.all) {
208
+ callback(null, [{ address: resolvedAddress, family }] as never);
209
+ return;
210
+ }
211
+ callback(null, resolvedAddress, family);
212
+ },
213
+ // Read by `PinnedAddressAgent#getName` so the keep-alive socket key
214
+ // carries the validated address. See the class comment for why.
215
+ { pinnedAddress: resolvedAddress },
216
+ ) as unknown as NonNullable<RequestOptions["lookup"]>;
217
+ // Direct egress gets a dedicated agent as well as the pinning `lookup`.
218
+ // Passing only `lookup` left the request on the global agent, whose socket
219
+ // key ignores `lookup` — so a re-resolved address could reuse a socket
220
+ // dialed against an earlier one and egress past the SSRF check. The direct
221
+ // agent keys its sockets on the pinned address, so the pin holds.
222
+ const directAgent = url.protocol === "https:" ? DIRECT_HTTPS_AGENT : DIRECT_HTTP_AGENT;
150
223
  return new Promise<Response>((resolve, reject) => {
151
224
  const nodeRequest = request(
152
225
  {
@@ -157,8 +230,17 @@ async function pinnedFetch( url: URL,
157
230
  method: init.method ?? "GET",
158
231
  headers: outboundHeaders,
159
232
  signal: init.signal ?? undefined,
160
- ...(agent ? { agent } : { lookup }),
233
+ ...(agent ? { agent } : { agent: directAgent, lookup }),
161
234
  ...(url.protocol === "https:" ? { servername: url.hostname } : {}),
235
+ // Bun's cipher list, curve list, ALPN and version span, when this
236
+ // request opted into it. See `network/tls/bun-client-hello.ts` for
237
+ // what this does and does not match: ciphers and curves land exactly;
238
+ // the extension list cannot be shaped from Node at all.
239
+ //
240
+ // Opt-in, and off by default. It changes the TLS handshake of every
241
+ // request it touches, and no provider needs it until the free-tier
242
+ // gate is shown to reject the default hello.
243
+ ...(useBunHello && url.protocol === "https:" ? bunClientHelloOptions() : {}),
162
244
  },
163
245
  (response) => {
164
246
  const responseHeaders = stripResponseHeaders(response.headers);
@@ -230,7 +312,7 @@ export function createValidatedFetch(options: ValidatedFetchOptions = {}): Valid
230
312
  // The egress dials the pool/relay, which resolves the target itself.
231
313
  // Counting these lets ops confirm how much traffic depends on
232
314
  // proxy-resolved DNS rather than the local system resolver.
233
- metrics.cartethyia_proxy_dial_dns_fallback_total.inc();
315
+ metrics.cartethyia_proxy_dial_proxy_resolved_total.inc();
234
316
  }
235
317
  try {
236
318
  if (!poolDialsTarget) {
@@ -263,7 +345,13 @@ export function createValidatedFetch(options: ValidatedFetchOptions = {}): Valid
263
345
  response = await options.fetchFn(url, requestInit);
264
346
  } else {
265
347
  const agent = agentForTarget(configuredAgent, url.protocol);
266
- response = await pinnedFetch(url, requestInit, resolvedAddress!, agent);
348
+ response = await pinnedFetch(
349
+ url,
350
+ requestInit,
351
+ resolvedAddress!,
352
+ agent,
353
+ options.bunClientHello === true,
354
+ );
267
355
  }
268
356
  if (response.status < 300 || response.status >= 400) {
269
357
  trackNetworkCall(url.hostname, performance.now() - startedAt);
@@ -0,0 +1,112 @@
1
+ import type { Socket } from "node:net";
2
+ import { connect as tlsConnect, type ConnectionOptions } from "node:tls";
3
+
4
+ /**
5
+ * A TLS ClientHello that matches Bun's fetch, for the paths that cannot use
6
+ * Bun's own `fetch`.
7
+ *
8
+ * `opencode2api` impersonates OpenCode by impersonating **Bun** — the official
9
+ * clients are Bun programs, and the free-tier gate fingerprints the TLS
10
+ * ClientHello, not just headers. It needed `utls` to do that, because Go's
11
+ * `crypto/tls` cannot change its hello. We are on Bun, so `fetch()` already
12
+ * emits the right one — but our outbound path is `node:https`, which emits
13
+ * OpenSSL's.
14
+ *
15
+ * This module narrows the gap on the Node path. **It does not close it**, and
16
+ * the shortfall is stated plainly below because it decides whether this is
17
+ * worth using at all.
18
+ *
19
+ * What it matches, measured:
20
+ *
21
+ * | Field | Bun | This module |
22
+ * |---|---|---|
23
+ * | Cipher count | 17 | **17** |
24
+ * | Cipher order | `1301-1302-1303-c02b-c02f-c02c-c030-cca9-cca8-c009-c013-c00a-c014-009c-009d-002f-0035` | **identical** |
25
+ * | Curves | `4588,29,23,24` | **`4588,29,23,24`** |
26
+ * | ALPN | `http/1.1` only | **`http/1.1` only** |
27
+ * | TLS span | 1.2–1.3 | **1.2–1.3** |
28
+ * | Extensions | 13, order `0,23,65281,10,11,35,16,5,13,18,51,45,43` | **11, `0,23,65281,10,11,35,16,13,51,45,43`** |
29
+ *
30
+ * The extension list is the part Node cannot reach. The first seven match
31
+ * exactly, then it diverges: `status_request` (5, OCSP stapling) and
32
+ * `signed_certificate_timestamp` (18, SCT) are absent, and BoringSSL sends
33
+ * both between ALPN and `signature_algorithms`. Neither has a Node option.
34
+ * No extension we send is foreign to Bun's — we send a subset, not a
35
+ * different set.
36
+ *
37
+ * **So this is a closer hello, not an identical one.** If the gate's check is
38
+ * JA3/JA4-shaped — and the reference gateway's own comment names JA4
39
+ * `t13d1713h1`, which is computed from the extension list — this will not pass
40
+ * it. Use `fetch` through a CONNECT bridge (`outbound-fetch.ts`) where an exact
41
+ * match is required; that path measures identical, all 13 extensions in order.
42
+ *
43
+ * Kept because it is still strictly better than the OpenSSL default (52 ciphers
44
+ * in OpenSSL's order) for any check that reads ciphers or curves without hashing
45
+ * the whole extension list, and because it costs one function call.
46
+ */
47
+
48
+ /**
49
+ * Bun's cipher list, in wire order.
50
+ *
51
+ * OpenSSL names, because that is what Node accepts — the `TLS_*` spellings
52
+ * resolve to only 3 suites here (the rest are unavailable to OpenSSL), which is
53
+ * why an earlier attempt at this produced a 3-cipher hello that was *further*
54
+ * from Bun than doing nothing.
55
+ */
56
+ export const BUN_CIPHER_SUITES =
57
+ "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:" +
58
+ "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:" +
59
+ "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:" +
60
+ "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:" +
61
+ "ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA:" +
62
+ "ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:" +
63
+ "AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA:AES256-SHA";
64
+
65
+ /**
66
+ * Bun's supported groups, in order: the post-quantum hybrid first, then
67
+ * X25519, P-256, P-384 — and nothing else. Measured identical to Bun.
68
+ */
69
+ export const BUN_SUPPORTED_GROUPS = "X25519MLKEM768:X25519:P-256:P-384";
70
+
71
+ /**
72
+ * Options that make a Node TLS connection handshake with Bun's cipher list,
73
+ * curve list, ALPN and version span.
74
+ *
75
+ * Spread into `tls.connect` or `https.request`. Does not touch the extension
76
+ * list, which Node does not expose.
77
+ */
78
+ export function bunClientHelloOptions(): Pick<
79
+ ConnectionOptions,
80
+ "ciphers" | "ecdhCurve" | "ALPNProtocols" | "minVersion" | "maxVersion"
81
+ > {
82
+ return {
83
+ ciphers: BUN_CIPHER_SUITES,
84
+ ecdhCurve: BUN_SUPPORTED_GROUPS,
85
+ // One protocol, like Bun. Offering `h2` here would change the ALPN
86
+ // extension and the negotiated protocol.
87
+ ALPNProtocols: ["http/1.1"],
88
+ minVersion: "TLSv1.2",
89
+ maxVersion: "TLSv1.3",
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Upgrades an already-connected socket to TLS with Bun's parameters.
95
+ *
96
+ * The socket is the point: it lets a SOCKS5 `SocksProxyAgent` own the dial
97
+ * (keeping its SSRF `lookup`) while this controls the hello. Bun's `fetch`
98
+ * cannot do that combination — it refuses SOCKS5 outright.
99
+ */
100
+ export function bunTlsOverSocket(
101
+ socket: Socket,
102
+ servername: string,
103
+ extra: Omit<ConnectionOptions, "socket" | "servername"> = {},
104
+ ) {
105
+ return tlsConnect({
106
+ socket,
107
+ servername,
108
+ rejectUnauthorized: true,
109
+ ...bunClientHelloOptions(),
110
+ ...extra,
111
+ });
112
+ }
@@ -176,19 +176,37 @@ export async function readTelemetryEvents(): Promise<TelemetryEventRecord[]> {
176
176
  export interface EventQuery {
177
177
  readonly tenantId?: string;
178
178
  readonly since?: Date;
179
+ /**
180
+ * Exclusive upper bound on `createdAt`, used to push a pagination cursor into
181
+ * the query instead of filtering after the whole window was parsed. Without
182
+ * it a cursor page drops `limit` — the reader cannot know where the page
183
+ * ends — so only the first page of a list is bounded.
184
+ */
185
+ readonly until?: Date;
179
186
  readonly apiKeyIds?: readonly string[];
180
187
  readonly accountIds?: readonly string[];
181
188
  readonly requestId?: string;
189
+ /**
190
+ * Distinct from {@link requestId}: `id` defaults to the request id when a row
191
+ * does not carry one of its own (`event-log.ts:68`), so a row can match by
192
+ * either. A lookup by `id` short-circuits like `requestId` does, which is the
193
+ * whole point — without it a single-event detail read parses the retention
194
+ * window and then scans it in JS.
195
+ */
196
+ readonly id?: string;
182
197
  readonly httpStatus?: number;
183
198
  readonly limit?: number;
184
199
  }
185
200
 
186
- function matches(event: TelemetryEventRecord, query: EventQuery, since: number | undefined, keys: ReadonlySet<string> | undefined, accounts: ReadonlySet<string> | undefined): boolean {
201
+ function matches(event: TelemetryEventRecord, query: EventQuery, since: number | undefined, until: number | undefined, keys: ReadonlySet<string> | undefined, accounts: ReadonlySet<string> | undefined): boolean {
187
202
  if (query.tenantId !== undefined && event.tenantId !== query.tenantId) return false;
188
- if (since !== undefined && new Date(event.createdAt).getTime() < since) return false;
203
+ const at = new Date(event.createdAt).getTime();
204
+ if (since !== undefined && at < since) return false;
205
+ if (until !== undefined && at > until) return false;
189
206
  if (keys && (event.apiKeyId === null || !keys.has(event.apiKeyId))) return false;
190
207
  if (accounts && (event.accountId === null || !accounts.has(event.accountId))) return false;
191
208
  if (query.requestId !== undefined && event.requestId !== query.requestId) return false;
209
+ if (query.id !== undefined && event.id !== query.id) return false;
192
210
  if (query.httpStatus !== undefined && event.httpStatus !== query.httpStatus) return false;
193
211
  return true;
194
212
  }
@@ -199,6 +217,7 @@ export async function queryTelemetryEvents(query: EventQuery): Promise<Telemetry
199
217
  .filter((file) => file.endsWith(".jsonl") && (query.since === undefined || eventFileHour(file) >= hourStamp(query.since)))
200
218
  .sort((a, b) => (a < b ? 1 : a > b ? -1 : 0));
201
219
  const since = query.since?.getTime();
220
+ const until = query.until?.getTime();
202
221
  const keys = query.apiKeyIds ? new Set(query.apiKeyIds) : undefined;
203
222
  const accounts = query.accountIds ? new Set(query.accountIds) : undefined;
204
223
  const events: TelemetryEventRecord[] = [];
@@ -207,19 +226,21 @@ export async function queryTelemetryEvents(query: EventQuery): Promise<Telemetry
207
226
  // field, and a false negative is not possible while a false positive costs
208
227
  // only the parse it would have paid anyway.
209
228
  const allow =
210
- query.tenantId === undefined && query.requestId === undefined
229
+ query.tenantId === undefined && query.requestId === undefined && query.id === undefined
211
230
  ? undefined
212
231
  : (line: string): boolean =>
213
232
  (query.tenantId === undefined || line.includes(query.tenantId)) &&
214
- (query.requestId === undefined || line.includes(query.requestId));
233
+ (query.requestId === undefined || line.includes(query.requestId)) &&
234
+ (query.id === undefined || line.includes(query.id));
235
+ // A single-row lookup: stop at the first (newest) match instead of parsing
236
+ // the whole retention window.
237
+ const oneRow = query.requestId !== undefined || query.id !== undefined;
215
238
  for (const file of files) {
216
239
  const matched: TelemetryEventRecord[] = [];
217
240
  for await (const event of readEventFiles([file], allow)) {
218
- if (!matches(event, query, since, keys, accounts)) continue;
241
+ if (!matches(event, query, since, until, keys, accounts)) continue;
219
242
  matched.push(event);
220
- // A requestId lookup needs one row: stop at the first (newest) match
221
- // instead of parsing the whole retention window.
222
- if (query.requestId !== undefined) return matched;
243
+ if (oneRow) return matched;
223
244
  }
224
245
  matched.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
225
246
  events.push(...matched);