cartethyia 2.5.9 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (386) hide show
  1. package/.env.example +15 -42
  2. package/README.md +11 -10
  3. package/dist/dashboard/assets/{BannedPage-Brvuj_Eb.js → BannedPage-l6XMnfr9.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-CqfXtI3y.js → CliToolDetailPage-DwOzAW45.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-GQeS91bg.js → CliToolsPage-BT3Up3QA.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-BDRsTXBB.js → CombosPage-6kYIUpzR.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-zP8AFHX0.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-BmfrNZ14.js → CustomizationPage-C7m-H1Rz.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-DoreIgm8.js → LoginPage-CbphLvjg.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-C7pDb41R.js → ModelPicker-DVny4S5_.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-BnoPgA7w.js → ProvidersPage-Dfb2896L.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
  27. package/dist/dashboard/assets/{ProxyPage-Bq_TGgdb.js → ProxyPage-BaCIdAUt.js} +1 -1
  28. package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-Day3mCOn.js → SetupPage-D4g6vOGc.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-DQxllnB5.js → SortableList-C93i14BD.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js → StudioPage-SgQpymjm.js} +1 -1
  38. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
  39. package/dist/dashboard/assets/{UsagePage-DXa1oR-4.js → UsagePage-BFME-eba.js} +2 -2
  40. package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
  41. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  42. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  43. package/dist/dashboard/assets/{app-BIt446yE.js → app-2KhtHCv9.js} +1 -1
  44. package/dist/dashboard/assets/{app-BIt446yE.js.gz → app-2KhtHCv9.js.gz} +0 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  46. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  47. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  48. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-CR72dasT.js → badge-CKsF123d.js} +1 -1
  50. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  51. package/dist/dashboard/assets/{banner-CI9_eSfO.js → banner-8j7uChA9.js} +1 -1
  52. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  53. package/dist/dashboard/assets/{card-DS7CQEsO.js → card-CdsSlWA3.js} +1 -1
  54. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  55. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  56. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  57. package/dist/dashboard/assets/{clipboard-button-611pSsaX.js → clipboard-button-uL5aYLfW.js} +1 -1
  58. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  59. package/dist/dashboard/assets/{dialog-k37exjNt.js → dialog-C07hr5_G.js} +1 -1
  60. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  61. package/dist/dashboard/assets/{drawer-CX7N8ucR.js → drawer-BPO1TcKH.js} +1 -1
  62. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CTP8AyiS.js → entry-Bz_CtDfC.js} +1 -1
  64. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
  66. package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  68. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  69. package/dist/dashboard/assets/{github-badge-sltk1qUi.js → github-badge-DmQreSDd.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-z3zDOSMM.js → index-Bblu6erR.js} +2 -2
  72. package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-BhY2sSnO.js → inline-C9dCYaBZ.js} +1 -1
  74. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-V1umFiBk.js → input-CjCnXweJ.js} +1 -1
  76. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  77. package/dist/dashboard/assets/{layout-BBOYycyS.js → layout-V6y8DAkl.js} +1 -1
  78. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-1zzZYM0B.js → list-row-CM1AaG_y.js} +1 -1
  80. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  82. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  86. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  87. package/dist/dashboard/assets/{numbers-DkfMR5Qs.js → numbers-BOevJzdz.js} +1 -1
  88. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  92. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-C6MMiaio.js → select-C980c4cz.js} +1 -1
  94. package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  96. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-BHTfXdz_.js → stack-_gfTdLVB.js} +1 -1
  98. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-mPDRlQkl.js → state-2c5V4zo7.js} +1 -1
  100. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DOmJxg7H.js → switch-Dkke7Naf.js} +1 -1
  102. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  103. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  104. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  105. package/dist/dashboard/index.html +1 -1
  106. package/dist/dashboard/index.html.gz +0 -0
  107. package/dist/dashboard/precompressed-assets.json +51 -51
  108. package/migrations/README.md +52 -52
  109. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  110. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  111. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  112. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  113. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  114. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  115. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  116. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  117. package/package.json +96 -97
  118. package/scripts/cli/admin.ts +126 -125
  119. package/scripts/cli/autostart.ts +237 -237
  120. package/scripts/cli/background.ts +120 -110
  121. package/scripts/cli/bin/carte.cjs +8 -8
  122. package/scripts/cli/bin/cartethyia.cjs +167 -167
  123. package/scripts/cli/console-menu.ts +308 -275
  124. package/scripts/cli/controller.ts +145 -0
  125. package/scripts/cli/doctor.ts +1 -1
  126. package/scripts/cli/env.ts +99 -99
  127. package/scripts/cli/gateway.ts +264 -261
  128. package/scripts/{health.ts → cli/health.ts} +4 -4
  129. package/scripts/cli/instance.ts +234 -224
  130. package/scripts/cli/main.ts +188 -164
  131. package/scripts/cli/platform.ts +64 -64
  132. package/scripts/cli/prompt.ts +275 -107
  133. package/scripts/cli/services.ts +88 -88
  134. package/scripts/cli/setup.ts +445 -445
  135. package/scripts/cli/start.ts +85 -82
  136. package/scripts/cli/startup-signal.ts +23 -23
  137. package/scripts/cli/telemetry.ts +70 -0
  138. package/scripts/cli/tray/index.ts +263 -263
  139. package/scripts/cli/tray/windows.ps1 +107 -107
  140. package/scripts/cli/tray.ts +192 -208
  141. package/scripts/cli/update.ts +39 -39
  142. package/scripts/cli/version.ts +67 -67
  143. package/scripts/cli/wordmark.ts +58 -58
  144. package/src/boot/app.ts +16 -1
  145. package/src/boot/dependencies.ts +128 -25
  146. package/src/config/package-paths.ts +24 -0
  147. package/src/config/resources.ts +137 -0
  148. package/src/config/runtime.ts +133 -76
  149. package/src/config/spec.ts +30 -29
  150. package/src/config/telemetry.ts +0 -25
  151. package/src/console/api-keys/bucket-spend.ts +14 -17
  152. package/src/console/api-keys/contracts.ts +410 -410
  153. package/src/console/api-keys/routes.ts +12 -0
  154. package/src/console/audit/store.ts +2 -3
  155. package/src/console/auth/setup-service.ts +8 -1
  156. package/src/console/backup/routes.ts +7 -0
  157. package/src/console/backup/service.ts +463 -463
  158. package/src/console/backup/store.ts +16 -11
  159. package/src/console/backup/validate.ts +1 -4
  160. package/src/console/cli-tools/fs-ops.ts +18 -2
  161. package/src/console/data-management/contracts.ts +1 -8
  162. package/src/console/data-management/operations.ts +61 -48
  163. package/src/console/data-management/routes.ts +4 -2
  164. package/src/console/domain-registration.ts +25 -3
  165. package/src/console/inferhub/client.ts +139 -0
  166. package/src/console/inferhub/key-store.ts +129 -0
  167. package/src/console/inferhub/normalize.ts +347 -0
  168. package/src/console/inferhub/routes.ts +411 -0
  169. package/src/console/inferhub/store.ts +109 -0
  170. package/src/console/inferhub/types.ts +183 -0
  171. package/src/console/observability/contracts.ts +2 -41
  172. package/src/console/observability/store.ts +176 -371
  173. package/src/console/providers/catalog/public-model-store.ts +0 -0
  174. package/src/console/providers/catalog/store.ts +23 -36
  175. package/src/console/quota/account-quota-shared.ts +52 -52
  176. package/src/console/quota/account-quota-tenant.ts +16 -24
  177. package/src/console/quota/refresh.ts +441 -441
  178. package/src/console/remote-support/service.ts +270 -114
  179. package/src/console/routing/pools/import-parse.ts +206 -206
  180. package/src/console/routing/pools/operations.ts +543 -543
  181. package/src/console/routing/pools/probe-verdict.ts +132 -132
  182. package/src/console/routing/pools/routes.ts +181 -181
  183. package/src/console/routing/pools/store.ts +1 -1
  184. package/src/console/settings/contracts.ts +42 -40
  185. package/src/console/settings/store.ts +25 -25
  186. package/src/console/share/share-stats.ts +22 -4
  187. package/src/console/share/share-usage.ts +30 -4
  188. package/src/console/shared/cursor.ts +15 -0
  189. package/src/console/updates/repository-updates.ts +40 -39
  190. package/src/console/updates/routes.ts +12 -0
  191. package/src/main.ts +13 -5
  192. package/src/network/pool/resolver.ts +1104 -1104
  193. package/src/observability/client-name.ts +22 -0
  194. package/src/observability/event-log.ts +5 -29
  195. package/src/observability/event-query.ts +690 -0
  196. package/src/observability/log-ring.ts +26 -2
  197. package/src/observability/log-sanitize.ts +118 -0
  198. package/src/observability/logger.ts +17 -13
  199. package/src/observability/metrics.ts +35 -0
  200. package/src/observability/payload-capture.ts +114 -41
  201. package/src/observability/payload-store.ts +249 -24
  202. package/src/observability/raw-event-writer.ts +118 -0
  203. package/src/observability/runtime-metrics.ts +71 -1
  204. package/src/observability/telemetry-buffer.ts +100 -17
  205. package/src/observability/telemetry-reconciliation.ts +417 -0
  206. package/src/persistence/boot.ts +20 -9
  207. package/src/persistence/db-pg.ts +13 -0
  208. package/src/persistence/db-sqlite.ts +569 -497
  209. package/src/persistence/dialect.ts +3 -2
  210. package/src/persistence/migrate.ts +181 -181
  211. package/src/persistence/schema.ts +1496 -1406
  212. package/src/persistence/shared-cache.ts +5 -1
  213. package/src/persistence/stores/api-key-store.ts +581 -581
  214. package/src/persistence/stores/telemetry-store.ts +124 -63
  215. package/src/protocol/reasoning-text.ts +30 -0
  216. package/src/protocol/registry.ts +2 -0
  217. package/src/protocol/response/chat.ts +33 -7
  218. package/src/protocol/response/codex.ts +14 -2
  219. package/src/protocol/response/messages.ts +5 -2
  220. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  221. package/src/providers/bundled-discovery-providers.ts +122 -122
  222. package/src/providers/default-registry.ts +14 -0
  223. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  224. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  225. package/src/providers/discovery/probe-phases.ts +43 -7
  226. package/src/providers/discovery/probing-service.ts +70 -32
  227. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  228. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  231. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  232. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  233. package/src/providers/integrations/buddy/capabilities.ts +4 -0
  234. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  235. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  236. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  237. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  238. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  239. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  240. package/src/providers/integrations/claude/claude.ts +3 -4
  241. package/src/providers/integrations/cline/cline.ts +94 -8
  242. package/src/providers/integrations/codex/codex.ts +61 -37
  243. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  244. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  245. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  246. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  247. package/src/providers/integrations/cursor/cursor.ts +29 -17
  248. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  249. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  250. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  251. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  252. package/src/providers/integrations/github/github.ts +3 -1
  253. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  254. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  255. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  256. package/src/providers/integrations/hermes.ts +7 -1
  257. package/src/providers/integrations/inferhub.ts +201 -73
  258. package/src/providers/integrations/kilo/kilo.ts +152 -152
  259. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  260. package/src/providers/integrations/opencode/provider.ts +2 -0
  261. package/src/providers/integrations/openrouter/provider.ts +129 -129
  262. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  263. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  264. package/src/providers/integrations/trae/trae-headers.ts +17 -48
  265. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  266. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  267. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  268. package/src/providers/integrations/trae/trae.ts +22 -24
  269. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  270. package/src/providers/integrations/zcode/oauth.ts +1 -1
  271. package/src/providers/operations/account-health-service.ts +605 -279
  272. package/src/providers/provider-metadata.ts +12 -1
  273. package/src/providers/provider-model-definition.ts +373 -373
  274. package/src/providers/provider-probe.ts +3 -0
  275. package/src/providers/quota/quota-contracts.ts +3 -7
  276. package/src/providers/search/search-provider.ts +2 -6
  277. package/src/security/admission/contracts.ts +10 -0
  278. package/src/security/admission/in-memory-store.ts +89 -14
  279. package/src/security/admission/lua.ts +191 -182
  280. package/src/security/admission/redis-store.ts +30 -0
  281. package/src/security/admission/service.ts +18 -0
  282. package/src/security/api-key-auth-cache.ts +96 -0
  283. package/src/security/api-key-auth.ts +11 -100
  284. package/src/security/client-router-fingerprint.ts +3 -7
  285. package/src/security/ip-boundary.ts +7 -3
  286. package/src/shared/coerce.ts +67 -0
  287. package/src/shared/hash.ts +6 -0
  288. package/src/shared/time.ts +7 -0
  289. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  290. package/src/transport/dispatch/attempt-loop.ts +103 -81
  291. package/src/transport/dispatch/capture-policy.ts +78 -0
  292. package/src/transport/dispatch/proxy-request.ts +4 -2
  293. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  294. package/src/transport/dispatch/streaming/stream.ts +19 -2
  295. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  296. package/src/transport/failure-policy.ts +27 -0
  297. package/src/transport/guards/abuse-guard.ts +2 -2
  298. package/src/transport/guards/auth-guard.ts +17 -0
  299. package/src/transport/guards/body-policy.ts +3 -2
  300. package/src/transport/guards/concurrency-guard.ts +89 -13
  301. package/src/transport/guards/early-rejection-capture.ts +43 -3
  302. package/src/transport/guards/error-lifecycle.ts +10 -2
  303. package/src/transport/guards/guard-errors.ts +23 -0
  304. package/src/transport/guards/headroom-guard.ts +41 -7
  305. package/src/transport/guards/pipeline.ts +24 -0
  306. package/src/transport/guards/request-context.ts +2 -1
  307. package/src/transport/routing/admission.ts +51 -9
  308. package/src/transport/routing/bounded-lru.ts +49 -0
  309. package/src/transport/routing/candidate-index.ts +88 -0
  310. package/src/transport/routing/reservations.ts +121 -7
  311. package/src/transport/routing/round-robin.ts +10 -22
  312. package/src/transport/routing/route-catalog.ts +26 -13
  313. package/src/transport/routing/route-model.ts +22 -2
  314. package/src/transport/routing/router.ts +53 -31
  315. package/src/transport/surface/chat/encode.ts +2 -3
  316. package/src/transport/surface/chat/parse.ts +2 -1
  317. package/src/transport/surface/dialects.ts +11 -3
  318. package/src/transport/surface/messages/encode.ts +2 -1
  319. package/src/transport/surface/messages/parse.ts +3 -1
  320. package/src/transport/surface/messages/stream.ts +2 -6
  321. package/src/workers/maintenance-lease.ts +200 -0
  322. package/src/workers/scheduler.ts +101 -1
  323. package/dist/dashboard/assets/BannedPage-Brvuj_Eb.js.gz +0 -0
  324. package/dist/dashboard/assets/CliToolDetailPage-CqfXtI3y.js.gz +0 -0
  325. package/dist/dashboard/assets/CliToolsPage-GQeS91bg.js.gz +0 -0
  326. package/dist/dashboard/assets/CombosPage-BDRsTXBB.js.gz +0 -0
  327. package/dist/dashboard/assets/ConsoleLogPage-zP8AFHX0.js.gz +0 -0
  328. package/dist/dashboard/assets/CustomizationPage-BmfrNZ14.js.gz +0 -0
  329. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js +0 -1
  330. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js.gz +0 -0
  331. package/dist/dashboard/assets/LoginPage-DoreIgm8.js.gz +0 -0
  332. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  333. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js +0 -1
  334. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js.gz +0 -0
  335. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js +0 -3
  336. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js.gz +0 -0
  337. package/dist/dashboard/assets/ProvidersPage-BnoPgA7w.js.gz +0 -0
  338. package/dist/dashboard/assets/ProxyPage-Bq_TGgdb.js.gz +0 -0
  339. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js +0 -1
  340. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js.gz +0 -0
  341. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js +0 -1
  342. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js.gz +0 -0
  343. package/dist/dashboard/assets/SetupPage-Day3mCOn.js.gz +0 -0
  344. package/dist/dashboard/assets/SortableList-DQxllnB5.js.gz +0 -0
  345. package/dist/dashboard/assets/UsagePage-DXa1oR-4.js.gz +0 -0
  346. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  347. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  348. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  349. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  350. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  351. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  352. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  353. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  354. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  355. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  356. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  357. package/dist/dashboard/assets/entry-B9Xvtnfj.js +0 -1
  358. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-ByMGkR3C.js +0 -4
  360. package/dist/dashboard/assets/entry-ByMGkR3C.js.gz +0 -0
  361. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  363. package/dist/dashboard/assets/index-z3zDOSMM.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  365. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  367. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  368. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  369. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  370. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  371. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  372. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  373. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  374. package/dist/dashboard/assets/select-C6MMiaio.js.gz +0 -0
  375. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  376. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  377. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  378. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  379. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  380. package/scripts/build/aot.ts +0 -143
  381. package/scripts/build/build.ts +0 -24
  382. package/scripts/build/compile.ts +0 -88
  383. package/scripts/build/restart.ts +0 -120
  384. package/scripts/build/start.ts +0 -25
  385. package/src/observability/event-compaction.ts +0 -317
  386. package/src/observability/event-day-index.ts +0 -440
