cartethyia 2.4.6 → 2.5.1

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/README.md +45 -13
  2. package/dist/dashboard/assets/BannedPage-ChqnTRpZ.js +1 -0
  3. package/dist/dashboard/assets/BannedPage-ChqnTRpZ.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-Bcy150Jo.js → CliToolDetailPage-Bb9CNuoh.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-Bb9CNuoh.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-DyNXefph.js → CliToolsPage-BXUaRCQQ.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-BXUaRCQQ.js.gz +0 -0
  8. package/dist/dashboard/assets/CombosPage-CisNZOUt.js +1 -0
  9. package/dist/dashboard/assets/CombosPage-CisNZOUt.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CATpAEOz.js → ConsoleLogPage-BOpI_LOH.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-BOpI_LOH.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-D6jEU7nL.js → CustomizationPage-DAkdvJ4k.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-DAkdvJ4k.js.gz +0 -0
  14. package/dist/dashboard/assets/{HealthEventsModal-CZkpn0a7.js → HealthEventsModal-DeT-VXHd.js} +1 -1
  15. package/dist/dashboard/assets/HealthEventsModal-DeT-VXHd.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-BJlMVN4U.js → LoginPage-CytxSeGP.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-CytxSeGP.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-Br3CXEau.js → ModelPicker-oQ-SDXp8.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-CU7S1wJs.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-CU7S1wJs.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-DLm2baot.js +3 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-DLm2baot.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +1 -0
  25. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  26. package/dist/dashboard/assets/ProvidersPage-DOXfuzfL.js +1 -0
  27. package/dist/dashboard/assets/ProvidersPage-DOXfuzfL.js.gz +0 -0
  28. package/dist/dashboard/assets/ProxyPage-wvk0ciQN.js +7 -0
  29. package/dist/dashboard/assets/ProxyPage-wvk0ciQN.js.gz +0 -0
  30. package/dist/dashboard/assets/QuotaPage-Cn_fcUHE.js +1 -0
  31. package/dist/dashboard/assets/QuotaPage-Cn_fcUHE.js.gz +0 -0
  32. package/dist/dashboard/assets/{SettingsPage-BHW4Zt06.js → SettingsPage-8-eVadhc.js} +1 -1
  33. package/dist/dashboard/assets/SettingsPage-8-eVadhc.js.gz +0 -0
  34. package/dist/dashboard/assets/{SetupPage-VOYC_lIa.js → SetupPage-uEIXPyGO.js} +1 -1
  35. package/dist/dashboard/assets/SetupPage-uEIXPyGO.js.gz +0 -0
  36. package/dist/dashboard/assets/SortableList-DCY0TPTZ.js +1 -0
  37. package/dist/dashboard/assets/SortableList-DCY0TPTZ.js.gz +0 -0
  38. package/dist/dashboard/assets/{StudioPage-BNIDLiU6.js → StudioPage-DqK5E26W.js} +4 -4
  39. package/dist/dashboard/assets/StudioPage-DqK5E26W.js.gz +0 -0
  40. package/dist/dashboard/assets/UsagePage-38JfiZ0H.js +56 -0
  41. package/dist/dashboard/assets/UsagePage-38JfiZ0H.js.gz +0 -0
  42. package/dist/dashboard/assets/{api-keys-BETfaMYZ.js → api-keys-BmDbsior.js} +1 -1
  43. package/dist/dashboard/assets/api-keys-BmDbsior.js.gz +0 -0
  44. package/dist/dashboard/assets/app-BbpSarVV.css +2 -0
  45. package/dist/dashboard/assets/app-BbpSarVV.css.gz +0 -0
  46. package/dist/dashboard/assets/assert-BHfaei-X.js +1 -0
  47. package/dist/dashboard/assets/assert-BHfaei-X.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-BTyGk_DL.js → badge-DpXT4Ixt.js} +1 -1
  49. package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-bujTWYM4.js → banner-CSAAWLJv.js} +1 -1
  51. package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-B0Bnx5CA.js → card-DEhRXXw4.js} +1 -1
  53. package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-DE2ug812.js → cli-tools-B0wBq-RL.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-R_rArH8S.js → clipboard-button-DOo6EU8O.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-BWIP_EvW.js → dialog-BvQeBMfP.js} +1 -1
  59. package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-BKcgVugF.js → drawer-CMwPXwQB.js} +1 -1
  61. package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
  62. package/dist/dashboard/assets/entry--yj84zk3.js +4 -0
  63. package/dist/dashboard/assets/entry--yj84zk3.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-CcWkGR4a.js → entry-D4O2229X.js} +1 -1
  65. package/dist/dashboard/assets/{entry-CcWkGR4a.js.gz → entry-D4O2229X.js.gz} +0 -0
  66. package/dist/dashboard/assets/{entry-kTiTFrvh.js → entry-DKvr5Q9K.js} +1 -1
  67. package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
  68. package/dist/dashboard/assets/{github-badge-Booc5mNe.js → github-badge-zliLvBwr.js} +1 -1
  69. package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-D70pChfn.js → index-CcD1eSJ6.js} +2 -2
  71. package/dist/dashboard/assets/index-CcD1eSJ6.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-D1TCqM2D.js → inline-D1IlZZ8H.js} +1 -1
  73. package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-B9oyU93y.js → input-BpVZdrbc.js} +1 -1
  75. package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-BArzXirQ.js → layout-BioLW7gN.js} +1 -1
  77. package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-BVZzaoHF.js → list-row-CwVcGAIc.js} +1 -1
  79. package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-EmuIayqp.js → live-yfEJFF9T.js} +1 -1
  81. package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
  82. package/dist/dashboard/assets/{model-bans-CSPQ9xB0.js → model-bans-CT-TxSNX.js} +1 -1
  83. package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
  84. package/dist/dashboard/assets/providers/cursor.webp +0 -0
  85. package/dist/dashboard/assets/providers/trae.webp +0 -0
  86. package/dist/dashboard/assets/{routing-BExgxxBU.js → routing-Cs6jl49L.js} +1 -1
  87. package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
  88. package/dist/dashboard/assets/select-XPJ-dIw3.js +41 -0
  89. package/dist/dashboard/assets/select-XPJ-dIw3.js.gz +0 -0
  90. package/dist/dashboard/assets/settings-CfcTcOyU.js +1 -0
  91. package/dist/dashboard/assets/settings-CfcTcOyU.js.gz +0 -0
  92. package/dist/dashboard/assets/{stack-Ca5HxI5i.js → stack-C9_uUjaA.js} +1 -1
  93. package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
  94. package/dist/dashboard/assets/{state-CzUPfLXo.js → state-DA0B95UQ.js} +1 -1
  95. package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
  96. package/dist/dashboard/assets/{switch-DOUIky_i.js → switch-Cittxy-x.js} +1 -1
  97. package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
  98. package/dist/dashboard/assets/{toast-C76WrMhc.js → toast-CNt_jzO4.js} +1 -1
  99. package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
  100. package/dist/dashboard/assets/{useMutation-D87FZwH0.js → useMutation-BQKMcJ1H.js} +1 -1
  101. package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
  102. package/dist/dashboard/index.html +1 -1
  103. package/dist/dashboard/index.html.gz +0 -0
  104. package/dist/dashboard/precompressed-assets.json +51 -52
  105. package/migrations/full-postgres/0015_add_payload_stages.sql +18 -0
  106. package/migrations/full-postgres/0016_add_share_link_slug.sql +20 -0
  107. package/migrations/full-postgres/0017_add_models_pro_only.sql +18 -0
  108. package/migrations/full-postgres/0018_add_accounts_quota_plan.sql +18 -0
  109. package/migrations/full-postgres/0019_add_models_free_tier.sql +15 -0
  110. package/migrations/lite-sqlite3/0015_add_payload_stages.sql +18 -0
  111. package/migrations/lite-sqlite3/0016_add_share_link_slug.sql +20 -0
  112. package/migrations/lite-sqlite3/0017_add_models_pro_only.sql +18 -0
  113. package/migrations/lite-sqlite3/0018_add_accounts_quota_plan.sql +18 -0
  114. package/migrations/lite-sqlite3/0019_add_models_free_tier.sql +15 -0
  115. package/package.json +3 -2
  116. package/scripts/cli/command/console-menu.ts +30 -11
  117. package/scripts/cli/command/start.ts +77 -13
  118. package/scripts/cli/command/tray.ts +47 -8
  119. package/scripts/cli/command/update.ts +1 -1
  120. package/scripts/cli/gateway/instance-lock.ts +105 -0
  121. package/scripts/cli/gateway/spawn.ts +173 -101
  122. package/scripts/cli/ui/background.ts +110 -49
  123. package/src/boot/app.ts +20 -6
  124. package/src/boot/dependencies.ts +19 -0
  125. package/src/boot/elysia-typebox-bridge.ts +37 -0
  126. package/src/boot/lifecycle.ts +2 -0
  127. package/src/config/cooldowns.ts +138 -138
  128. package/src/config/providers.ts +5 -5
  129. package/src/config/runtime.ts +19 -3
  130. package/src/config/spec.ts +9 -0
  131. package/src/console/api-keys/contracts.ts +414 -391
  132. package/src/console/api-keys/routes.ts +880 -870
  133. package/src/console/auth/account-reset-store.ts +14 -0
  134. package/src/console/auth/account-reset.ts +3 -2
  135. package/src/console/auth/setup-service.ts +6 -3
  136. package/src/console/domain-registration.ts +9 -2
  137. package/src/console/observability/contracts.ts +475 -455
  138. package/src/console/observability/store.ts +1015 -952
  139. package/src/console/providers/catalog/contracts.ts +31 -0
  140. package/src/console/providers/catalog/provider-operations.ts +8 -0
  141. package/src/console/providers/catalog/store.ts +1532 -1525
  142. package/src/console/providers/oauth/callback-listener.ts +18 -3
  143. package/src/console/providers/oauth/routes.ts +38 -15
  144. package/src/console/quota/refresh.ts +19 -0
  145. package/src/console/updates/routes.ts +162 -4
  146. package/src/main.ts +3 -0
  147. package/src/network/http2-bidi.ts +85 -0
  148. package/src/network/outbound-fetch.ts +1 -26
  149. package/src/network/pool/agent.ts +478 -460
  150. package/src/network/pool/resolver.ts +1104 -544
  151. package/src/network/pool-health-machine.ts +12 -5
  152. package/src/observability/payload-capture.ts +344 -324
  153. package/src/persistence/db-pg.ts +9 -3
  154. package/src/persistence/db-sqlite.ts +497 -495
  155. package/src/persistence/health-event-flush.ts +137 -0
  156. package/src/persistence/migrate.ts +181 -129
  157. package/src/persistence/schema.ts +1360 -1295
  158. package/src/persistence/settings-revision.ts +25 -0
  159. package/src/persistence/shared-cache.ts +217 -0
  160. package/src/persistence/stores/share-store.ts +556 -528
  161. package/src/persistence/usage-flush.ts +61 -1
  162. package/src/protocol/canonical-model.ts +23 -1
  163. package/src/protocol/response/chat.ts +143 -10
  164. package/src/protocol/response/omp-tool-call-markup.ts +221 -0
  165. package/src/providers/default-registry.ts +173 -1
  166. package/src/providers/discovery/openai-model-discovery.ts +39 -3
  167. package/src/providers/discovery/openrouter-free-roster.ts +98 -0
  168. package/src/providers/discovery/probing-service.ts +1022 -1001
  169. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +39 -0
  170. package/src/providers/integrations/buddy/buddy-chat-shared.ts +913 -914
  171. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +18 -5
  172. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +4 -0
  173. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -175
  174. package/src/providers/integrations/buddy/codebuddy/shared.ts +15 -6
  175. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -204
  176. package/src/providers/integrations/buddy/workbuddy/shared.ts +3 -3
  177. package/src/providers/integrations/claude/claude.ts +606 -582
  178. package/src/providers/integrations/cline/cline-quota.ts +18 -16
  179. package/src/providers/integrations/cline/cline.ts +539 -468
  180. package/src/providers/integrations/commandcode/commandcode.ts +675 -541
  181. package/src/providers/integrations/commandcode/discovery.ts +116 -0
  182. package/src/providers/integrations/commandcode/quota.ts +160 -0
  183. package/src/providers/integrations/cursor/cursor-auth.ts +418 -0
  184. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -0
  185. package/src/providers/integrations/cursor/cursor-http2.ts +300 -0
  186. package/src/providers/integrations/cursor/cursor-identity.ts +92 -0
  187. package/src/providers/integrations/cursor/cursor-request.ts +471 -0
  188. package/src/providers/integrations/cursor/cursor-session-manager.ts +281 -0
  189. package/src/providers/integrations/cursor/cursor-shared.ts +108 -0
  190. package/src/providers/integrations/cursor/cursor.ts +960 -0
  191. package/src/providers/integrations/cursor/protocol/framing.ts +129 -0
  192. package/src/providers/integrations/cursor/protocol/messages.ts +2355 -0
  193. package/src/providers/integrations/cursor/protocol/struct.ts +305 -0
  194. package/src/providers/integrations/github/github.ts +4 -4
  195. package/src/providers/integrations/hermes.ts +79 -8
  196. package/src/providers/integrations/kilo/kilo-discovery.ts +10 -1
  197. package/src/providers/integrations/kilo/kilo.ts +153 -146
  198. package/src/providers/integrations/opencode/provider.ts +532 -538
  199. package/src/providers/integrations/openrouter/provider.ts +139 -281
  200. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -0
  201. package/src/providers/integrations/qoder/qoder-oauth.ts +310 -257
  202. package/src/providers/integrations/qoder/qoder-realm.ts +106 -0
  203. package/src/providers/integrations/qoder/qoder-virtual.ts +77 -0
  204. package/src/providers/integrations/qoder/qoder.ts +865 -730
  205. package/src/providers/integrations/trae/trae-catalog.ts +190 -0
  206. package/src/providers/integrations/trae/trae-headers.ts +149 -0
  207. package/src/providers/integrations/trae/trae-oauth.ts +773 -0
  208. package/src/providers/integrations/trae/trae-protocol.ts +400 -0
  209. package/src/providers/integrations/trae/trae-realm.ts +220 -0
  210. package/src/providers/integrations/trae/trae.ts +458 -0
  211. package/src/providers/operations/account-health-service.ts +107 -10
  212. package/src/providers/operations/client-versions.ts +133 -6
  213. package/src/providers/operations/provider-catalog-seeder.ts +95 -86
  214. package/src/providers/operations/provider-credential-service.ts +46 -7
  215. package/src/providers/provider-contracts.ts +76 -0
  216. package/src/providers/provider-metadata.ts +78 -6
  217. package/src/providers/provider-model-definition.ts +373 -349
  218. package/src/security/api-key-auth.ts +126 -8
  219. package/src/transport/contracts.ts +3 -0
  220. package/src/transport/dispatch/attempt-finalize.ts +32 -1
  221. package/src/transport/dispatch/attempt-loop.ts +530 -515
  222. package/src/transport/dispatch/generation.ts +88 -21
  223. package/src/transport/dispatch/leases.ts +99 -2
  224. package/src/transport/dispatch/proxy-request.ts +60 -3
  225. package/src/transport/dispatch/streaming/finalize.ts +16 -1
  226. package/src/transport/dispatch/streaming/stream.ts +22 -2
  227. package/src/transport/dispatch/upstream.ts +19 -1
  228. package/src/transport/guards/auth-guard.ts +26 -1
  229. package/src/transport/guards/body-policy.ts +5 -4
  230. package/src/transport/request/preparer.ts +103 -0
  231. package/src/transport/request/state.ts +14 -0
  232. package/src/transport/routing/plan-gate.ts +45 -0
  233. package/src/transport/routing/route-catalog.ts +799 -777
  234. package/src/transport/routing/route-model.ts +52 -1
  235. package/src/transport/routing/router.ts +51 -0
  236. package/dist/dashboard/assets/BannedPage-QhvbJB9o.js +0 -1
  237. package/dist/dashboard/assets/BannedPage-QhvbJB9o.js.gz +0 -0
  238. package/dist/dashboard/assets/CliToolDetailPage-Bcy150Jo.js.gz +0 -0
  239. package/dist/dashboard/assets/CliToolsPage-DyNXefph.js.gz +0 -0
  240. package/dist/dashboard/assets/CombosPage-Cak0ljnO.js +0 -1
  241. package/dist/dashboard/assets/CombosPage-Cak0ljnO.js.gz +0 -0
  242. package/dist/dashboard/assets/ConsoleLogPage-CATpAEOz.js.gz +0 -0
  243. package/dist/dashboard/assets/CustomizationPage-D6jEU7nL.js.gz +0 -0
  244. package/dist/dashboard/assets/HealthEventsModal-CZkpn0a7.js.gz +0 -0
  245. package/dist/dashboard/assets/LoginPage-BJlMVN4U.js.gz +0 -0
  246. package/dist/dashboard/assets/ModelPicker-Br3CXEau.js.gz +0 -0
  247. package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js +0 -1
  248. package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js.gz +0 -0
  249. package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js +0 -3
  250. package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js.gz +0 -0
  251. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js +0 -1
  252. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js.gz +0 -0
  253. package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js +0 -1
  254. package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js.gz +0 -0
  255. package/dist/dashboard/assets/ProxyPage-CXiumA53.js +0 -7
  256. package/dist/dashboard/assets/ProxyPage-CXiumA53.js.gz +0 -0
  257. package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js +0 -1
  258. package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js.gz +0 -0
  259. package/dist/dashboard/assets/SettingsPage-BHW4Zt06.js.gz +0 -0
  260. package/dist/dashboard/assets/SetupPage-VOYC_lIa.js.gz +0 -0
  261. package/dist/dashboard/assets/SortableList-DpOTnGuH.js +0 -1
  262. package/dist/dashboard/assets/SortableList-DpOTnGuH.js.gz +0 -0
  263. package/dist/dashboard/assets/StudioPage-BNIDLiU6.js.gz +0 -0
  264. package/dist/dashboard/assets/UsagePage-BAFaSDQV.js +0 -56
  265. package/dist/dashboard/assets/UsagePage-BAFaSDQV.js.gz +0 -0
  266. package/dist/dashboard/assets/api-keys-BETfaMYZ.js.gz +0 -0
  267. package/dist/dashboard/assets/app-BLCvGdzb.css +0 -2
  268. package/dist/dashboard/assets/app-BLCvGdzb.css.gz +0 -0
  269. package/dist/dashboard/assets/assert-7J6_LVD3.js +0 -1
  270. package/dist/dashboard/assets/assert-7J6_LVD3.js.gz +0 -0
  271. package/dist/dashboard/assets/badge-BTyGk_DL.js.gz +0 -0
  272. package/dist/dashboard/assets/banner-bujTWYM4.js.gz +0 -0
  273. package/dist/dashboard/assets/card-B0Bnx5CA.js.gz +0 -0
  274. package/dist/dashboard/assets/cli-tools-DE2ug812.js.gz +0 -0
  275. package/dist/dashboard/assets/clipboard-button-R_rArH8S.js.gz +0 -0
  276. package/dist/dashboard/assets/dialog-BWIP_EvW.js.gz +0 -0
  277. package/dist/dashboard/assets/drawer-BKcgVugF.js.gz +0 -0
  278. package/dist/dashboard/assets/entry-7dpgvpJ3.js +0 -4
  279. package/dist/dashboard/assets/entry-7dpgvpJ3.js.gz +0 -0
  280. package/dist/dashboard/assets/entry-kTiTFrvh.js.gz +0 -0
  281. package/dist/dashboard/assets/github-badge-Booc5mNe.js.gz +0 -0
  282. package/dist/dashboard/assets/index-D70pChfn.js.gz +0 -0
  283. package/dist/dashboard/assets/inline-D1TCqM2D.js.gz +0 -0
  284. package/dist/dashboard/assets/input-B9oyU93y.js.gz +0 -0
  285. package/dist/dashboard/assets/layout-BArzXirQ.js.gz +0 -0
  286. package/dist/dashboard/assets/list-row-BVZzaoHF.js.gz +0 -0
  287. package/dist/dashboard/assets/live-EmuIayqp.js.gz +0 -0
  288. package/dist/dashboard/assets/model-bans-CSPQ9xB0.js.gz +0 -0
  289. package/dist/dashboard/assets/routing-BExgxxBU.js.gz +0 -0
  290. package/dist/dashboard/assets/select-C9mMoEpJ.js +0 -41
  291. package/dist/dashboard/assets/select-C9mMoEpJ.js.gz +0 -0
  292. package/dist/dashboard/assets/settings-C4_Gn_wq.js +0 -1
  293. package/dist/dashboard/assets/settings-C4_Gn_wq.js.gz +0 -0
  294. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js +0 -1
  295. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js.gz +0 -0
  296. package/dist/dashboard/assets/stack-Ca5HxI5i.js.gz +0 -0
  297. package/dist/dashboard/assets/state-CzUPfLXo.js.gz +0 -0
  298. package/dist/dashboard/assets/switch-DOUIky_i.js.gz +0 -0
  299. package/dist/dashboard/assets/toast-C76WrMhc.js.gz +0 -0
  300. package/dist/dashboard/assets/useMutation-D87FZwH0.js.gz +0 -0
  301. package/src/network/tls/bun-client-hello.ts +0 -112
  302. /package/dist/dashboard/assets/{app-Cjq7nrwu.js → app-C-Y4oZT0.js} +0 -0
  303. /package/dist/dashboard/assets/{app-Cjq7nrwu.js.gz → app-C-Y4oZT0.js.gz} +0 -0