@@ -1,543 +1,543 @@
1
- // Network-pool control-plane operations: the store-backed mutations and reads
2
- // behind the routes. Depends only on the contracts and the shared helpers;
3
- // `routes.ts` is the only HTTP surface.
4
- import { ConsoleDomainError, requireTenantScope } from "../../shared/errors";
5
- import type { PoolHealthEvent } from "../../../network/pool-health-machine";
6
- import type { AccessDecision } from "../../../security/access-control";
7
- import {
8
- deployRelay as deployRelayWorker,
9
- isRelayTarget,
10
- type RelayDeployRequest,
11
- } from "./relay-deploy";
12
- import { parseProxyFile } from "./import-parse";
13
- import { SPEED_TEST_MAX_BYTES, SPEED_TEST_MIN_BYTES } from "./speed-test-sizes";
14
- import {
15
- MAX_BATCH_PROBE_TARGETS,
16
- PROVIDER_PROBE_BATCH_CONCURRENCY,
17
- POOL_BATCH_PROBE_CONCURRENCY,
18
- POOL_ROUTING_STRATEGIES,
19
- normalizePoolConfig,
20
- sanitizePoolResponse,
21
- validateEndpoint,
22
- validatePoolLimits,
23
- validateTransportConfig,
24
- type CreateNetworkPoolRequest,
25
- type HealthCheckResult,
26
- type PoolBatchProviderProbeResult,
27
- type PoolProviderProbeResult,
28
- type NetworkPoolConfig,
29
- type NetworkPoolRecord,
30
- type NetworkPoolResponse,
31
- type PoolBatchProbeResult,
32
- type PoolSpeedTestResult,
33
- type PoolStrategySetting,
34
- } from "./contracts";
35
-
36
- /**
37
- * Whether a provider entry is a retired egress rather than a refilling
38
- * allowance. Read from the stored flag, never from the duration.
39
- */
40
- function isBlockedEntry(entry: { readonly blocked?: boolean }): boolean {
41
- return entry.blocked === true;
42
- }
43
-
44
- export function createNetworkPoolOperations(config: NetworkPoolConfig) {
45
- const operations = {
46
- async listPools(access: AccessDecision | undefined): Promise<NetworkPoolResponse[]> {
47
- const a = requireTenantScope(access, "dashboard:read");
48
- const rawPools = await config.store.list(a.tenantId);
49
- return Promise.all(
50
- rawPools.map(async (r) => {
51
- const res = sanitizePoolResponse(r);
52
- const cooldowns = await config.poolSelector?.getPoolCooldowns(r.id);
53
- const inflight = config.poolSelector
54
- ? await config.poolSelector.getInflightAuthoritative(r.id)
55
- : res.inflight;
56
- return {
57
- ...res,
58
- inflight,
59
- available: Math.max(0, res.maxInflight - inflight),
60
- utilization: res.maxInflight > 0 ? Math.min(1, inflight / res.maxInflight) : 1,
61
- ...(cooldowns && cooldowns.length > 0
62
- ? {
63
- providerCooldowns: cooldowns.map((c) => ({
64
- providerId: c.providerId,
65
- until: new Date(c.until).toISOString(),
66
- reason: c.reason,
67
- blocked: isBlockedEntry(c),
68
- })),
69
- }
70
- : {}),
71
- };
72
- }),
73
- );
74
- },
75
- async getPoolDetail(
76
- access: AccessDecision | undefined,
77
- poolId: string,
78
- ): Promise<NetworkPoolResponse> {
79
- const a = requireTenantScope(access, "dashboard:read");
80
- const rec = await config.store.get(a.tenantId, poolId);
81
- if (!rec) throw new ConsoleDomainError("pool_not_found", 404, `Pool ${poolId} not found`);
82
- const res = sanitizePoolResponse(rec);
83
- const cooldowns = await config.poolSelector?.getPoolCooldowns(poolId);
84
- const inflight = config.poolSelector
85
- ? await config.poolSelector.getInflightAuthoritative(poolId)
86
- : res.inflight;
87
- return {
88
- ...res,
89
- inflight,
90
- available: Math.max(0, res.maxInflight - inflight),
91
- utilization: res.maxInflight > 0 ? Math.min(1, inflight / res.maxInflight) : 1,
92
- ...(cooldowns && cooldowns.length > 0
93
- ? {
94
- providerCooldowns: cooldowns.map((c) => ({
95
- providerId: c.providerId,
96
- until: new Date(c.until).toISOString(),
97
- reason: c.reason,
98
- blocked: isBlockedEntry(c),
99
- })),
100
- }
101
- : {}),
102
- };
103
- },
104
- async clearPoolCooldown(
105
- access: AccessDecision | undefined,
106
- poolId: string,
107
- providerId?: string,
108
- ): Promise<{ success: boolean }> {
109
- requireTenantScope(access, "dashboard:write");
110
- if (providerId) {
111
- await config.poolSelector?.clearProviderCooldown(poolId, providerId);
112
- } else {
113
- const active = await config.poolSelector?.getPoolCooldowns(poolId);
114
- if (active) {
115
- for (const c of active) {
116
- await config.poolSelector?.clearProviderCooldown(poolId, c.providerId);
117
- }
118
- }
119
- }
120
- return { success: true };
121
- },
122
- async getStrategy(access: AccessDecision | undefined): Promise<PoolStrategySetting> {
123
- const a = requireTenantScope(access, "dashboard:read");
124
- return config.store.getStrategy(a.tenantId);
125
- },
126
- async updateStrategy(
127
- access: AccessDecision | undefined,
128
- request: Partial<PoolStrategySetting>,
129
- ): Promise<PoolStrategySetting> {
130
- const a = requireTenantScope(access, "dashboard:write");
131
- const current = await config.store.getStrategy(a.tenantId);
132
- const strategy = request.strategy ?? current.strategy;
133
- // Runtime check despite the type: the HTTP body is cast at the route
134
- // boundary, so a hand-crafted request can carry any string here.
135
- if (!POOL_ROUTING_STRATEGIES.includes(strategy)) {
136
- throw new ConsoleDomainError("invalid_pool", 400, "Pool strategy is invalid");
137
- }
138
- const rotateCount = request.rotateCount ?? current.rotateCount;
139
- if (!Number.isInteger(rotateCount) || rotateCount < 1 || rotateCount > 1000) {
140
- throw new ConsoleDomainError(
141
- "invalid_pool_limits",
142
- 400,
143
- "rotateCount must be an integer between 1 and 1000",
144
- );
145
- }
146
- const saved = await config.store.setStrategy(a.tenantId, { strategy, rotateCount });
147
- await config.auditSink?.record({
148
- access: a,
149
- action: "network_pool.strategy_updated",
150
- target: a.tenantId,
151
- detail: { strategy: saved.strategy, rotateCount: saved.rotateCount },
152
- });
153
- await config.snapshotInvalidator?.invalidate();
154
- return saved;
155
- },
156
- async importPools(
157
- access: AccessDecision | undefined,
158
- request: {
159
- readonly content: string;
160
- readonly maxInflight?: number | undefined;
161
- readonly weight?: number | undefined;
162
- readonly family?: string | null | undefined;
163
- readonly labelPrefix?: string | undefined;
164
- },
165
- ): Promise<{ created: number; skipped: readonly { line: number; reason: string }[] }> {
166
- const a = requireTenantScope(access, "dashboard:write");
167
- const parsed = parseProxyFile(request.content);
168
- const skipped = [...parsed.skipped];
169
- let created = 0;
170
- for (const [index, row] of parsed.rows.entries()) {
171
- try {
172
- await operations.createPool(a, {
173
- kind: row.kind,
174
- endpoint: row.endpoint,
175
- ...(row.credential === null ? {} : { credential: row.credential }),
176
- ...(request.maxInflight === undefined ? {} : { maxInflight: request.maxInflight }),
177
- ...(request.weight === undefined ? {} : { weight: request.weight }),
178
- ...(request.family === undefined ? {} : { family: request.family }),
179
- ...(row.label !== null
180
- ? { label: row.label }
181
- : request.labelPrefix
182
- ? { label: `${request.labelPrefix} ${index + 1}` }
183
- : {}),
184
- });
185
- created += 1;
186
- } catch (error) {
187
- // One bad node must not abort the list: the rest are still worth
188
- // having, and the reason is reported against its own line.
189
- skipped.push({
190
- line: index + 1,
191
- reason: error instanceof Error ? error.message.slice(0, 160) : "rejected",
192
- });
193
- }
194
- }
195
- return { created, skipped };
196
- },
197
- async createPool(
198
- access: AccessDecision | undefined,
199
- request: CreateNetworkPoolRequest,
200
- ): Promise<NetworkPoolResponse> {
201
- const a = requireTenantScope(access, "dashboard:write");
202
- const normalizedConfig = normalizePoolConfig(request.config);
203
- const endpoint = request.endpoint;
204
- validateEndpoint(request.kind, endpoint, config.ssrfPolicy);
205
- validateTransportConfig(request.kind, normalizedConfig);
206
- validatePoolLimits(request.maxInflight, request.weight);
207
- const record: NetworkPoolRecord = {
208
- id: crypto.randomUUID(),
209
- kind: request.kind,
210
- endpoint,
211
- maxInflight: request.maxInflight ?? 10,
212
- weight: request.weight ?? 100,
213
- status: "active",
214
- inflight: 0,
215
- consecutiveFailures: 0,
216
- tenantId: a.tenantId,
217
- createdAt: new Date().toISOString(),
218
- ...(request.label === undefined ? {} : { label: request.label }),
219
- ...(Object.keys(normalizedConfig).length > 0 ? { config: normalizedConfig } : {}),
220
- ...(request.credential === undefined ? {} : { credential: request.credential }),
221
- ...(request.credential !== undefined ? { hasCredential: true } : {}),
222
- // Absent or `null` both mean unmetered; a positive number is the
223
- // allowance. The body schema already rejects zero and negatives.
224
- ...(typeof request.quotaBytes === "number" ? { quotaBytes: request.quotaBytes } : {}),
225
- ...(request.family === undefined ? {} : { family: request.family }),
226
- };
227
- await config.store.create(record);
228
- await config.auditSink?.record({
229
- access: a,
230
- action: "network_pool.created",
231
- target: record.id,
232
- detail: { kind: record.kind, hasCredential: record.hasCredential === true },
233
- });
234
- await config.snapshotInvalidator?.invalidate();
235
- return sanitizePoolResponse(record);
236
- },
237
- async updatePool(
238
- access: AccessDecision | undefined,
239
- poolId: string,
240
- request: Partial<CreateNetworkPoolRequest>,
241
- ): Promise<NetworkPoolResponse> {
242
- const a = requireTenantScope(access, "dashboard:write");
243
- validatePoolLimits(request.maxInflight, request.weight);
244
- if (
245
- request.endpoint !== undefined ||
246
- request.kind !== undefined ||
247
- request.config !== undefined
248
- ) {
249
- const existing = await config.store.get(a.tenantId, poolId);
250
- if (!existing) throw new ConsoleDomainError("pool_not_found", 404, `Pool ${poolId} not found`);
251
- const effectiveKind = request.kind ?? existing.kind;
252
- const effectiveEndpoint = request.endpoint ?? existing.endpoint;
253
- const mergedConfig = { ...(existing.config ?? {}), ...(request.config ?? {}) };
254
- const normalizedConfig = normalizePoolConfig(mergedConfig);
255
- validateEndpoint(effectiveKind, effectiveEndpoint, config.ssrfPolicy);
256
- validateTransportConfig(effectiveKind, normalizedConfig);
257
- request = { ...request, endpoint: effectiveEndpoint, config: normalizedConfig };
258
- }
259
- const updated = await config.store.update(
260
- a.tenantId,
261
- poolId,
262
- request as Partial<NetworkPoolRecord>,
263
- );
264
- if (!updated) throw new ConsoleDomainError("pool_not_found", 404, `Pool ${poolId} not found`);
265
- // Any mutation can invalidate the cached dial agent (endpoint, credential,
266
- // kind, config, or a disable via status) — release unconditionally so the
267
- // next dispatch rebuilds against the fresh row rather than reusing a
268
- // stale agent/delegate bound to the pre-update configuration.
269
- await config.poolAgentReleaser?.releasePool(poolId, a.tenantId);
270
- await config.auditSink?.record({
271
- access: a,
272
- action: "network_pool.updated",
273
- target: poolId,
274
- detail: { fields: Object.keys(request) },
275
- });
276
- await config.snapshotInvalidator?.invalidate();
277
- return sanitizePoolResponse(updated);
278
- },
279
- async deletePool(
280
- access: AccessDecision | undefined,
281
- poolId: string,
282
- ): Promise<{ success: boolean }> {
283
- const a = requireTenantScope(access, "dashboard:write");
284
- const ok = await config.store.delete(a.tenantId, poolId);
285
- if (!ok) throw new ConsoleDomainError("pool_not_found", 404, `Pool ${poolId} not found`);
286
- await config.poolAgentReleaser?.releasePool(poolId, a.tenantId);
287
- // Drop the pool's cooldown markers and their index set with it. Without
288
- // this the Redis keys outlive the row, so a pool later reusing the id
289
- // inherits cooldowns it never earned — and the index set, which has no
290
- // TTL of its own, would keep listing them.
291
- await config.poolSelector?.clearPoolCooldowns(poolId);
292
- await config.auditSink?.record({
293
- access: a,
294
- action: "network_pool.deleted",
295
- target: poolId,
296
- });
297
- await config.snapshotInvalidator?.invalidate();
298
- return { success: true };
299
- },
300
- async listPoolHealthEvents(
301
- access: AccessDecision | undefined,
302
- poolId: string,
303
- ): Promise<readonly PoolHealthEvent[]> {
304
- const a = requireTenantScope(access, "dashboard:read");
305
- return config.store.listHealthEvents(a.tenantId, poolId);
306
- },
307
- async recoverPool(
308
- access: AccessDecision | undefined,
309
- poolId: string,
310
- ): Promise<{ success: true }> {
311
- const a = requireTenantScope(access, "dashboard:write");
312
- const recovered = await config.store.recover(a.tenantId, poolId);
313
- if (!recovered) throw new ConsoleDomainError("pool_not_found", 404, `Pool ${poolId} not found`);
314
- await config.poolAgentReleaser?.releasePool(poolId, a.tenantId);
315
- await config.auditSink?.record({
316
- access: a,
317
- action: "network_pool.recovered",
318
- target: poolId,
319
- });
320
- await config.snapshotInvalidator?.invalidate();
321
- return { success: true };
322
- },
323
- async healthCheck(
324
- access: AccessDecision | undefined,
325
- poolId: string,
326
- ): Promise<HealthCheckResult> {
327
- const a = requireTenantScope(access, "dashboard:read");
328
- const result = await config.store.healthCheck(a.tenantId, poolId);
329
- if (result.httpStatus !== undefined) {
330
- await config.poolAgentReleaser?.releasePool(poolId, a.tenantId);
331
- await config.snapshotInvalidator?.invalidate();
332
- }
333
- return result;
334
- },
335
- /**
336
- * Measures download throughput through one saved pool.
337
- *
338
- * Read-scoped like the health check: it dials the tenant's own pool and
339
- * changes no state. A pool that cannot be dialed reports `failed` rather
340
- * than throwing, so a batch of these returns a per-pool verdict.
341
- */
342
- async speedTest(
343
- access: AccessDecision | undefined,
344
- poolId: string,
345
- bytes: number,
346
- ): Promise<PoolSpeedTestResult> {
347
- const a = requireTenantScope(access, "dashboard:read");
348
- if (!config.store.speedTest) {
349
- throw new ConsoleDomainError("not_supported", 400, "Speed tests are unavailable");
350
- }
351
- if (!Number.isInteger(bytes) || bytes < SPEED_TEST_MIN_BYTES || bytes > SPEED_TEST_MAX_BYTES) {
352
- throw new ConsoleDomainError(
353
- "invalid_speed_test_size",
354
- 422,
355
- `Payload must be between ${SPEED_TEST_MIN_BYTES} and ${SPEED_TEST_MAX_BYTES} bytes`,
356
- );
357
- }
358
- return config.store.speedTest(a.tenantId, poolId, bytes);
359
- },
360
- /**
361
- * Asks one provider whether it will serve traffic from this pool.
362
- *
363
- * Read-only by default: an operator testing a proxy wants to know its
364
- * state, not to change it. `block` is the explicit opt-in that also
365
- * retires the pool for that provider.
366
- */
367
- async providerProbe(
368
- access: AccessDecision | undefined,
369
- poolId: string,
370
- providerId: string,
371
- ): Promise<PoolProviderProbeResult> {
372
- const a = requireTenantScope(access, "dashboard:read");
373
- if (!config.store.providerProbe) {
374
- throw new ConsoleDomainError("not_supported", 400, "Provider probing is unavailable");
375
- }
376
- const result = await config.store.providerProbe(a.tenantId, poolId, providerId);
377
- if (!config.poolSelector) return result;
378
- // The verdict *is* the enforcement. Storing it and leaving the pool in
379
- // rotation is what produced "I tested every pool, some are red, and it
380
- // still picked a red one": the red was a colour, not a decision.
381
- //
382
- // `blocked` retires the egress for this provider, so the next dispatch
383
- // skips it. Anything else clears a previous block, because the address is
384
- // serving again — an operator who replaced a proxy must not have to find
385
- // a separate button to un-retire it.
386
- if (result.status === "blocked") {
387
- await config.poolSelector.flagProviderBlocked(
388
- poolId,
389
- providerId,
390
- "Upstream refused this egress address",
391
- );
392
- return { ...result, blockedPool: true };
393
- }
394
- await config.poolSelector.clearProviderCooldown(poolId, providerId);
395
- return { ...result, blockedPool: false };
396
- },
397
- /**
398
- * Probes many unsaved pool definitions against one provider.
399
- *
400
- * Same shape and bounds as `probeAdHocPoolBatch`: the add form tests a
401
- * pasted list before saving it, and a per-endpoint verdict is what it
402
- * prints. A single failing probe becomes that endpoint's result rather
403
- * than failing the batch.
404
- */
405
- async probeAdHocProviderBatch(
406
- access: AccessDecision | undefined,
407
- requests: readonly CreateNetworkPoolRequest[],
408
- providerId: string,
409
- ): Promise<readonly PoolBatchProviderProbeResult[]> {
410
- const a = requireTenantScope(access, "dashboard:read");
411
- if (requests.length === 0) return [];
412
- if (requests.length > MAX_BATCH_PROBE_TARGETS) {
413
- throw new ConsoleDomainError(
414
- "batch_too_large",
415
- 422,
416
- `At most ${MAX_BATCH_PROBE_TARGETS} targets per batch`,
417
- );
418
- }
419
- if (!config.store.probeAdHocProvider) {
420
- throw new ConsoleDomainError("not_supported", 400, "Provider probing is unavailable");
421
- }
422
- const probe = config.store.probeAdHocProvider.bind(config.store);
423
- const results: PoolBatchProviderProbeResult[] = new Array(requests.length);
424
- let cursor = 0;
425
- const worker = async () => {
426
- while (cursor < requests.length) {
427
- const index = cursor++;
428
- const request = requests[index]!;
429
- let result: PoolProviderProbeResult;
430
- try {
431
- result = await probe(a.tenantId, request, providerId);
432
- } catch (error) {
433
- result = {
434
- poolId: request.endpoint,
435
- providerId,
436
- status: "unreachable",
437
- reason: error instanceof Error ? error.message : "Probe failed",
438
- };
439
- }
440
- results[index] = { endpoint: request.endpoint, result };
441
- }
442
- };
443
- await Promise.all(
444
- Array.from({ length: Math.min(PROVIDER_PROBE_BATCH_CONCURRENCY, requests.length) }, () => worker()),
445
- );
446
- return results;
447
- },
448
- async probeAdHocPool(
449
- access: AccessDecision | undefined,
450
- request: CreateNetworkPoolRequest,
451
- ): Promise<HealthCheckResult> {
452
- const a = requireTenantScope(access, "dashboard:read");
453
- if (config.store.probeAdHoc) {
454
- return config.store.probeAdHoc(a.tenantId, request);
455
- }
456
- throw new ConsoleDomainError("not_supported", 400, "Ad-hoc pool probing is unavailable");
457
- },
458
- /**
459
- * Probes many unsaved pool definitions in one call.
460
- *
461
- * The dashboard tests a pasted list before saving it; doing that one
462
- * request per proxy made a 100-line paste take a minute. The dials still
463
- * happen individually — only the round-trips are batched — and run with
464
- * bounded concurrency so a large list does not open every socket at once.
465
- */
466
- async probeAdHocPoolBatch(
467
- access: AccessDecision | undefined,
468
- requests: readonly CreateNetworkPoolRequest[],
469
- ): Promise<readonly PoolBatchProbeResult[]> {
470
- const a = requireTenantScope(access, "dashboard:read");
471
- if (requests.length === 0) return [];
472
- if (requests.length > MAX_BATCH_PROBE_TARGETS) {
473
- throw new ConsoleDomainError(
474
- "batch_too_large",
475
- 422,
476
- `At most ${MAX_BATCH_PROBE_TARGETS} targets per batch`,
477
- );
478
- }
479
- if (!config.store.probeAdHoc) {
480
- throw new ConsoleDomainError("not_supported", 400, "Ad-hoc pool probing is unavailable");
481
- }
482
- const probe = config.store.probeAdHoc.bind(config.store);
483
- const results: PoolBatchProbeResult[] = new Array(requests.length);
484
- let cursor = 0;
485
- const worker = async () => {
486
- while (cursor < requests.length) {
487
- const index = cursor++;
488
- const request = requests[index]!;
489
- // A single failing probe must not fail the batch: the caller wants a
490
- // per-endpoint verdict, so an error becomes that endpoint's result.
491
- let result: HealthCheckResult;
492
- try {
493
- result = await probe(a.tenantId, request);
494
- } catch (error) {
495
- result = {
496
- poolId: request.endpoint,
497
- status: "unhealthy",
498
- errorMessage: error instanceof Error ? error.message : "Probe failed",
499
- };
500
- }
501
- results[index] = { endpoint: request.endpoint, kind: request.kind, result };
502
- }
503
- };
504
- const workers = Array.from(
505
- { length: Math.min(POOL_BATCH_PROBE_CONCURRENCY, requests.length) },
506
- () => worker(),
507
- );
508
- await Promise.all(workers);
509
- return results;
510
- },
511
- /**
512
- * Deploys a hosted relay (Cloudflare/Vercel/Deno) and registers its URL as
513
- * an active HTTP pool. The provider API token is used for the deploy and
514
- * never persisted; the relay URL is a public host, which is what the pool
515
- * stores. The deploy fetch is the SSRF-validated one, so the provider API
516
- * call is checked like every other egress.
517
- */
518
- async deployRelay(
519
- access: AccessDecision | undefined,
520
- request: RelayDeployRequest,
521
- ): Promise<NetworkPoolResponse & { relayUrl: string }> {
522
- const a = requireTenantScope(access, "dashboard:write");
523
- if (!config.relayFetch)
524
- throw new ConsoleDomainError("not_supported", 503, "Relay deployment is unavailable");
525
- if (!isRelayTarget(request.target))
526
- throw new ConsoleDomainError("invalid_request", 422, `Unsupported relay target: ${String(request.target)}`);
527
- const result = await deployRelayWorker(config.relayFetch, request);
528
- const created = await operations.createPool(access, {
529
- kind: "http",
530
- endpoint: result.relayUrl,
531
- label: request.projectName?.trim() || `${result.target} relay`,
532
- });
533
- await config.auditSink?.record({
534
- access: a,
535
- action: "network_pool.relay_deployed",
536
- target: created.id,
537
- detail: { target: result.target, relayUrl: result.relayUrl },
538
- });
539
- return { ...created, relayUrl: result.relayUrl };
540
- },
541
- };
542
- return operations;
543
- }
1
+ // Network-pool control-plane operations: the store-backed mutations and reads
2
+ // behind the routes. Depends only on the contracts and the shared helpers;
3
+ // `routes.ts` is the only HTTP surface.
4
+ import { ConsoleDomainError, requireTenantScope } from "../../shared/errors";
5
+ import type { PoolHealthEvent } from "../../../network/pool-health-machine";
6
+ import type { AccessDecision } from "../../../security/access-control";
7
+ import {
8
+ deployRelay as deployRelayWorker,
9
+ isRelayTarget,
10
+ type RelayDeployRequest,
11
+ } from "./relay-deploy";
12
+ import { parseProxyFile } from "./import-parse";
13
+ import { SPEED_TEST_MAX_BYTES, SPEED_TEST_MIN_BYTES } from "./speed-test-sizes";
14
+ import {
15
+ MAX_BATCH_PROBE_TARGETS,
16
+ PROVIDER_PROBE_BATCH_CONCURRENCY,
17
+ POOL_BATCH_PROBE_CONCURRENCY,
18
+ POOL_ROUTING_STRATEGIES,
19
+ normalizePoolConfig,
20
+ sanitizePoolResponse,
21
+ validateEndpoint,
22
+ validatePoolLimits,
23
+ validateTransportConfig,
24
+ type CreateNetworkPoolRequest,
25
+ type HealthCheckResult,
26
+ type PoolBatchProviderProbeResult,
27
+ type PoolProviderProbeResult,
28
+ type NetworkPoolConfig,
29
+ type NetworkPoolRecord,
30
+ type NetworkPoolResponse,
31
+ type PoolBatchProbeResult,
32
+ type PoolSpeedTestResult,
33
+ type PoolStrategySetting,
34
+ } from "./contracts";
35
+
36
+ /**
37
+ * Whether a provider entry is a retired egress rather than a refilling
38
+ * allowance. Read from the stored flag, never from the duration.
39
+ */
40
+ function isBlockedEntry(entry: { readonly blocked?: boolean }): boolean {
41
+ return entry.blocked === true;
42
+ }
43
+
44
+ export function createNetworkPoolOperations(config: NetworkPoolConfig) {
45
+ const operations = {
46
+ async listPools(access: AccessDecision | undefined): Promise<NetworkPoolResponse[]> {
47
+ const a = requireTenantScope(access, "dashboard:read");
48
+ const rawPools = await config.store.list(a.tenantId);
49
+ return Promise.all(
50
+ rawPools.map(async (r) => {
51
+ const res = sanitizePoolResponse(r);
52
+ const cooldowns = await config.poolSelector?.getPoolCooldowns(r.id);
53
+ const inflight = config.poolSelector
54
+ ? await config.poolSelector.getInflightAuthoritative(r.id)
55
+ : res.inflight;
56
+ return {
57
+ ...res,
58
+ inflight,
59
+ available: Math.max(0, res.maxInflight - inflight),
60
+ utilization: res.maxInflight > 0 ? Math.min(1, inflight / res.maxInflight) : 1,
61
+ ...(cooldowns && cooldowns.length > 0
62
+ ? {
63
+ providerCooldowns: cooldowns.map((c) => ({
64
+ providerId: c.providerId,
65
+ until: new Date(c.until).toISOString(),
66
+ reason: c.reason,
67
+ blocked: isBlockedEntry(c),
68
+ })),
69
+ }
70
+ : {}),
71
+ };
72
+ }),
73
+ );
74
+ },
75
+ async getPoolDetail(
76
+ access: AccessDecision | undefined,
77
+ poolId: string,
78
+ ): Promise<NetworkPoolResponse> {
79
+ const a = requireTenantScope(access, "dashboard:read");
80
+ const rec = await config.store.get(a.tenantId, poolId);
81
+ if (!rec) throw new ConsoleDomainError("pool_not_found", 404, `Pool ${poolId} not found`);
82
+ const res = sanitizePoolResponse(rec);
83
+ const cooldowns = await config.poolSelector?.getPoolCooldowns(poolId);
84
+ const inflight = config.poolSelector
85
+ ? await config.poolSelector.getInflightAuthoritative(poolId)
86
+ : res.inflight;
87
+ return {
88
+ ...res,
89
+ inflight,
90
+ available: Math.max(0, res.maxInflight - inflight),
91
+ utilization: res.maxInflight > 0 ? Math.min(1, inflight / res.maxInflight) : 1,
92
+ ...(cooldowns && cooldowns.length > 0
93
+ ? {
94
+ providerCooldowns: cooldowns.map((c) => ({
95
+ providerId: c.providerId,
96
+ until: new Date(c.until).toISOString(),
97
+ reason: c.reason,
98
+ blocked: isBlockedEntry(c),
99
+ })),
100
+ }
101
+ : {}),
102
+ };
103
+ },
104
+ async clearPoolCooldown(
105
+ access: AccessDecision | undefined,
106
+ poolId: string,
107
+ providerId?: string,
108
+ ): Promise<{ success: boolean }> {
109
+ requireTenantScope(access, "dashboard:write");
110
+ if (providerId) {
111
+ await config.poolSelector?.clearProviderCooldown(poolId, providerId);
112
+ } else {
113
+ const active = await config.poolSelector?.getPoolCooldowns(poolId);
114
+ if (active) {
115
+ for (const c of active) {
116
+ await config.poolSelector?.clearProviderCooldown(poolId, c.providerId);
117
+ }
118
+ }
119
+ }
120
+ return { success: true };
121
+ },
122
+ async getStrategy(access: AccessDecision | undefined): Promise<PoolStrategySetting> {
123
+ const a = requireTenantScope(access, "dashboard:read");
124
+ return config.store.getStrategy(a.tenantId);
125
+ },
126
+ async updateStrategy(
127
+ access: AccessDecision | undefined,
128
+ request: Partial<PoolStrategySetting>,
129
+ ): Promise<PoolStrategySetting> {
130
+ const a = requireTenantScope(access, "dashboard:write");
131
+ const current = await config.store.getStrategy(a.tenantId);
132
+ const strategy = request.strategy ?? current.strategy;
133
+ // Runtime check despite the type: the HTTP body is cast at the route
134
+ // boundary, so a hand-crafted request can carry any string here.
135
+ if (!POOL_ROUTING_STRATEGIES.includes(strategy)) {
136
+ throw new ConsoleDomainError("invalid_pool", 400, "Pool strategy is invalid");
137
+ }
138
+ const rotateCount = request.rotateCount ?? current.rotateCount;
139
+ if (!Number.isInteger(rotateCount) || rotateCount < 1 || rotateCount > 1000) {
140
+ throw new ConsoleDomainError(
141
+ "invalid_pool_limits",
142
+ 400,
143
+ "rotateCount must be an integer between 1 and 1000",
144
+ );
145
+ }
146
+ const saved = await config.store.setStrategy(a.tenantId, { strategy, rotateCount });
147
+ await config.auditSink?.record({
148
+ access: a,
149
+ action: "network_pool.strategy_updated",
150
+ target: a.tenantId,
151
+ detail: { strategy: saved.strategy, rotateCount: saved.rotateCount },
152
+ });
153
+ await config.snapshotInvalidator?.invalidate();
154
+ return saved;
155
+ },
156
+ async importPools(
157
+ access: AccessDecision | undefined,
158
+ request: {
159
+ readonly content: string;
160
+ readonly maxInflight?: number | undefined;
161
+ readonly weight?: number | undefined;
162
+ readonly family?: string | null | undefined;
163
+ readonly labelPrefix?: string | undefined;
164
+ },
165
+ ): Promise<{ created: number; skipped: readonly { line: number; reason: string }[] }> {
166
+ const a = requireTenantScope(access, "dashboard:write");
167
+ const parsed = parseProxyFile(request.content);
168
+ const skipped = [...parsed.skipped];
169
+ let created = 0;
170
+ for (const [index, row] of parsed.rows.entries()) {
171
+ try {
172
+ await operations.createPool(a, {
173
+ kind: row.kind,
174
+ endpoint: row.endpoint,
175
+ ...(row.credential === null ? {} : { credential: row.credential }),
176
+ ...(request.maxInflight === undefined ? {} : { maxInflight: request.maxInflight }),
177
+ ...(request.weight === undefined ? {} : { weight: request.weight }),
178
+ ...(request.family === undefined ? {} : { family: request.family }),
179
+ ...(row.label !== null
180
+ ? { label: row.label }
181
+ : request.labelPrefix
182
+ ? { label: `${request.labelPrefix} ${index + 1}` }
183
+ : {}),
184
+ });
185
+ created += 1;
186
+ } catch (error) {
187
+ // One bad node must not abort the list: the rest are still worth
188
+ // having, and the reason is reported against its own line.
189
+ skipped.push({
190
+ line: index + 1,
191
+ reason: error instanceof Error ? error.message.slice(0, 160) : "rejected",
192
+ });
193
+ }
194
+ }
195
+ return { created, skipped };
196
+ },
197
+ async createPool(
198
+ access: AccessDecision | undefined,
199
+ request: CreateNetworkPoolRequest,
200
+ ): Promise<NetworkPoolResponse> {
201
+ const a = requireTenantScope(access, "dashboard:write");
202
+ const normalizedConfig = normalizePoolConfig(request.config);
203
+ const endpoint = request.endpoint;
204
+ validateEndpoint(request.kind, endpoint, config.ssrfPolicy);
205
+ validateTransportConfig(request.kind, normalizedConfig);
206
+ validatePoolLimits(request.maxInflight, request.weight);
207
+ const record: NetworkPoolRecord = {
208
+ id: crypto.randomUUID(),
209
+ kind: request.kind,
210
+ endpoint,
211
+ maxInflight: request.maxInflight ?? 10,
212
+ weight: request.weight ?? 100,
213
+ status: "active",
214
+ inflight: 0,
215
+ consecutiveFailures: 0,
216
+ tenantId: a.tenantId,
217
+ createdAt: new Date().toISOString(),
218
+ ...(request.label === undefined ? {} : { label: request.label }),
219
+ ...(Object.keys(normalizedConfig).length > 0 ? { config: normalizedConfig } : {}),
220
+ ...(request.credential === undefined ? {} : { credential: request.credential }),
221
+ ...(request.credential !== undefined ? { hasCredential: true } : {}),
222
+ // Absent or `null` both mean unmetered; a positive number is the
223
+ // allowance. The body schema already rejects zero and negatives.
224
+ ...(typeof request.quotaBytes === "number" ? { quotaBytes: request.quotaBytes } : {}),
225
+ ...(request.family === undefined ? {} : { family: request.family }),
226
+ };
227
+ await config.store.create(record);
228
+ await config.auditSink?.record({
229
+ access: a,
230
+ action: "network_pool.created",
231
+ target: record.id,
232
+ detail: { kind: record.kind, hasCredential: record.hasCredential === true },
233
+ });
234
+ await config.snapshotInvalidator?.invalidate();
235
+ return sanitizePoolResponse(record);
236
+ },
237
+ async updatePool(
238
+ access: AccessDecision | undefined,
239
+ poolId: string,
240
+ request: Partial<CreateNetworkPoolRequest>,
241
+ ): Promise<NetworkPoolResponse> {
242
+ const a = requireTenantScope(access, "dashboard:write");
243
+ validatePoolLimits(request.maxInflight, request.weight);
244
+ if (
245
+ request.endpoint !== undefined ||
246
+ request.kind !== undefined ||
247
+ request.config !== undefined
248
+ ) {
249
+ const existing = await config.store.get(a.tenantId, poolId);
250
+ if (!existing) throw new ConsoleDomainError("pool_not_found", 404, `Pool ${poolId} not found`);
251
+ const effectiveKind = request.kind ?? existing.kind;
252
+ const effectiveEndpoint = request.endpoint ?? existing.endpoint;
253
+ const mergedConfig = { ...(existing.config ?? {}), ...(request.config ?? {}) };
254
+ const normalizedConfig = normalizePoolConfig(mergedConfig);
255
+ validateEndpoint(effectiveKind, effectiveEndpoint, config.ssrfPolicy);
256
+ validateTransportConfig(effectiveKind, normalizedConfig);
257
+ request = { ...request, endpoint: effectiveEndpoint, config: normalizedConfig };
258
+ }
259
+ const updated = await config.store.update(
260
+ a.tenantId,
261
+ poolId,
262
+ request as Partial<NetworkPoolRecord>,
263
+ );
264
+ if (!updated) throw new ConsoleDomainError("pool_not_found", 404, `Pool ${poolId} not found`);
265
+ // Any mutation can invalidate the cached dial agent (endpoint, credential,
266
+ // kind, config, or a disable via status) — release unconditionally so the
267
+ // next dispatch rebuilds against the fresh row rather than reusing a
268
+ // stale agent/delegate bound to the pre-update configuration.
269
+ await config.poolAgentReleaser?.releasePool(poolId, a.tenantId);
270
+ await config.auditSink?.record({
271
+ access: a,
272
+ action: "network_pool.updated",
273
+ target: poolId,
274
+ detail: { fields: Object.keys(request) },
275
+ });
276
+ await config.snapshotInvalidator?.invalidate();
277
+ return sanitizePoolResponse(updated);
278
+ },
279
+ async deletePool(
280
+ access: AccessDecision | undefined,
281
+ poolId: string,
282
+ ): Promise<{ success: boolean }> {
283
+ const a = requireTenantScope(access, "dashboard:write");
284
+ const ok = await config.store.delete(a.tenantId, poolId);
285
+ if (!ok) throw new ConsoleDomainError("pool_not_found", 404, `Pool ${poolId} not found`);
286
+ await config.poolAgentReleaser?.releasePool(poolId, a.tenantId);
287
+ // Drop the pool's cooldown markers and their index set with it. Without
288
+ // this the Redis keys outlive the row, so a pool later reusing the id
289
+ // inherits cooldowns it never earned — and the index set, which has no
290
+ // TTL of its own, would keep listing them.
291
+ await config.poolSelector?.clearPoolCooldowns(poolId);
292
+ await config.auditSink?.record({
293
+ access: a,
294
+ action: "network_pool.deleted",
295
+ target: poolId,
296
+ });
297
+ await config.snapshotInvalidator?.invalidate();
298
+ return { success: true };
299
+ },
300
+ async listPoolHealthEvents(
301
+ access: AccessDecision | undefined,
302
+ poolId: string,
303
+ ): Promise<readonly PoolHealthEvent[]> {
304
+ const a = requireTenantScope(access, "dashboard:read");
305
+ return config.store.listHealthEvents(a.tenantId, poolId);
306
+ },
307
+ async recoverPool(
308
+ access: AccessDecision | undefined,
309
+ poolId: string,
310
+ ): Promise<{ success: true }> {
311
+ const a = requireTenantScope(access, "dashboard:write");
312
+ const recovered = await config.store.recover(a.tenantId, poolId);
313
+ if (!recovered) throw new ConsoleDomainError("pool_not_found", 404, `Pool ${poolId} not found`);
314
+ await config.poolAgentReleaser?.releasePool(poolId, a.tenantId);
315
+ await config.auditSink?.record({
316
+ access: a,
317
+ action: "network_pool.recovered",
318
+ target: poolId,
319
+ });
320
+ await config.snapshotInvalidator?.invalidate();
321
+ return { success: true };
322
+ },
323
+ async healthCheck(
324
+ access: AccessDecision | undefined,
325
+ poolId: string,
326
+ ): Promise<HealthCheckResult> {
327
+ const a = requireTenantScope(access, "dashboard:read");
328
+ const result = await config.store.healthCheck(a.tenantId, poolId);
329
+ if (result.httpStatus !== undefined) {
330
+ await config.poolAgentReleaser?.releasePool(poolId, a.tenantId);
331
+ await config.snapshotInvalidator?.invalidate();
332
+ }
333
+ return result;
334
+ },
335
+ /**
336
+ * Measures download throughput through one saved pool.
337
+ *
338
+ * Read-scoped like the health check: it dials the tenant's own pool and
339
+ * changes no state. A pool that cannot be dialed reports `failed` rather
340
+ * than throwing, so a batch of these returns a per-pool verdict.
341
+ */
342
+ async speedTest(
343
+ access: AccessDecision | undefined,
344
+ poolId: string,
345
+ bytes: number,
346
+ ): Promise<PoolSpeedTestResult> {
347
+ const a = requireTenantScope(access, "dashboard:read");
348
+ if (!config.store.speedTest) {
349
+ throw new ConsoleDomainError("not_supported", 400, "Speed tests are unavailable");
350
+ }
351
+ if (!Number.isInteger(bytes) || bytes < SPEED_TEST_MIN_BYTES || bytes > SPEED_TEST_MAX_BYTES) {
352
+ throw new ConsoleDomainError(
353
+ "invalid_speed_test_size",
354
+ 422,
355
+ `Payload must be between ${SPEED_TEST_MIN_BYTES} and ${SPEED_TEST_MAX_BYTES} bytes`,
356
+ );
357
+ }
358
+ return config.store.speedTest(a.tenantId, poolId, bytes);
359
+ },
360
+ /**
361
+ * Asks one provider whether it will serve traffic from this pool.
362
+ *
363
+ * Read-only by default: an operator testing a proxy wants to know its
364
+ * state, not to change it. `block` is the explicit opt-in that also
365
+ * retires the pool for that provider.
366
+ */
367
+ async providerProbe(
368
+ access: AccessDecision | undefined,
369
+ poolId: string,
370
+ providerId: string,
371
+ ): Promise<PoolProviderProbeResult> {
372
+ const a = requireTenantScope(access, "dashboard:read");
373
+ if (!config.store.providerProbe) {
374
+ throw new ConsoleDomainError("not_supported", 400, "Provider probing is unavailable");
375
+ }
376
+ const result = await config.store.providerProbe(a.tenantId, poolId, providerId);
377
+ if (!config.poolSelector) return result;
378
+ // The verdict *is* the enforcement. Storing it and leaving the pool in
379
+ // rotation is what produced "I tested every pool, some are red, and it
380
+ // still picked a red one": the red was a colour, not a decision.
381
+ //
382
+ // `blocked` retires the egress for this provider, so the next dispatch
383
+ // skips it. Anything else clears a previous block, because the address is
384
+ // serving again — an operator who replaced a proxy must not have to find
385
+ // a separate button to un-retire it.
386
+ if (result.status === "blocked") {
387
+ await config.poolSelector.flagProviderBlocked(
388
+ poolId,
389
+ providerId,
390
+ "Upstream refused this egress address",
391
+ );
392
+ return { ...result, blockedPool: true };
393
+ }
394
+ await config.poolSelector.clearProviderCooldown(poolId, providerId);
395
+ return { ...result, blockedPool: false };
396
+ },
397
+ /**
398
+ * Probes many unsaved pool definitions against one provider.
399
+ *
400
+ * Same shape and bounds as `probeAdHocPoolBatch`: the add form tests a
401
+ * pasted list before saving it, and a per-endpoint verdict is what it
402
+ * prints. A single failing probe becomes that endpoint's result rather
403
+ * than failing the batch.
404
+ */
405
+ async probeAdHocProviderBatch(
406
+ access: AccessDecision | undefined,
407
+ requests: readonly CreateNetworkPoolRequest[],
408
+ providerId: string,
409
+ ): Promise<readonly PoolBatchProviderProbeResult[]> {
410
+ const a = requireTenantScope(access, "dashboard:read");
411
+ if (requests.length === 0) return [];
412
+ if (requests.length > MAX_BATCH_PROBE_TARGETS) {
413
+ throw new ConsoleDomainError(
414
+ "batch_too_large",
415
+ 422,
416
+ `At most ${MAX_BATCH_PROBE_TARGETS} targets per batch`,
417
+ );
418
+ }
419
+ if (!config.store.probeAdHocProvider) {
420
+ throw new ConsoleDomainError("not_supported", 400, "Provider probing is unavailable");
421
+ }
422
+ const probe = config.store.probeAdHocProvider.bind(config.store);
423
+ const results: PoolBatchProviderProbeResult[] = new Array(requests.length);
424
+ let cursor = 0;
425
+ const worker = async () => {
426
+ while (cursor < requests.length) {
427
+ const index = cursor++;
428
+ const request = requests[index]!;
429
+ let result: PoolProviderProbeResult;
430
+ try {
431
+ result = await probe(a.tenantId, request, providerId);
432
+ } catch (error) {
433
+ result = {
434
+ poolId: request.endpoint,
435
+ providerId,
436
+ status: "unreachable",
437
+ reason: error instanceof Error ? error.message : "Probe failed",
438
+ };
439
+ }
440
+ results[index] = { endpoint: request.endpoint, result };
441
+ }
442
+ };
443
+ await Promise.all(
444
+ Array.from({ length: Math.min(PROVIDER_PROBE_BATCH_CONCURRENCY, requests.length) }, () => worker()),
445
+ );
446
+ return results;
447
+ },
448
+ async probeAdHocPool(
449
+ access: AccessDecision | undefined,
450
+ request: CreateNetworkPoolRequest,
451
+ ): Promise<HealthCheckResult> {
452
+ const a = requireTenantScope(access, "dashboard:read");
453
+ if (config.store.probeAdHoc) {
454
+ return config.store.probeAdHoc(a.tenantId, request);
455
+ }
456
+ throw new ConsoleDomainError("not_supported", 400, "Ad-hoc pool probing is unavailable");
457
+ },
458
+ /**
459
+ * Probes many unsaved pool definitions in one call.
460
+ *
461
+ * The dashboard tests a pasted list before saving it; doing that one
462
+ * request per proxy made a 100-line paste take a minute. The dials still
463
+ * happen individually — only the round-trips are batched — and run with
464
+ * bounded concurrency so a large list does not open every socket at once.
465
+ */
466
+ async probeAdHocPoolBatch(
467
+ access: AccessDecision | undefined,
468
+ requests: readonly CreateNetworkPoolRequest[],
469
+ ): Promise<readonly PoolBatchProbeResult[]> {
470
+ const a = requireTenantScope(access, "dashboard:read");
471
+ if (requests.length === 0) return [];
472
+ if (requests.length > MAX_BATCH_PROBE_TARGETS) {
473
+ throw new ConsoleDomainError(
474
+ "batch_too_large",
475
+ 422,
476
+ `At most ${MAX_BATCH_PROBE_TARGETS} targets per batch`,
477
+ );
478
+ }
479
+ if (!config.store.probeAdHoc) {
480
+ throw new ConsoleDomainError("not_supported", 400, "Ad-hoc pool probing is unavailable");
481
+ }
482
+ const probe = config.store.probeAdHoc.bind(config.store);
483
+ const results: PoolBatchProbeResult[] = new Array(requests.length);
484
+ let cursor = 0;
485
+ const worker = async () => {
486
+ while (cursor < requests.length) {
487
+ const index = cursor++;
488
+ const request = requests[index]!;
489
+ // A single failing probe must not fail the batch: the caller wants a
490
+ // per-endpoint verdict, so an error becomes that endpoint's result.
491
+ let result: HealthCheckResult;
492
+ try {
493
+ result = await probe(a.tenantId, request);
494
+ } catch (error) {
495
+ result = {
496
+ poolId: request.endpoint,
497
+ status: "unhealthy",
498
+ errorMessage: error instanceof Error ? error.message : "Probe failed",
499
+ };
500
+ }
501
+ results[index] = { endpoint: request.endpoint, kind: request.kind, result };
502
+ }
503
+ };
504
+ const workers = Array.from(
505
+ { length: Math.min(POOL_BATCH_PROBE_CONCURRENCY, requests.length) },
506
+ () => worker(),
507
+ );
508
+ await Promise.all(workers);
509
+ return results;
510
+ },
511
+ /**
512
+ * Deploys a hosted relay (Cloudflare/Vercel/Deno) and registers its URL as
513
+ * an active HTTP pool. The provider API token is used for the deploy and
514
+ * never persisted; the relay URL is a public host, which is what the pool
515
+ * stores. The deploy fetch is the SSRF-validated one, so the provider API
516
+ * call is checked like every other egress.
517
+ */
518
+ async deployRelay(
519
+ access: AccessDecision | undefined,
520
+ request: RelayDeployRequest,
521
+ ): Promise<NetworkPoolResponse & { relayUrl: string }> {
522
+ const a = requireTenantScope(access, "dashboard:write");
523
+ if (!config.relayFetch)
524
+ throw new ConsoleDomainError("not_supported", 503, "Relay deployment is unavailable");
525
+ if (!isRelayTarget(request.target))
526
+ throw new ConsoleDomainError("invalid_request", 422, `Unsupported relay target: ${String(request.target)}`);
527
+ const result = await deployRelayWorker(config.relayFetch, request);
528
+ const created = await operations.createPool(access, {
529
+ kind: "http",
530
+ endpoint: result.relayUrl,
531
+ label: request.projectName?.trim() || `${result.target} relay`,
532
+ });
533
+ await config.auditSink?.record({
534
+ access: a,
535
+ action: "network_pool.relay_deployed",
536
+ target: created.id,
537
+ detail: { target: result.target, relayUrl: result.relayUrl },
538
+ });
539
+ return { ...created, relayUrl: result.relayUrl };
540
+ },
541
+ };
542
+ return operations;
543
+ }