@@ -1,460 +1,478 @@
1
- /**
2
- * Pool egress agents: the HTTP CONNECT pair, the SOCKS5 pair, and the
3
- * per-connect SSRF revalidation they share. `PoolAgentResolver` (resolver.ts)
4
- * caches instances of these; the selector owns admission.
5
- */
6
- import { isIP } from "node:net";
7
- import { accountSocketBytes, accountSocketWrites } from "./byte-accounting";
8
- import {
9
- Agent as HttpAgent,
10
- request as httpRequest,
11
- type ClientRequestArgs,
12
- type IncomingMessage,
13
- type RequestOptions,
14
- } from "node:http";
15
- import { Agent as HttpsAgent, request as httpsRequest } from "node:https";
16
- import * as tls from "node:tls";
17
- import type { Duplex } from "node:stream";
18
- import { SocksProxyAgent } from "socks-proxy-agent";
19
- import { resolveAllAddresses, validateResolvedAddresses } from "../ssrf";
20
- import type { SsrfPolicy } from "../../config/network";
21
- import { resolveProxyMaxFreeSockets, resolveProxyMaxSockets } from "../../config/network";
22
- import type { AgentConfig } from "../types";
23
-
24
- /**
25
- * Idle keep-alive socket timeout for every pool agent. Matches the 60s DB pool
26
- * idle window so long-idle resources in one process reap together.
27
- */
28
- const PROXY_KEEP_ALIVE_TIMEOUT_MS = 60_000;
29
-
30
- /**
31
- * The transport a network pool dials outbound traffic through. Every kind is
32
- * dialled directly by the resolver; no pool spawns a child process.
33
- *
34
- * `http` and `https` differ only in which flavor answers a `https:` target, so a
35
- * hosted relay front door is recorded as the protocol it actually speaks. A
36
- * serverless host that cannot answer CONNECT at all (see `isRelayHost`) is
37
- * dialled through the `x-relay-target` contract instead, which
38
- * `createHttpProxyAgent` selects from the hostname.
39
- */
40
- export const TRANSPORT_KINDS = ["http", "https", "socks5"] as const;
41
-
42
- /** One outbound transport kind. */
43
- export type TransportKind = (typeof TRANSPORT_KINDS)[number];
44
-
45
-
46
- /** Config keys reserved for endpoint/label; every other key is opaque non-secret transport config. */
47
- export const RESERVED_ENDPOINT_CONFIG_KEYS: Record<string, true> = {
48
- endpoint: true,
49
- label: true,
50
- };
51
-
52
- export function splitEndpointConfig(config: Record<string, unknown>): {
53
- endpoint?: string;
54
- label?: string;
55
- rest: Record<string, unknown>;
56
- } {
57
- const rest: Record<string, unknown> = {};
58
- for (const [key, value] of Object.entries(config)) {
59
- if (!RESERVED_ENDPOINT_CONFIG_KEYS[key]) rest[key] = value;
60
- }
61
- return {
62
- ...(typeof config.endpoint === "string" ? { endpoint: config.endpoint } : {}),
63
- ...(typeof config.label === "string" ? { label: config.label } : {}),
64
- rest,
65
- };
66
- }
67
-
68
- /** Derives the application-level `https` distinction the DB enum collapses into `http`. */
69
- export function deriveKind(dbKind: string, endpoint: string): TransportKind {
70
- if (dbKind === "http") return endpoint.startsWith("https:") ? "https" : "http";
71
- return dbKind as TransportKind;
72
- }
73
-
74
- // Pool agents — single egress
75
- export type PoolAgent = HttpAgent | HttpsAgent | ProxyAgentPair;
76
-
77
- class InvalidPoolAgentConfigError extends Error {}
78
-
79
- function withCredential(url: URL, credential: string | undefined): URL {
80
- if (!credential) return url;
81
- const separator = credential.indexOf(":");
82
- const username = separator === -1 ? credential : credential.slice(0, separator);
83
- const password = separator === -1 ? "" : credential.slice(separator + 1);
84
- url.username = encodeURIComponent(username);
85
- url.password = encodeURIComponent(password);
86
- return url;
87
- }
88
-
89
- /**
90
- * Hosted relay front doors expose an application HTTP endpoint rather than
91
- * an RFC CONNECT proxy. Keep this classification beside the pool factory so
92
- * the dispatch and health-check paths cannot accidentally tunnel them with
93
- * CONNECT. This mirrors the supported relay-host policy in Cartethyia 21.
94
- */
95
- function isRelayHost(hostname: string): boolean {
96
- const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
97
- return (
98
- normalized.endsWith(".vercel.app") ||
99
- normalized.endsWith(".workers.dev") ||
100
- normalized.endsWith(".netlify.app") ||
101
- // Deno Deploy project URLs. `*.deno.dev` is the project hostname Deno
102
- // Deploy assigns, so a deployed relay answers there exactly like a
103
- // Vercel/Workers front door.
104
- normalized.endsWith(".deno.dev")
105
- );
106
- }
107
-
108
- /**
109
- * Per-connect DNS lookup that validates every resolved address of the dialed
110
- * host against the SSRF policy, pinning the validated address for the socket.
111
- * Shared by the HTTP CONNECT handshake and the SOCKS5 proxy socket: both dial
112
- * a *proxy* host that was validated at create-time, so a DNS-rebind of that
113
- * record must not be able to swap it to a private/link-local address later.
114
- */
115
- function createSsrfLookup(policy: SsrfPolicy): NonNullable<RequestOptions["lookup"]> {
116
- return ((
117
- hostname: string,
118
- lookupOptions: { all?: boolean; family?: number } | number,
119
- cb: (
120
- error: Error | null,
121
- address?: string | Array<{ address: string; family: number }>,
122
- family?: number,
123
- ) => void,
124
- ): void => {
125
- const abort = new AbortController();
126
- const timer = setTimeout(() => abort.abort(), 5_000);
127
- void resolveAllAddresses(hostname, abort.signal)
128
- .then((addrs) => {
129
- try {
130
- validateResolvedAddresses(addrs, policy);
131
- } catch (error) {
132
- cb(error as Error);
133
- return;
134
- }
135
- const first = addrs[0];
136
- if (!first) {
137
- cb(new Error(`no addresses resolved for ${hostname}`));
138
- return;
139
- }
140
- const family = first.includes(":") ? 6 : 4;
141
- const opts = typeof lookupOptions === "number" ? { family: lookupOptions } : lookupOptions;
142
- if (opts?.all) {
143
- cb(null, addrs.map((address) => ({ address, family: address.includes(":") ? 6 : 4 })));
144
- return;
145
- }
146
- cb(null, first, family);
147
- })
148
- .catch((error: unknown) => cb(error as Error))
149
- .finally(() => clearTimeout(timer));
150
- }) as unknown as NonNullable<RequestOptions["lookup"]>;
151
- }
152
-
153
- /**
154
- * A proxy answered the CONNECT handshake with a non-200 status instead of
155
- * establishing the tunnel. Typed so the caller can read the status without
156
- * re-parsing text: the health machine treats 402/407 as "reachable but
157
- * unusable", and a relay pool treats the whole class as "this front door
158
- * does not do CONNECT, use the relay form".
159
- */
160
- export class ProxyConnectRefusalError extends Error {
161
- constructor(
162
- readonly statusCode: number,
163
- statusMessage?: string,
164
- ) {
165
- super(`Proxy CONNECT failed: ${statusCode}${statusMessage ? ` ${statusMessage}` : ""}`);
166
- this.name = "ProxyConnectRefusalError";
167
- }
168
- }
169
-
170
- /**
171
- * A CONNECT handshake that never reached "established" for a reason other than
172
- * an explicit status refusal — the socket was reset, the connection refused, or
173
- * the peer closed before answering.
174
- *
175
- * Tagged at the handshake on purpose. The distinction that matters to a caller
176
- * is *when* the failure happened: this class means "the front door could not be
177
- * reached as a tunnel", which is the signal to fall back to the relay form. A
178
- * failure that happens *inside* an established tunnel — the target's own TLS
179
- * handshake failing, say — is a different error from a different code path and
180
- * must not be retried elsewhere, so it is deliberately not this class.
181
- */
182
- export class ProxyConnectHandshakeError extends Error {
183
- constructor(cause: Error) {
184
- super(`Proxy CONNECT handshake failed: ${cause.message}`);
185
- this.name = "ProxyConnectHandshakeError";
186
- this.cause = cause;
187
- }
188
- }
189
-
190
- /**
191
- * True when a CONNECT attempt failed at the handshake, so a relay pool
192
- * should retry through the relay form. Only the two handshake classes qualify:
193
- * a `ProxyConnectRefusalError` (the front door answered "no CONNECT") and a
194
- * `ProxyConnectHandshakeError` (the front door reset or refused the socket). A
195
- * caller cancellation and any error raised after the tunnel was established are
196
- * never retried.
197
- */
198
- /**
199
- * Shared HTTP CONNECT tunnel establishment (proxy handshake + SSRF-pinned
200
- * proxy DNS). Used by both scheme flavors below; the returned socket is the
201
- * raw CONNECT tunnel — node's http/https layer performs the *target* TLS
202
- * handshake itself over it.
203
- */
204
- function createProxyConnection(
205
- proxy: URL,
206
- policy: SsrfPolicy,
207
- options: ClientRequestArgs,
208
- callback?: (error: Error | null, stream: Duplex) => void,
209
- poolId?: string,
210
- ): Duplex | null | undefined {
211
- const targetHost = options.hostname ?? options.host ?? "";
212
- const targetPort = options.port ?? 443;
213
- const isHttpsProxy = proxy.protocol === "https:";
214
- const fail = (error: Error): void => {
215
- callback?.(error, undefined as unknown as Duplex);
216
- };
217
- const lookup = createSsrfLookup(policy);
218
- const proxyOptions: ClientRequestArgs = {
219
- hostname: proxy.hostname,
220
- port: proxy.port ? Number(proxy.port) : isHttpsProxy ? 443 : 80,
221
- method: "CONNECT",
222
- path: `${targetHost}:${targetPort}`,
223
- headers: { host: `${targetHost}:${targetPort}` },
224
- agent: false,
225
- lookup,
226
- // SNI is mandatory for name-based TLS front doors (Vercel serves a
227
- // fallback cert without it → altnames mismatch). Skipped for literal
228
- // IPs, which TLS rejects as a servername.
229
- ...(isHttpsProxy && !isIP(proxy.hostname) ? { servername: proxy.hostname } : {}),
230
- };
231
- if (proxy.username || proxy.password) {
232
- const auth = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`;
233
- proxyOptions.headers = {
234
- ...proxyOptions.headers,
235
- "proxy-authorization": `Basic ${Buffer.from(auth).toString("base64")}`,
236
- };
237
- }
238
- const proxyRequest = (isHttpsProxy ? httpsRequest : httpRequest)(proxyOptions);
239
- // Every failure before the tunnel is established — the socket reset, the
240
- // connection refused, the peer closing early, the CONNECT request itself
241
- // erroring — is a handshake failure and is tagged as such, so a relay
242
- // pool can tell "this front door does not tunnel" from an error raised
243
- // later, inside a tunnel that did open.
244
- let established = false;
245
- const failHandshake = (error: Error): void => {
246
- if (established) {
247
- fail(error);
248
- return;
249
- }
250
- fail(new ProxyConnectHandshakeError(error));
251
- };
252
- proxyRequest.on(
253
- "connect",
254
- (response: IncomingMessage, socket: Duplex, head: Buffer) => {
255
- if (response.statusCode !== 200) {
256
- fail(new ProxyConnectRefusalError(response.statusCode ?? 502, response.statusMessage));
257
- socket.destroy();
258
- return;
259
- }
260
- established = true;
261
- if (head.length > 0) socket.unshift(head);
262
- // Tally the tunnel from here on. The CONNECT request itself is a few
263
- // dozen bytes and is not counted; everything the tunnel carries is.
264
- if (poolId) {
265
- accountSocketBytes(socket, poolId);
266
- accountSocketWrites(socket, poolId);
267
- }
268
- callback?.(null, socket);
269
- },
270
- );
271
- proxyRequest.on("error", failHandshake);
272
- proxyRequest.end();
273
- return undefined;
274
- }
275
-
276
- /**
277
- * One pool's dual-scheme egress. Node validates `agent.protocol` per
278
- * request (`https.request` rejects an `http:` agent with `Protocol "https:"
279
- * not supported`), so a single Agent class cannot serve both target
280
- * schemes — the pair carries one flavor per scheme around one shared
281
- * CONNECT implementation.
282
- */
283
- export interface ProxyAgentPair {
284
- /** Flavor for `http:` targets (protocol `http:`). */
285
- readonly http: HttpAgent;
286
- /** Flavor for `https:` targets (protocol `https:`). */
287
- readonly https: HttpsAgent;
288
- /**
289
- * Relay endpoint for hosted HTTP front doors (Vercel/Workers/Netlify).
290
- * These endpoints are application relays, not RFC CONNECT proxies; the
291
- * validated fetcher sends `x-relay-target`/`x-relay-path` to this URL.
292
- */
293
- readonly relayEndpoint?: URL;
294
- }
295
-
296
- export function isProxyAgentPair(value: unknown): value is ProxyAgentPair {
297
- // Shape-checked (not instanceof): the socks pair holds two
298
- // SocksProxyAgent flavors, which are never HttpsAgent subclasses — what
299
- // matters is one agent per target scheme behind a shared CONNECT path.
300
- if (!value || typeof value !== "object") return false;
301
- const pair = value as Record<string, unknown>;
302
- const http = pair.http as { createConnection?: unknown } | undefined;
303
- const https = pair.https as { createConnection?: unknown } | undefined;
304
- return (
305
- typeof http?.createConnection === "function" &&
306
- typeof https?.createConnection === "function"
307
- );
308
- }
309
-
310
- class HttpProxyAgent extends HttpAgent {
311
- constructor(
312
- readonly proxy: URL,
313
- /** SSRF policy applied to the *proxy* hostname on every CONNECT so a
314
- * DNS-rebind of the proxy record cannot swap it to a private/link-local
315
- * address after the pool was validated at create-time. */
316
- private readonly policy: SsrfPolicy = {},
317
- config: AgentConfig = {},
318
- private readonly poolId?: string,
319
- ) {
320
- super({
321
- keepAlive: true,
322
- maxSockets: config.maxSockets ?? resolveProxyMaxSockets(),
323
- maxFreeSockets: config.maxFreeSockets ?? resolveProxyMaxFreeSockets(),
324
- timeout: config.keepAliveTimeout ?? PROXY_KEEP_ALIVE_TIMEOUT_MS,
325
- });
326
- }
327
-
328
- override createConnection(
329
- options: ClientRequestArgs,
330
- callback?: (error: Error | null, stream: Duplex) => void,
331
- ): Duplex | null | undefined {
332
- return createProxyConnection(this.proxy, this.policy, options, callback, this.poolId);
333
- }
334
- }
335
-
336
- class HttpsProxyAgent extends HttpsAgent {
337
- constructor(
338
- readonly proxy: URL,
339
- private readonly policy: SsrfPolicy = {},
340
- config: AgentConfig = {},
341
- private readonly poolId?: string,
342
- ) {
343
- super({
344
- keepAlive: true,
345
- maxSockets: config.maxSockets ?? resolveProxyMaxSockets(),
346
- maxFreeSockets: config.maxFreeSockets ?? resolveProxyMaxFreeSockets(),
347
- timeout: config.keepAliveTimeout ?? PROXY_KEEP_ALIVE_TIMEOUT_MS,
348
- });
349
- }
350
- override createConnection(
351
- options: ClientRequestArgs,
352
- callback?: (error: Error | null, stream: Duplex) => void,
353
- ): Duplex | null | undefined {
354
- return createProxyConnection(this.proxy, this.policy, options, (err, rawSocket) => {
355
- if (err || !rawSocket) {
356
- callback?.(err, rawSocket);
357
- return;
358
- }
359
- const targetHost = options.hostname ?? options.host ?? "";
360
- const tlsSocket = tls.connect({
361
- socket: rawSocket,
362
- servername: isIP(targetHost) ? undefined : targetHost,
363
- rejectUnauthorized: this.options.rejectUnauthorized !== false,
364
- });
365
- tlsSocket.once("secureConnect", () => {
366
- callback?.(null, tlsSocket);
367
- });
368
- tlsSocket.once("error", (tlsErr) => {
369
- rawSocket.destroy();
370
- callback?.(tlsErr, undefined as unknown as Duplex);
371
- });
372
- }, this.poolId);
373
- }
374
- }
375
- export function createHttpProxyAgent(
376
- endpoint: string,
377
- credential?: string,
378
- policy: SsrfPolicy = {},
379
- config: AgentConfig = {},
380
- poolId?: string,
381
- ): ProxyAgentPair {
382
- let url: URL;
383
- try {
384
- url = new URL(endpoint);
385
- } catch {
386
- throw new InvalidPoolAgentConfigError(`invalid HTTP proxy endpoint: ${endpoint}`);
387
- }
388
- const proxy = new URL(withCredential(url, credential).toString());
389
- return {
390
- http: new HttpProxyAgent(proxy, policy, config, poolId),
391
- https: new HttpsProxyAgent(proxy, policy, config, poolId),
392
- ...(isRelayHost(proxy.hostname) ? { relayEndpoint: proxy } : {}),
393
- };
394
- }
395
-
396
- /**
397
- * Tally a SOCKS5 agent's tunnel.
398
- *
399
- * The socks client owns socket creation, so this wraps `createConnection`
400
- * instead of the CONNECT callback the HTTP path uses. The socket handed back is
401
- * the raw tunnel (Node applies TLS afterwards for `https:` targets), which is
402
- * exactly the layer whose bytes the proxy bills. The SOCKS negotiation itself
403
- * is a handful of bytes exchanged before this point and is not counted.
404
- */
405
- function accountSocksAgent(agent: SocksProxyAgent, poolId?: string): void {
406
- if (!poolId) return;
407
- const original = agent.createConnection.bind(agent) as (
408
- options: ClientRequestArgs,
409
- callback?: (error: Error | null, stream: Duplex) => void,
410
- ) => Duplex | null | undefined;
411
- agent.createConnection = ((options: ClientRequestArgs, callback?: (error: Error | null, stream: Duplex) => void) =>
412
- original(options, (error, socket) => {
413
- if (socket) {
414
- accountSocketBytes(socket, poolId);
415
- accountSocketWrites(socket, poolId);
416
- }
417
- callback?.(error, socket);
418
- })) as unknown as typeof agent.createConnection;
419
- }
420
-
421
- export function createSocks5Agent(
422
- endpoint: string,
423
- credential?: string,
424
- policy: SsrfPolicy = {},
425
- config: AgentConfig = {},
426
- poolId?: string,
427
- ): ProxyAgentPair {
428
- const authority = endpoint.includes("://") ? endpoint : `socks5://${endpoint}`;
429
- let url: URL;
430
- try {
431
- url = new URL(authority);
432
- } catch {
433
- throw new InvalidPoolAgentConfigError(`invalid SOCKS5 endpoint: ${endpoint}`);
434
- }
435
- // The proxy hostname is resolved by the socks client itself, so the policy
436
- // is applied through the socket's `lookup`: every connect re-resolves and
437
- // re-validates the proxy address instead of trusting the create-time check.
438
- const options = {
439
- socketOptions: { lookup: createSsrfLookup(policy) },
440
- keepAlive: true,
441
- maxSockets: config.maxSockets ?? resolveProxyMaxSockets(),
442
- maxFreeSockets: config.maxFreeSockets ?? resolveProxyMaxFreeSockets(),
443
- timeout: config.keepAliveTimeout ?? PROXY_KEEP_ALIVE_TIMEOUT_MS,
444
- };
445
- const http = new SocksProxyAgent(withCredential(url, credential), options);
446
- accountSocksAgent(http, poolId);
447
- // Same latent Node check as HTTP pools: an `http:`-protocol agent is
448
- // rejected for `https:` targets, so the https flavor carries an
449
- // overridden protocol (per-instance shadow — the shared SocksProxyAgent
450
- // prototype is untouched). Behavior is identical; only the check reads it.
451
- const https = new SocksProxyAgent(withCredential(url, credential), options);
452
- https.protocol = "https:";
453
- accountSocksAgent(https, poolId);
454
- return {
455
- http: http as unknown as HttpAgent,
456
- https: https as unknown as HttpsAgent,
457
- };
458
- }
459
-
460
- export class PoolBindingError extends Error {}
1
+ /**
2
+ * Pool egress agents: the HTTP CONNECT pair, the SOCKS5 pair, and the
3
+ * per-connect SSRF revalidation they share. `PoolAgentResolver` (resolver.ts)
4
+ * caches instances of these; the selector owns admission.
5
+ */
6
+ import { isIP } from "node:net";
7
+ import { accountSocketBytes, accountSocketWrites, recordPoolBytes } from "./byte-accounting";
8
+ import {
9
+ Agent as HttpAgent,
10
+ request as httpRequest,
11
+ type ClientRequestArgs,
12
+ type IncomingMessage,
13
+ type RequestOptions,
14
+ } from "node:http";
15
+ import { Agent as HttpsAgent, request as httpsRequest } from "node:https";
16
+ import * as tls from "node:tls";
17
+ import type { Duplex } from "node:stream";
18
+ import { SocksProxyAgent } from "socks-proxy-agent";
19
+ import { resolveAllAddresses, validateResolvedAddresses } from "../ssrf";
20
+ import type { SsrfPolicy } from "../../config/network";
21
+ import { resolveProxyMaxFreeSockets, resolveProxyMaxSockets } from "../../config/network";
22
+ import type { AgentConfig } from "../types";
23
+
24
+ /**
25
+ * Idle keep-alive socket timeout for every pool agent. Matches the 60s DB pool
26
+ * idle window so long-idle resources in one process reap together.
27
+ */
28
+ const PROXY_KEEP_ALIVE_TIMEOUT_MS = 60_000;
29
+
30
+ /**
31
+ * The transport a network pool dials outbound traffic through. Every kind is
32
+ * dialled directly by the resolver; no pool spawns a child process.
33
+ *
34
+ * `http` and `https` differ only in which flavor answers a `https:` target, so a
35
+ * hosted relay front door is recorded as the protocol it actually speaks. A
36
+ * serverless host that cannot answer CONNECT at all (see `isRelayHost`) is
37
+ * dialled through the `x-relay-target` contract instead, which
38
+ * `createHttpProxyAgent` selects from the hostname.
39
+ */
40
+ export const TRANSPORT_KINDS = ["http", "https", "socks5"] as const;
41
+
42
+ /** One outbound transport kind. */
43
+ export type TransportKind = (typeof TRANSPORT_KINDS)[number];
44
+
45
+
46
+ /** Config keys reserved for endpoint/label; every other key is opaque non-secret transport config. */
47
+ export const RESERVED_ENDPOINT_CONFIG_KEYS: Record<string, true> = {
48
+ endpoint: true,
49
+ label: true,
50
+ };
51
+
52
+ export function splitEndpointConfig(config: Record<string, unknown>): {
53
+ endpoint?: string;
54
+ label?: string;
55
+ rest: Record<string, unknown>;
56
+ } {
57
+ const rest: Record<string, unknown> = {};
58
+ for (const [key, value] of Object.entries(config)) {
59
+ if (!RESERVED_ENDPOINT_CONFIG_KEYS[key]) rest[key] = value;
60
+ }
61
+ return {
62
+ ...(typeof config.endpoint === "string" ? { endpoint: config.endpoint } : {}),
63
+ ...(typeof config.label === "string" ? { label: config.label } : {}),
64
+ rest,
65
+ };
66
+ }
67
+
68
+ /** Derives the application-level `https` distinction the DB enum collapses into `http`. */
69
+ export function deriveKind(dbKind: string, endpoint: string): TransportKind {
70
+ if (dbKind === "http") return endpoint.startsWith("https:") ? "https" : "http";
71
+ return dbKind as TransportKind;
72
+ }
73
+
74
+ // Pool agents — single egress
75
+ export type PoolAgent = HttpAgent | HttpsAgent | ProxyAgentPair;
76
+
77
+ class InvalidPoolAgentConfigError extends Error {}
78
+
79
+ function withCredential(url: URL, credential: string | undefined): URL {
80
+ if (!credential) return url;
81
+ const separator = credential.indexOf(":");
82
+ const username = separator === -1 ? credential : credential.slice(0, separator);
83
+ const password = separator === -1 ? "" : credential.slice(separator + 1);
84
+ url.username = encodeURIComponent(username);
85
+ url.password = encodeURIComponent(password);
86
+ return url;
87
+ }
88
+
89
+ /**
90
+ * Hosted relay front doors expose an application HTTP endpoint rather than
91
+ * an RFC CONNECT proxy. Keep this classification beside the pool factory so
92
+ * the dispatch and health-check paths cannot accidentally tunnel them with
93
+ * CONNECT. This mirrors the supported relay-host policy in Cartethyia 21.
94
+ */
95
+ function isRelayHost(hostname: string): boolean {
96
+ const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
97
+ return (
98
+ normalized.endsWith(".vercel.app") ||
99
+ normalized.endsWith(".workers.dev") ||
100
+ normalized.endsWith(".netlify.app") ||
101
+ // Deno Deploy project URLs. `*.deno.dev` is the project hostname Deno
102
+ // Deploy assigns, so a deployed relay answers there exactly like a
103
+ // Vercel/Workers front door.
104
+ normalized.endsWith(".deno.dev")
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Per-connect DNS lookup that validates every resolved address of the dialed
110
+ * host against the SSRF policy, pinning the validated address for the socket.
111
+ * Shared by the HTTP CONNECT handshake and the SOCKS5 proxy socket: both dial
112
+ * a *proxy* host that was validated at create-time, so a DNS-rebind of that
113
+ * record must not be able to swap it to a private/link-local address later.
114
+ */
115
+ function createSsrfLookup(policy: SsrfPolicy): NonNullable<RequestOptions["lookup"]> {
116
+ return ((
117
+ hostname: string,
118
+ lookupOptions: { all?: boolean; family?: number } | number,
119
+ cb: (
120
+ error: Error | null,
121
+ address?: string | Array<{ address: string; family: number }>,
122
+ family?: number,
123
+ ) => void,
124
+ ): void => {
125
+ const abort = new AbortController();
126
+ const timer = setTimeout(() => abort.abort(), 5_000);
127
+ void resolveAllAddresses(hostname, abort.signal)
128
+ .then((addrs) => {
129
+ try {
130
+ validateResolvedAddresses(addrs, policy);
131
+ } catch (error) {
132
+ cb(error as Error);
133
+ return;
134
+ }
135
+ const first = addrs[0];
136
+ if (!first) {
137
+ cb(new Error(`no addresses resolved for ${hostname}`));
138
+ return;
139
+ }
140
+ const family = first.includes(":") ? 6 : 4;
141
+ const opts = typeof lookupOptions === "number" ? { family: lookupOptions } : lookupOptions;
142
+ if (opts?.all) {
143
+ cb(null, addrs.map((address) => ({ address, family: address.includes(":") ? 6 : 4 })));
144
+ return;
145
+ }
146
+ cb(null, first, family);
147
+ })
148
+ .catch((error: unknown) => cb(error as Error))
149
+ .finally(() => clearTimeout(timer));
150
+ }) as unknown as NonNullable<RequestOptions["lookup"]>;
151
+ }
152
+
153
+ /**
154
+ * A proxy answered the CONNECT handshake with a non-200 status instead of
155
+ * establishing the tunnel. Typed so the caller can read the status without
156
+ * re-parsing text: the health machine treats 402/407 as "reachable but
157
+ * unusable", and a relay pool treats the whole class as "this front door
158
+ * does not do CONNECT, use the relay form".
159
+ */
160
+ export class ProxyConnectRefusalError extends Error {
161
+ constructor(
162
+ readonly statusCode: number,
163
+ statusMessage?: string,
164
+ ) {
165
+ super(`Proxy CONNECT failed: ${statusCode}${statusMessage ? ` ${statusMessage}` : ""}`);
166
+ this.name = "ProxyConnectRefusalError";
167
+ }
168
+ }
169
+
170
+ /**
171
+ * A CONNECT handshake that never reached "established" for a reason other than
172
+ * an explicit status refusal — the socket was reset, the connection refused, or
173
+ * the peer closed before answering.
174
+ *
175
+ * Tagged at the handshake on purpose. The distinction that matters to a caller
176
+ * is *when* the failure happened: this class means "the front door could not be
177
+ * reached as a tunnel", which is the signal to fall back to the relay form. A
178
+ * failure that happens *inside* an established tunnel — the target's own TLS
179
+ * handshake failing, say — is a different error from a different code path and
180
+ * must not be retried elsewhere, so it is deliberately not this class.
181
+ */
182
+ export class ProxyConnectHandshakeError extends Error {
183
+ constructor(cause: Error) {
184
+ super(`Proxy CONNECT handshake failed: ${cause.message}`);
185
+ this.name = "ProxyConnectHandshakeError";
186
+ this.cause = cause;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * True when a CONNECT attempt failed at the handshake, so a relay pool
192
+ * should retry through the relay form. Only the two handshake classes qualify:
193
+ * a `ProxyConnectRefusalError` (the front door answered "no CONNECT") and a
194
+ * `ProxyConnectHandshakeError` (the front door reset or refused the socket). A
195
+ * caller cancellation and any error raised after the tunnel was established are
196
+ * never retried.
197
+ */
198
+ /**
199
+ * Shared HTTP CONNECT tunnel establishment (proxy handshake + SSRF-pinned
200
+ * proxy DNS). Used by both scheme flavors below; the returned socket is the
201
+ * raw CONNECT tunnel — node's http/https layer performs the *target* TLS
202
+ * handshake itself over it.
203
+ */
204
+ function createProxyConnection(
205
+ proxy: URL,
206
+ policy: SsrfPolicy,
207
+ options: ClientRequestArgs,
208
+ callback?: (error: Error | null, stream: Duplex) => void,
209
+ poolId?: string,
210
+ ): Duplex | null | undefined {
211
+ const targetHost = options.hostname ?? options.host ?? "";
212
+ const targetPort = options.port ?? 443;
213
+ const isHttpsProxy = proxy.protocol === "https:";
214
+ const fail = (error: Error): void => {
215
+ callback?.(error, undefined as unknown as Duplex);
216
+ };
217
+ const lookup = createSsrfLookup(policy);
218
+ const proxyOptions: ClientRequestArgs = {
219
+ hostname: proxy.hostname,
220
+ port: proxy.port ? Number(proxy.port) : isHttpsProxy ? 443 : 80,
221
+ method: "CONNECT",
222
+ path: `${targetHost}:${targetPort}`,
223
+ headers: { host: `${targetHost}:${targetPort}` },
224
+ agent: false,
225
+ lookup,
226
+ // SNI is mandatory for name-based TLS front doors (Vercel serves a
227
+ // fallback cert without it → altnames mismatch). Skipped for literal
228
+ // IPs, which TLS rejects as a servername.
229
+ ...(isHttpsProxy && !isIP(proxy.hostname) ? { servername: proxy.hostname } : {}),
230
+ };
231
+ if (proxy.username || proxy.password) {
232
+ const auth = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`;
233
+ proxyOptions.headers = {
234
+ ...proxyOptions.headers,
235
+ "proxy-authorization": `Basic ${Buffer.from(auth).toString("base64")}`,
236
+ };
237
+ }
238
+ const proxyRequest = (isHttpsProxy ? httpsRequest : httpRequest)(proxyOptions);
239
+ // The CONNECT exchange itself costs bytes on this pool's egress. It is tens
240
+ // of bytes per tunnel, so it barely moves a quota on its own — but a pool
241
+ // that churns tunnels without ever establishing one spends real traffic and
242
+ // previously metered as zero.
243
+ if (poolId) {
244
+ const requestLine = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\n`;
245
+ const headerText = Object.entries(proxyOptions.headers ?? {})
246
+ .map(([name, value]) => `${name}: ${String(value)}\r\n`)
247
+ .join("");
248
+ recordPoolBytes(poolId, "sent", Buffer.byteLength(requestLine + headerText + "\r\n", "utf8"));
249
+ }
250
+ // Every failure before the tunnel is established — the socket reset, the
251
+ // connection refused, the peer closing early, the CONNECT request itself
252
+ // erroring — is a handshake failure and is tagged as such, so a relay
253
+ // pool can tell "this front door does not tunnel" from an error raised
254
+ // later, inside a tunnel that did open.
255
+ let established = false;
256
+ const failHandshake = (error: Error): void => {
257
+ if (established) {
258
+ fail(error);
259
+ return;
260
+ }
261
+ fail(new ProxyConnectHandshakeError(error));
262
+ };
263
+ proxyRequest.on(
264
+ "connect",
265
+ (response: IncomingMessage, socket: Duplex, head: Buffer) => {
266
+ if (response.statusCode !== 200) {
267
+ fail(new ProxyConnectRefusalError(response.statusCode ?? 502, response.statusMessage));
268
+ socket.destroy();
269
+ return;
270
+ }
271
+ established = true;
272
+ // `head` is whatever the peer had already sent by the time CONNECT
273
+ // completed. Unshifting it replays those bytes into the stream, but a
274
+ // `data` listener attached afterwards never sees them — so they were
275
+ // silently missing from the pool's metered totals.
276
+ if (head.length > 0) {
277
+ if (poolId) recordPoolBytes(poolId, "received", head.length);
278
+ socket.unshift(head);
279
+ }
280
+ // Tally the tunnel from here on: `write` on this socket is the TLS
281
+ // record stream, so what is counted is what actually went on the wire.
282
+ if (poolId) {
283
+ accountSocketBytes(socket, poolId);
284
+ accountSocketWrites(socket, poolId);
285
+ }
286
+ callback?.(null, socket);
287
+ },
288
+ );
289
+ proxyRequest.on("error", failHandshake);
290
+ proxyRequest.end();
291
+ return undefined;
292
+ }
293
+
294
+ /**
295
+ * One pool's dual-scheme egress. Node validates `agent.protocol` per
296
+ * request (`https.request` rejects an `http:` agent with `Protocol "https:"
297
+ * not supported`), so a single Agent class cannot serve both target
298
+ * schemes — the pair carries one flavor per scheme around one shared
299
+ * CONNECT implementation.
300
+ */
301
+ export interface ProxyAgentPair {
302
+ /** Flavor for `http:` targets (protocol `http:`). */
303
+ readonly http: HttpAgent;
304
+ /** Flavor for `https:` targets (protocol `https:`). */
305
+ readonly https: HttpsAgent;
306
+ /**
307
+ * Relay endpoint for hosted HTTP front doors (Vercel/Workers/Netlify).
308
+ * These endpoints are application relays, not RFC CONNECT proxies; the
309
+ * validated fetcher sends `x-relay-target`/`x-relay-path` to this URL.
310
+ */
311
+ readonly relayEndpoint?: URL;
312
+ }
313
+
314
+ export function isProxyAgentPair(value: unknown): value is ProxyAgentPair {
315
+ // Shape-checked (not instanceof): the socks pair holds two
316
+ // SocksProxyAgent flavors, which are never HttpsAgent subclasses — what
317
+ // matters is one agent per target scheme behind a shared CONNECT path.
318
+ if (!value || typeof value !== "object") return false;
319
+ const pair = value as Record<string, unknown>;
320
+ const http = pair.http as { createConnection?: unknown } | undefined;
321
+ const https = pair.https as { createConnection?: unknown } | undefined;
322
+ return (
323
+ typeof http?.createConnection === "function" &&
324
+ typeof https?.createConnection === "function"
325
+ );
326
+ }
327
+
328
+ class HttpProxyAgent extends HttpAgent {
329
+ constructor(
330
+ readonly proxy: URL,
331
+ /** SSRF policy applied to the *proxy* hostname on every CONNECT so a
332
+ * DNS-rebind of the proxy record cannot swap it to a private/link-local
333
+ * address after the pool was validated at create-time. */
334
+ private readonly policy: SsrfPolicy = {},
335
+ config: AgentConfig = {},
336
+ private readonly poolId?: string,
337
+ ) {
338
+ super({
339
+ keepAlive: true,
340
+ maxSockets: config.maxSockets ?? resolveProxyMaxSockets(),
341
+ maxFreeSockets: config.maxFreeSockets ?? resolveProxyMaxFreeSockets(),
342
+ timeout: config.keepAliveTimeout ?? PROXY_KEEP_ALIVE_TIMEOUT_MS,
343
+ });
344
+ }
345
+
346
+ override createConnection(
347
+ options: ClientRequestArgs,
348
+ callback?: (error: Error | null, stream: Duplex) => void,
349
+ ): Duplex | null | undefined {
350
+ return createProxyConnection(this.proxy, this.policy, options, callback, this.poolId);
351
+ }
352
+ }
353
+
354
+ class HttpsProxyAgent extends HttpsAgent {
355
+ constructor(
356
+ readonly proxy: URL,
357
+ private readonly policy: SsrfPolicy = {},
358
+ config: AgentConfig = {},
359
+ private readonly poolId?: string,
360
+ ) {
361
+ super({
362
+ keepAlive: true,
363
+ maxSockets: config.maxSockets ?? resolveProxyMaxSockets(),
364
+ maxFreeSockets: config.maxFreeSockets ?? resolveProxyMaxFreeSockets(),
365
+ timeout: config.keepAliveTimeout ?? PROXY_KEEP_ALIVE_TIMEOUT_MS,
366
+ });
367
+ }
368
+ override createConnection(
369
+ options: ClientRequestArgs,
370
+ callback?: (error: Error | null, stream: Duplex) => void,
371
+ ): Duplex | null | undefined {
372
+ return createProxyConnection(this.proxy, this.policy, options, (err, rawSocket) => {
373
+ if (err || !rawSocket) {
374
+ callback?.(err, rawSocket);
375
+ return;
376
+ }
377
+ const targetHost = options.hostname ?? options.host ?? "";
378
+ const tlsSocket = tls.connect({
379
+ socket: rawSocket,
380
+ servername: isIP(targetHost) ? undefined : targetHost,
381
+ rejectUnauthorized: this.options.rejectUnauthorized !== false,
382
+ });
383
+ tlsSocket.once("secureConnect", () => {
384
+ callback?.(null, tlsSocket);
385
+ });
386
+ tlsSocket.once("error", (tlsErr) => {
387
+ rawSocket.destroy();
388
+ callback?.(tlsErr, undefined as unknown as Duplex);
389
+ });
390
+ }, this.poolId);
391
+ }
392
+ }
393
+ export function createHttpProxyAgent(
394
+ endpoint: string,
395
+ credential?: string,
396
+ policy: SsrfPolicy = {},
397
+ config: AgentConfig = {},
398
+ poolId?: string,
399
+ ): ProxyAgentPair {
400
+ let url: URL;
401
+ try {
402
+ url = new URL(endpoint);
403
+ } catch {
404
+ throw new InvalidPoolAgentConfigError(`invalid HTTP proxy endpoint: ${endpoint}`);
405
+ }
406
+ const proxy = new URL(withCredential(url, credential).toString());
407
+ return {
408
+ http: new HttpProxyAgent(proxy, policy, config, poolId),
409
+ https: new HttpsProxyAgent(proxy, policy, config, poolId),
410
+ ...(isRelayHost(proxy.hostname) ? { relayEndpoint: proxy } : {}),
411
+ };
412
+ }
413
+
414
+ /**
415
+ * Tally a SOCKS5 agent's tunnel.
416
+ *
417
+ * The socks client owns socket creation, so this wraps `createConnection`
418
+ * instead of the CONNECT callback the HTTP path uses. The socket handed back is
419
+ * the raw tunnel (Node applies TLS afterwards for `https:` targets), which is
420
+ * exactly the layer whose bytes the proxy bills. The SOCKS negotiation itself
421
+ * is a handful of bytes exchanged before this point and is not counted.
422
+ */
423
+ function accountSocksAgent(agent: SocksProxyAgent, poolId?: string): void {
424
+ if (!poolId) return;
425
+ const original = agent.createConnection.bind(agent) as (
426
+ options: ClientRequestArgs,
427
+ callback?: (error: Error | null, stream: Duplex) => void,
428
+ ) => Duplex | null | undefined;
429
+ agent.createConnection = ((options: ClientRequestArgs, callback?: (error: Error | null, stream: Duplex) => void) =>
430
+ original(options, (error, socket) => {
431
+ if (socket) {
432
+ accountSocketBytes(socket, poolId);
433
+ accountSocketWrites(socket, poolId);
434
+ }
435
+ callback?.(error, socket);
436
+ })) as unknown as typeof agent.createConnection;
437
+ }
438
+
439
+ export function createSocks5Agent(
440
+ endpoint: string,
441
+ credential?: string,
442
+ policy: SsrfPolicy = {},
443
+ config: AgentConfig = {},
444
+ poolId?: string,
445
+ ): ProxyAgentPair {
446
+ const authority = endpoint.includes("://") ? endpoint : `socks5://${endpoint}`;
447
+ let url: URL;
448
+ try {
449
+ url = new URL(authority);
450
+ } catch {
451
+ throw new InvalidPoolAgentConfigError(`invalid SOCKS5 endpoint: ${endpoint}`);
452
+ }
453
+ // The proxy hostname is resolved by the socks client itself, so the policy
454
+ // is applied through the socket's `lookup`: every connect re-resolves and
455
+ // re-validates the proxy address instead of trusting the create-time check.
456
+ const options = {
457
+ socketOptions: { lookup: createSsrfLookup(policy) },
458
+ keepAlive: true,
459
+ maxSockets: config.maxSockets ?? resolveProxyMaxSockets(),
460
+ maxFreeSockets: config.maxFreeSockets ?? resolveProxyMaxFreeSockets(),
461
+ timeout: config.keepAliveTimeout ?? PROXY_KEEP_ALIVE_TIMEOUT_MS,
462
+ };
463
+ const http = new SocksProxyAgent(withCredential(url, credential), options);
464
+ accountSocksAgent(http, poolId);
465
+ // Same latent Node check as HTTP pools: an `http:`-protocol agent is
466
+ // rejected for `https:` targets, so the https flavor carries an
467
+ // overridden protocol (per-instance shadow — the shared SocksProxyAgent
468
+ // prototype is untouched). Behavior is identical; only the check reads it.
469
+ const https = new SocksProxyAgent(withCredential(url, credential), options);
470
+ https.protocol = "https:";
471
+ accountSocksAgent(https, poolId);
472
+ return {
473
+ http: http as unknown as HttpAgent,
474
+ https: https as unknown as HttpsAgent,
475
+ };
476
+ }
477
+
478
+ export class PoolBindingError extends Error {}