cartethyia 2.5.3 → 2.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (456) hide show
  1. package/.env.example +13 -4
  2. package/README.md +2 -2
  3. package/dist/dashboard/assets/{BannedPage-hfCzi-Ux.js → BannedPage-CADDq4IU.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-CADDq4IU.js.gz +0 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js +1 -0
  6. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-DEDyKBlC.js → CliToolsPage-DuO1Z7dq.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-DuO1Z7dq.js.gz +0 -0
  9. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js +1 -0
  10. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-bsLuZmYH.js → ConsoleLogPage-BVdpfiz1.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-BVdpfiz1.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-_UWhwziJ.js → CustomizationPage-BR5HHvbi.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-BR5HHvbi.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-DDVIfZGW.js → LoginPage-V5faV9V2.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-V5faV9V2.js.gz +0 -0
  19. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
  20. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js.gz +0 -0
  25. package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js +1 -0
  26. package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js.gz +0 -0
  27. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js +7 -0
  28. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-D6pmQ86L.js → SetupPage-UB690SE2.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-UB690SE2.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-DWvKiuSl.js → SortableList-BLujdX7N.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-BLujdX7N.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-CmkUrgdj.js → StudioPage-D5C2raAc.js} +3 -3
  38. package/dist/dashboard/assets/StudioPage-D5C2raAc.js.gz +0 -0
  39. package/dist/dashboard/assets/UsagePage-8uIcxa1W.js +56 -0
  40. package/dist/dashboard/assets/UsagePage-8uIcxa1W.js.gz +0 -0
  41. package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
  42. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  43. package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
  44. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  45. package/dist/dashboard/assets/{assert-NHwYq95t.js → assert-C9FD_JwJ.js} +1 -1
  46. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  47. package/dist/dashboard/assets/{badge-6uzDDvqs.js → badge-CR72dasT.js} +1 -1
  48. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  49. package/dist/dashboard/assets/{banner-4_WNg2WZ.js → banner-CI9_eSfO.js} +1 -1
  50. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  51. package/dist/dashboard/assets/{card--AtFedAq.js → card-DS7CQEsO.js} +1 -1
  52. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  53. package/dist/dashboard/assets/{cli-tools-B3e7dx3N.js → cli-tools-CNqzrfdb.js} +1 -1
  54. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  55. package/dist/dashboard/assets/{clipboard-button-NreUx6c8.js → clipboard-button-611pSsaX.js} +1 -1
  56. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  57. package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
  58. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  59. package/dist/dashboard/assets/database-Dz9N3zGd.js +1 -0
  60. package/dist/dashboard/assets/database-Dz9N3zGd.js.gz +0 -0
  61. package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
  62. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  63. package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
  64. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-B9Xvtnfj.js +1 -0
  66. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
  68. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  69. package/dist/dashboard/assets/entry-DxBHnauy.js +4 -0
  70. package/dist/dashboard/assets/entry-DxBHnauy.js.gz +0 -0
  71. package/dist/dashboard/assets/{github-badge-D_eM3IgG.js → github-badge-sltk1qUi.js} +1 -1
  72. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  73. package/dist/dashboard/assets/{index-BydyKaDz.js → index-CmxZr14F.js} +2 -2
  74. package/dist/dashboard/assets/index-CmxZr14F.js.gz +0 -0
  75. package/dist/dashboard/assets/{inline-BcNGF8Gr.js → inline-BhY2sSnO.js} +1 -1
  76. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  77. package/dist/dashboard/assets/{input-BG41et9G.js → input-V1umFiBk.js} +1 -1
  78. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  79. package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
  80. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  81. package/dist/dashboard/assets/{list-row-Gh_W4OR_.js → list-row-1zzZYM0B.js} +1 -1
  82. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  83. package/dist/dashboard/assets/{live-BJGYRBnR.js → live-BivGfuUs.js} +1 -1
  84. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  85. package/dist/dashboard/assets/model-bans-CmffjLuG.js +1 -0
  86. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  87. package/dist/dashboard/assets/{mutation-BkoLM5Ba.js → mutation-46P0oWMe.js} +1 -1
  88. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  89. package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
  90. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  91. package/dist/dashboard/assets/{query-keys-D7wX2vM5.js → query-keys-BFhGpwR1.js} +1 -1
  92. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  93. package/dist/dashboard/assets/{routing-CcusqXA7.js → routing-CAb4F37R.js} +1 -1
  94. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  95. package/dist/dashboard/assets/{select-BO6h8yNv.js → select-BYR_5K2c.js} +2 -2
  96. package/dist/dashboard/assets/select-BYR_5K2c.js.gz +0 -0
  97. package/dist/dashboard/assets/{settings-mpmXTgUa.js → settings-DNX9sydi.js} +1 -1
  98. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  99. package/dist/dashboard/assets/{stack-DwLmjUFK.js → stack-BHTfXdz_.js} +1 -1
  100. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  101. package/dist/dashboard/assets/{state-DYdQmdAl.js → state-mPDRlQkl.js} +1 -1
  102. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  103. package/dist/dashboard/assets/switch-DOmJxg7H.js +1 -0
  104. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  105. package/dist/dashboard/assets/terminal-0_IjPWTk.js +1 -0
  106. package/dist/dashboard/assets/terminal-0_IjPWTk.js.gz +0 -0
  107. package/dist/dashboard/assets/tokens-COzario9.js +1 -0
  108. package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
  109. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
  110. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
  111. package/dist/dashboard/assets/{useMutation-w4u4CVue.js → useMutation-Dem0lXQj.js} +1 -1
  112. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  113. package/dist/dashboard/assets/zap-C4EeVW4q.js +1 -0
  114. package/dist/dashboard/assets/zap-C4EeVW4q.js.gz +0 -0
  115. package/dist/dashboard/donation/qris-risun-marke.png +0 -0
  116. package/dist/dashboard/index.html +1 -1
  117. package/dist/dashboard/index.html.gz +0 -0
  118. package/dist/dashboard/precompressed-assets.json +57 -57
  119. package/migrations/README.md +52 -52
  120. package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
  121. package/package.json +96 -95
  122. package/scripts/cli/admin.ts +125 -0
  123. package/scripts/cli/autostart.ts +237 -0
  124. package/scripts/cli/{ui/background.ts → background.ts} +1 -1
  125. package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -248
  126. package/scripts/cli/doctor.ts +37 -0
  127. package/scripts/cli/{support/env.ts → env.ts} +99 -99
  128. package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
  129. package/scripts/cli/instance.ts +224 -0
  130. package/scripts/cli/main.ts +88 -18
  131. package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
  132. package/scripts/cli/port.ts +100 -0
  133. package/scripts/cli/{support/services.ts → services.ts} +88 -88
  134. package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
  135. package/scripts/cli/{command/start.ts → start.ts} +10 -5
  136. package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
  137. package/scripts/cli/tray/index.ts +62 -12
  138. package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
  139. package/scripts/cli/update.ts +40 -0
  140. package/scripts/cli/version.ts +67 -0
  141. package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
  142. package/src/boot/app.ts +11 -7
  143. package/src/boot/dependencies.ts +50 -19
  144. package/src/boot/timeout.ts +2 -13
  145. package/src/config/cooldowns.ts +186 -138
  146. package/src/config/network.ts +8 -14
  147. package/src/config/providers.ts +7 -10
  148. package/src/config/runtime.ts +76 -76
  149. package/src/config/security.ts +15 -0
  150. package/src/config/spec.ts +52 -7
  151. package/src/config/telemetry.ts +26 -0
  152. package/src/config/timeouts.ts +0 -6
  153. package/src/console/admin/model-abuse/contracts.ts +28 -6
  154. package/src/console/admin/model-abuse/routes.ts +56 -3
  155. package/src/console/api-keys/bucket-spend.ts +15 -4
  156. package/src/console/api-keys/contracts.ts +410 -414
  157. package/src/console/api-keys/routes.ts +881 -880
  158. package/src/console/auth/credentials.ts +3 -3
  159. package/src/console/auth/setup-service.ts +2 -2
  160. package/src/console/backup/contracts.ts +15 -2
  161. package/src/console/backup/export-endpoint.ts +6 -2
  162. package/src/console/backup/nine-router.ts +2 -5
  163. package/src/console/backup/service.ts +4 -0
  164. package/src/console/backup/store.ts +192 -29
  165. package/src/console/backup/validate.ts +3 -45
  166. package/src/console/cli-tools/fs-ops.ts +487 -487
  167. package/src/console/console-router-guards.ts +1 -1
  168. package/src/console/console-router.ts +8 -0
  169. package/src/console/data-management/contracts.ts +100 -0
  170. package/src/console/data-management/operations.ts +234 -0
  171. package/src/console/data-management/routes.ts +103 -0
  172. package/src/console/domain-registration.ts +45 -1
  173. package/src/console/observability/contracts.ts +524 -485
  174. package/src/console/observability/live.ts +22 -0
  175. package/src/console/observability/store.ts +1138 -1018
  176. package/src/console/observability/usage-dimensions.ts +4 -4
  177. package/src/console/providers/catalog/catalog-projections.ts +6 -7
  178. package/src/console/providers/catalog/contracts.ts +6 -0
  179. package/src/console/providers/catalog/model-operations.ts +5 -2
  180. package/src/console/providers/catalog/provider-operations.ts +24 -4
  181. package/src/console/providers/catalog/public-model-store.ts +8 -11
  182. package/src/console/providers/catalog/routes.ts +7 -0
  183. package/src/console/providers/catalog/store.ts +1553 -1532
  184. package/src/console/providers/oauth/routes.ts +8 -12
  185. package/src/console/quota/account-quota-shared.ts +52 -39
  186. package/src/console/quota/account-quota-tenant.ts +6 -6
  187. package/src/console/quota/quota-view.ts +1 -1
  188. package/src/console/quota/refresh.ts +441 -429
  189. package/src/console/remote-support/contracts.ts +61 -0
  190. package/src/console/remote-support/routes.ts +65 -0
  191. package/src/console/remote-support/service.ts +207 -0
  192. package/src/console/response-compression.ts +0 -3
  193. package/src/console/routing/pools/import-parse.ts +206 -0
  194. package/src/console/routing/pools/operations.ts +543 -501
  195. package/src/console/routing/pools/probe-result.ts +3 -2
  196. package/src/console/routing/pools/probe-verdict.ts +132 -132
  197. package/src/console/routing/pools/routes.ts +181 -164
  198. package/src/console/routing/pools/store.ts +8 -8
  199. package/src/console/settings/contracts.ts +95 -2
  200. package/src/console/settings/store.ts +81 -3
  201. package/src/console/shared/cursor.ts +8 -8
  202. package/src/console/shared/errors.ts +1 -3
  203. package/src/console/updates/routes.ts +16 -0
  204. package/src/main.ts +10 -0
  205. package/src/network/http2-bidi.ts +2 -4
  206. package/src/network/pool/agent.ts +478 -478
  207. package/src/network/pool/selector.ts +5 -4
  208. package/src/network/retry.ts +1 -2
  209. package/src/network/ssrf.ts +5 -4
  210. package/src/observability/event-compaction.ts +317 -0
  211. package/src/observability/event-day-index.ts +440 -0
  212. package/src/observability/event-log.ts +155 -6
  213. package/src/observability/logger.ts +6 -6
  214. package/src/observability/payload-capture.ts +158 -33
  215. package/src/observability/payload-store.ts +82 -61
  216. package/src/observability/redaction.ts +5 -6
  217. package/src/observability/request-notify.ts +43 -0
  218. package/src/observability/telemetry-status.ts +8 -9
  219. package/src/observability/token-speed.ts +5 -4
  220. package/src/persistence/db-mode.ts +7 -7
  221. package/src/persistence/health-event-flush.ts +36 -0
  222. package/src/persistence/readiness.ts +1 -1
  223. package/src/persistence/schema.ts +75 -29
  224. package/src/persistence/shared-cache.ts +1 -2
  225. package/src/persistence/stores/api-key-store.ts +18 -6
  226. package/src/persistence/stores/share-store.ts +4 -3
  227. package/src/persistence/stores/telemetry-store.ts +33 -11
  228. package/src/protocol/canonical-model.ts +11 -3
  229. package/src/protocol/codecs.ts +139 -6
  230. package/src/protocol/gateway-error.ts +6 -4
  231. package/src/protocol/image-clamp.ts +19 -1
  232. package/src/protocol/registry.ts +8 -0
  233. package/src/protocol/request/chat.ts +21 -1
  234. package/src/protocol/request/codex.ts +10 -1
  235. package/src/protocol/request/gemini.ts +9 -2
  236. package/src/protocol/request/messages.ts +18 -7
  237. package/src/protocol/request/responses.ts +9 -1
  238. package/src/protocol/response/chat.ts +34 -4
  239. package/src/protocol/response/codex.ts +7 -4
  240. package/src/protocol/response/dsml.ts +140 -28
  241. package/src/protocol/response/responses.ts +11 -5
  242. package/src/protocol/stream-error-frames.ts +4 -5
  243. package/src/providers/adapters/compatible-adapter.ts +3 -0
  244. package/src/providers/authentication/jwt-validator.ts +9 -10
  245. package/src/providers/authentication/oauth-refresh-service.ts +9 -11
  246. package/src/providers/bundled-discovery-providers.ts +122 -119
  247. package/src/providers/configured-openai-providers.ts +4 -4
  248. package/src/providers/default-registry.ts +5 -4
  249. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  250. package/src/providers/discovery/catalog/catalog-format.ts +3 -6
  251. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  252. package/src/providers/discovery/openai-model-discovery.ts +5 -0
  253. package/src/providers/discovery/openrouter-free-roster.ts +15 -17
  254. package/src/providers/discovery/probe-phases.ts +4 -4
  255. package/src/providers/discovery/probe-wire.ts +6 -13
  256. package/src/providers/discovery/probing-service.ts +1117 -1045
  257. package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
  258. package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
  259. package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
  260. package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
  261. package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
  262. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
  263. package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
  264. package/src/providers/integrations/buddy/buddy-media.ts +2 -2
  265. package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
  266. package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
  267. package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
  268. package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
  269. package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
  270. package/src/providers/integrations/claude/claude.ts +608 -608
  271. package/src/providers/integrations/cline/cline-quota.ts +27 -20
  272. package/src/providers/integrations/cline/cline.ts +537 -539
  273. package/src/providers/integrations/codex/codex-headers.ts +25 -39
  274. package/src/providers/integrations/codex/codex-oauth.ts +1 -1
  275. package/src/providers/integrations/codex/codex.ts +2 -29
  276. package/src/providers/integrations/commandcode/commandcode.ts +674 -675
  277. package/src/providers/integrations/commandcode/discovery.ts +117 -116
  278. package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
  279. package/src/providers/integrations/cursor/cursor-quota.ts +309 -0
  280. package/src/providers/integrations/cursor/cursor-request.ts +616 -471
  281. package/src/providers/integrations/cursor/cursor-session-manager.ts +319 -281
  282. package/src/providers/integrations/cursor/cursor.ts +1333 -960
  283. package/src/providers/integrations/cursor/protocol/messages.ts +25 -0
  284. package/src/providers/integrations/factory/factory-models.ts +3 -5
  285. package/src/providers/integrations/factory/factory.ts +8 -1
  286. package/src/providers/integrations/gemini.ts +6 -1
  287. package/src/providers/integrations/grok/grok-install-id.ts +2 -3
  288. package/src/providers/integrations/grok/grok-quota.ts +279 -177
  289. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -0
  290. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -0
  291. package/src/providers/integrations/grok/grok.ts +6 -7
  292. package/src/providers/integrations/hermes.ts +14 -18
  293. package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
  294. package/src/providers/integrations/kiro/kiro.ts +1 -1
  295. package/src/providers/integrations/mistral.ts +6 -6
  296. package/src/providers/integrations/opencode/fingerprint.ts +3 -3
  297. package/src/providers/integrations/opencode/provider.ts +19 -20
  298. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
  299. package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
  300. package/src/providers/integrations/qoder/qoder-quota.ts +192 -0
  301. package/src/providers/integrations/qoder/qoder.ts +884 -865
  302. package/src/providers/integrations/shared/connect.ts +6 -6
  303. package/src/providers/integrations/trae/trae-catalog.ts +1 -2
  304. package/src/providers/integrations/trae/trae-quota.ts +384 -0
  305. package/src/providers/integrations/trae/trae.ts +2 -2
  306. package/src/providers/integrations/zcode/oauth.ts +6 -1
  307. package/src/providers/operations/account-health-service.ts +75 -27
  308. package/src/providers/operations/account-reset-service.ts +1 -1
  309. package/src/providers/operations/client-versions.ts +6 -6
  310. package/src/providers/operations/custom-cli-headers.ts +3 -4
  311. package/src/providers/operations/provider-catalog-seeder.ts +95 -95
  312. package/src/providers/operations/provider-catalog-service.ts +3 -4
  313. package/src/providers/provider-contracts.ts +7 -0
  314. package/src/providers/provider-metadata.ts +10 -8
  315. package/src/providers/provider-registry.ts +6 -7
  316. package/src/providers/provider-usage.ts +1 -1
  317. package/src/providers/quota/quota-support.ts +8 -0
  318. package/src/security/abuse.ts +15 -10
  319. package/src/security/access-control.ts +144 -146
  320. package/src/security/admission/contracts.ts +11 -0
  321. package/src/security/admission/in-memory-store.ts +4 -3
  322. package/src/security/admission/lease-sweep.ts +6 -6
  323. package/src/security/admission/reasons.ts +5 -1
  324. package/src/security/admission/redis-store.ts +211 -210
  325. package/src/security/admission/service.ts +4 -0
  326. package/src/security/api-key-auth.ts +27 -17
  327. package/src/security/ip-boundary.ts +155 -15
  328. package/src/security/model-abuse.ts +366 -94
  329. package/src/security/outbound-headers.ts +3 -4
  330. package/src/shared/sleep.ts +4 -0
  331. package/src/transport/dispatch/attempt-finalize.ts +47 -5
  332. package/src/transport/dispatch/attempt-loop.ts +531 -530
  333. package/src/transport/dispatch/generation.ts +6 -0
  334. package/src/transport/dispatch/proxy-request.ts +24 -1
  335. package/src/transport/dispatch/ratelimit-capture.ts +37 -0
  336. package/src/transport/dispatch/stream-bridge.ts +1 -3
  337. package/src/transport/dispatch/streaming/finalize.ts +1 -1
  338. package/src/transport/dispatch/streaming/stream.ts +19 -6
  339. package/src/transport/dispatch/upstream.ts +9 -0
  340. package/src/transport/failure-policy.ts +162 -8
  341. package/src/transport/guards/auth-guard.ts +7 -4
  342. package/src/transport/guards/concurrency-guard.ts +0 -5
  343. package/src/transport/guards/error-lifecycle.ts +13 -6
  344. package/src/transport/guards/pipeline.ts +15 -3
  345. package/src/transport/guards/request-context.ts +8 -2
  346. package/src/transport/request/inflight.ts +5 -6
  347. package/src/transport/request/preparer.ts +9 -8
  348. package/src/transport/request/state.ts +21 -8
  349. package/src/transport/request/tenant-preferences.ts +60 -0
  350. package/src/transport/routing/admission.ts +4 -0
  351. package/src/transport/routing/route-catalog.ts +65 -10
  352. package/src/transport/routing/route-model.ts +46 -14
  353. package/src/transport/routing/router.ts +21 -14
  354. package/src/transport/surface/completion.ts +2 -3
  355. package/src/transport/surface/dialects.ts +5 -6
  356. package/src/transport/surface/responses/encode.ts +38 -0
  357. package/src/transport/surface/responses/parse.ts +4 -0
  358. package/src/transport/translation/capabilities.ts +2 -3
  359. package/src/transport/translation/quirks.ts +3 -3
  360. package/src/workers/buddy-growth-tasks.ts +5 -10
  361. package/src/workers/daily-checkin.ts +17 -23
  362. package/src/workers/quota-refresh-sweep.ts +26 -26
  363. package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
  364. package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js +0 -1
  365. package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
  366. package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
  367. package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js +0 -1
  368. package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
  369. package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
  370. package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
  371. package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js +0 -1
  372. package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
  373. package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
  374. package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js +0 -1
  375. package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
  376. package/dist/dashboard/assets/OverviewPage-B53YYMng.js +0 -1
  377. package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
  378. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +0 -3
  379. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
  380. package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js +0 -1
  381. package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
  382. package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js +0 -7
  383. package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
  384. package/dist/dashboard/assets/QuotaPage-BpOQCnun.js +0 -1
  385. package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
  386. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +0 -1
  387. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
  388. package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
  389. package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
  390. package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
  391. package/dist/dashboard/assets/UsagePage-B47uOydr.js +0 -56
  392. package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
  393. package/dist/dashboard/assets/api-keys-uNywYlfv.js +0 -1
  394. package/dist/dashboard/assets/api-keys-uNywYlfv.js.gz +0 -0
  395. package/dist/dashboard/assets/app-DxivpfYG.css +0 -2
  396. package/dist/dashboard/assets/app-DxivpfYG.css.gz +0 -0
  397. package/dist/dashboard/assets/assert-NHwYq95t.js.gz +0 -0
  398. package/dist/dashboard/assets/badge-6uzDDvqs.js.gz +0 -0
  399. package/dist/dashboard/assets/banner-4_WNg2WZ.js.gz +0 -0
  400. package/dist/dashboard/assets/card--AtFedAq.js.gz +0 -0
  401. package/dist/dashboard/assets/cli-tools-B3e7dx3N.js.gz +0 -0
  402. package/dist/dashboard/assets/clipboard-button-NreUx6c8.js.gz +0 -0
  403. package/dist/dashboard/assets/dialog-BtpsJU98.js +0 -1
  404. package/dist/dashboard/assets/dialog-BtpsJU98.js.gz +0 -0
  405. package/dist/dashboard/assets/download-A0SW9OCW.js +0 -1
  406. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  407. package/dist/dashboard/assets/drawer-WwwtzihF.js +0 -1
  408. package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
  409. package/dist/dashboard/assets/entry-CkryrIea.js +0 -4
  410. package/dist/dashboard/assets/entry-CkryrIea.js.gz +0 -0
  411. package/dist/dashboard/assets/entry-TJIye_jT.js +0 -1
  412. package/dist/dashboard/assets/entry-TJIye_jT.js.gz +0 -0
  413. package/dist/dashboard/assets/entry-jy9_4-Ke.js +0 -1
  414. package/dist/dashboard/assets/entry-jy9_4-Ke.js.gz +0 -0
  415. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  416. package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
  417. package/dist/dashboard/assets/index-BydyKaDz.js.gz +0 -0
  418. package/dist/dashboard/assets/inline-BcNGF8Gr.js.gz +0 -0
  419. package/dist/dashboard/assets/input-BG41et9G.js.gz +0 -0
  420. package/dist/dashboard/assets/layout-C8mer_Ak.js +0 -1
  421. package/dist/dashboard/assets/layout-C8mer_Ak.js.gz +0 -0
  422. package/dist/dashboard/assets/list-row-Gh_W4OR_.js.gz +0 -0
  423. package/dist/dashboard/assets/live-BJGYRBnR.js.gz +0 -0
  424. package/dist/dashboard/assets/model-bans-DeVLDA7I.js +0 -1
  425. package/dist/dashboard/assets/model-bans-DeVLDA7I.js.gz +0 -0
  426. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  427. package/dist/dashboard/assets/numbers-Bs4LOOuf.js +0 -2
  428. package/dist/dashboard/assets/numbers-Bs4LOOuf.js.gz +0 -0
  429. package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
  430. package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
  431. package/dist/dashboard/assets/select-BO6h8yNv.js.gz +0 -0
  432. package/dist/dashboard/assets/settings-mpmXTgUa.js.gz +0 -0
  433. package/dist/dashboard/assets/sparkles-BRaRUxAU.js +0 -1
  434. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  435. package/dist/dashboard/assets/stack-DwLmjUFK.js.gz +0 -0
  436. package/dist/dashboard/assets/state-DYdQmdAl.js.gz +0 -0
  437. package/dist/dashboard/assets/switch-CidSfbG7.js +0 -1
  438. package/dist/dashboard/assets/switch-CidSfbG7.js.gz +0 -0
  439. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +0 -1
  440. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  441. package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
  442. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  443. package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
  444. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  445. package/dist/dashboard/assets/useMutation-w4u4CVue.js.gz +0 -0
  446. package/scripts/cli/command/admin.ts +0 -65
  447. package/scripts/cli/command/doctor.ts +0 -257
  448. package/scripts/cli/command/update.ts +0 -23
  449. package/scripts/cli/gateway/drain-token.ts +0 -78
  450. package/scripts/cli/gateway/instance-lock.ts +0 -105
  451. package/scripts/cli/gateway/stop.ts +0 -51
  452. package/scripts/cli/support/bun.ts +0 -27
  453. package/scripts/cli/support/version.ts +0 -37
  454. /package/dist/dashboard/assets/{app-CafWtVXA.js → app-BIt446yE.js} +0 -0
  455. /package/dist/dashboard/assets/{app-CafWtVXA.js.gz → app-BIt446yE.js.gz} +0 -0
  456. /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
@@ -1,501 +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 { SPEED_TEST_MAX_BYTES, SPEED_TEST_MIN_BYTES } from "./speed-test-sizes";
13
- import {
14
- MAX_BATCH_PROBE_TARGETS,
15
- PROVIDER_PROBE_BATCH_CONCURRENCY,
16
- POOL_BATCH_PROBE_CONCURRENCY,
17
- POOL_ROUTING_STRATEGIES,
18
- normalizePoolConfig,
19
- sanitizePoolResponse,
20
- validateEndpoint,
21
- validatePoolLimits,
22
- validateTransportConfig,
23
- type CreateNetworkPoolRequest,
24
- type HealthCheckResult,
25
- type PoolBatchProviderProbeResult,
26
- type PoolProviderProbeResult,
27
- type NetworkPoolConfig,
28
- type NetworkPoolRecord,
29
- type NetworkPoolResponse,
30
- type PoolBatchProbeResult,
31
- type PoolSpeedTestResult,
32
- type PoolStrategySetting,
33
- } from "./contracts";
34
-
35
- /**
36
- * Whether a provider entry is a retired egress rather than a refilling
37
- * allowance. Read from the stored flag, never from the duration.
38
- */
39
- function isBlockedEntry(entry: { readonly blocked?: boolean }): boolean {
40
- return entry.blocked === true;
41
- }
42
-
43
- export function createNetworkPoolOperations(config: NetworkPoolConfig) {
44
- const operations = {
45
- async listPools(access: AccessDecision | undefined): Promise<NetworkPoolResponse[]> {
46
- const a = requireTenantScope(access, "dashboard:read");
47
- const rawPools = await config.store.list(a.tenantId);
48
- return Promise.all(
49
- rawPools.map(async (r) => {
50
- const res = sanitizePoolResponse(r);
51
- const cooldowns = await config.poolSelector?.getPoolCooldowns(r.id);
52
- const inflight = config.poolSelector
53
- ? await config.poolSelector.getInflightAuthoritative(r.id)
54
- : res.inflight;
55
- return {
56
- ...res,
57
- inflight,
58
- available: Math.max(0, res.maxInflight - inflight),
59
- utilization: res.maxInflight > 0 ? Math.min(1, inflight / res.maxInflight) : 1,
60
- ...(cooldowns && cooldowns.length > 0
61
- ? {
62
- providerCooldowns: cooldowns.map((c) => ({
63
- providerId: c.providerId,
64
- until: new Date(c.until).toISOString(),
65
- reason: c.reason,
66
- blocked: isBlockedEntry(c),
67
- })),
68
- }
69
- : {}),
70
- };
71
- }),
72
- );
73
- },
74
- async getPoolDetail(
75
- access: AccessDecision | undefined,
76
- poolId: string,
77
- ): Promise<NetworkPoolResponse> {
78
- const a = requireTenantScope(access, "dashboard:read");
79
- const rec = await config.store.get(a.tenantId, poolId);
80
- if (!rec) throw new ConsoleDomainError("pool_not_found", 404, `Pool ${poolId} not found`);
81
- const res = sanitizePoolResponse(rec);
82
- const cooldowns = await config.poolSelector?.getPoolCooldowns(poolId);
83
- const inflight = config.poolSelector
84
- ? await config.poolSelector.getInflightAuthoritative(poolId)
85
- : res.inflight;
86
- return {
87
- ...res,
88
- inflight,
89
- available: Math.max(0, res.maxInflight - inflight),
90
- utilization: res.maxInflight > 0 ? Math.min(1, inflight / res.maxInflight) : 1,
91
- ...(cooldowns && cooldowns.length > 0
92
- ? {
93
- providerCooldowns: cooldowns.map((c) => ({
94
- providerId: c.providerId,
95
- until: new Date(c.until).toISOString(),
96
- reason: c.reason,
97
- blocked: isBlockedEntry(c),
98
- })),
99
- }
100
- : {}),
101
- };
102
- },
103
- async clearPoolCooldown(
104
- access: AccessDecision | undefined,
105
- poolId: string,
106
- providerId?: string,
107
- ): Promise<{ success: boolean }> {
108
- requireTenantScope(access, "dashboard:write");
109
- if (providerId) {
110
- await config.poolSelector?.clearProviderCooldown(poolId, providerId);
111
- } else {
112
- const active = await config.poolSelector?.getPoolCooldowns(poolId);
113
- if (active) {
114
- for (const c of active) {
115
- await config.poolSelector?.clearProviderCooldown(poolId, c.providerId);
116
- }
117
- }
118
- }
119
- return { success: true };
120
- },
121
- async getStrategy(access: AccessDecision | undefined): Promise<PoolStrategySetting> {
122
- const a = requireTenantScope(access, "dashboard:read");
123
- return config.store.getStrategy(a.tenantId);
124
- },
125
- async updateStrategy(
126
- access: AccessDecision | undefined,
127
- request: Partial<PoolStrategySetting>,
128
- ): Promise<PoolStrategySetting> {
129
- const a = requireTenantScope(access, "dashboard:write");
130
- const current = await config.store.getStrategy(a.tenantId);
131
- const strategy = request.strategy ?? current.strategy;
132
- // Runtime check despite the type: the HTTP body is cast at the route
133
- // boundary, so a hand-crafted request can carry any string here.
134
- if (!POOL_ROUTING_STRATEGIES.includes(strategy)) {
135
- throw new ConsoleDomainError("invalid_pool", 400, "Pool strategy is invalid");
136
- }
137
- const rotateCount = request.rotateCount ?? current.rotateCount;
138
- if (!Number.isInteger(rotateCount) || rotateCount < 1 || rotateCount > 1000) {
139
- throw new ConsoleDomainError(
140
- "invalid_pool_limits",
141
- 400,
142
- "rotateCount must be an integer between 1 and 1000",
143
- );
144
- }
145
- const saved = await config.store.setStrategy(a.tenantId, { strategy, rotateCount });
146
- await config.auditSink?.record({
147
- access: a,
148
- action: "network_pool.strategy_updated",
149
- target: a.tenantId,
150
- detail: { strategy: saved.strategy, rotateCount: saved.rotateCount },
151
- });
152
- await config.snapshotInvalidator?.invalidate();
153
- return saved;
154
- },
155
- async createPool(
156
- access: AccessDecision | undefined,
157
- request: CreateNetworkPoolRequest,
158
- ): Promise<NetworkPoolResponse> {
159
- const a = requireTenantScope(access, "dashboard:write");
160
- const normalizedConfig = normalizePoolConfig(request.config);
161
- const endpoint = request.endpoint;
162
- validateEndpoint(request.kind, endpoint, config.ssrfPolicy);
163
- validateTransportConfig(request.kind, normalizedConfig);
164
- validatePoolLimits(request.maxInflight, request.weight);
165
- const record: NetworkPoolRecord = {
166
- id: crypto.randomUUID(),
167
- kind: request.kind,
168
- endpoint,
169
- maxInflight: request.maxInflight ?? 10,
170
- weight: request.weight ?? 100,
171
- status: "active",
172
- inflight: 0,
173
- consecutiveFailures: 0,
174
- tenantId: a.tenantId,
175
- createdAt: new Date().toISOString(),
176
- ...(request.label === undefined ? {} : { label: request.label }),
177
- ...(Object.keys(normalizedConfig).length > 0 ? { config: normalizedConfig } : {}),
178
- ...(request.credential === undefined ? {} : { credential: request.credential }),
179
- ...(request.credential !== undefined ? { hasCredential: true } : {}),
180
- // Absent or `null` both mean unmetered; a positive number is the
181
- // allowance. The body schema already rejects zero and negatives.
182
- ...(typeof request.quotaBytes === "number" ? { quotaBytes: request.quotaBytes } : {}),
183
- ...(request.family === undefined ? {} : { family: request.family }),
184
- };
185
- await config.store.create(record);
186
- await config.auditSink?.record({
187
- access: a,
188
- action: "network_pool.created",
189
- target: record.id,
190
- detail: { kind: record.kind, hasCredential: record.hasCredential === true },
191
- });
192
- await config.snapshotInvalidator?.invalidate();
193
- return sanitizePoolResponse(record);
194
- },
195
- async updatePool(
196
- access: AccessDecision | undefined,
197
- poolId: string,
198
- request: Partial<CreateNetworkPoolRequest>,
199
- ): Promise<NetworkPoolResponse> {
200
- const a = requireTenantScope(access, "dashboard:write");
201
- validatePoolLimits(request.maxInflight, request.weight);
202
- if (
203
- request.endpoint !== undefined ||
204
- request.kind !== undefined ||
205
- request.config !== undefined
206
- ) {
207
- const existing = await config.store.get(a.tenantId, poolId);
208
- if (!existing) throw new ConsoleDomainError("pool_not_found", 404, `Pool ${poolId} not found`);
209
- const effectiveKind = request.kind ?? existing.kind;
210
- const effectiveEndpoint = request.endpoint ?? existing.endpoint;
211
- const mergedConfig = { ...(existing.config ?? {}), ...(request.config ?? {}) };
212
- const normalizedConfig = normalizePoolConfig(mergedConfig);
213
- validateEndpoint(effectiveKind, effectiveEndpoint, config.ssrfPolicy);
214
- validateTransportConfig(effectiveKind, normalizedConfig);
215
- request = { ...request, endpoint: effectiveEndpoint, config: normalizedConfig };
216
- }
217
- const updated = await config.store.update(
218
- a.tenantId,
219
- poolId,
220
- request as Partial<NetworkPoolRecord>,
221
- );
222
- if (!updated) throw new ConsoleDomainError("pool_not_found", 404, `Pool ${poolId} not found`);
223
- // Any mutation can invalidate the cached dial agent (endpoint, credential,
224
- // kind, config, or a disable via status) — release unconditionally so the
225
- // next dispatch rebuilds against the fresh row rather than reusing a
226
- // stale agent/delegate bound to the pre-update configuration.
227
- await config.poolAgentReleaser?.releasePool(poolId, a.tenantId);
228
- await config.auditSink?.record({
229
- access: a,
230
- action: "network_pool.updated",
231
- target: poolId,
232
- detail: { fields: Object.keys(request) },
233
- });
234
- await config.snapshotInvalidator?.invalidate();
235
- return sanitizePoolResponse(updated);
236
- },
237
- async deletePool(
238
- access: AccessDecision | undefined,
239
- poolId: string,
240
- ): Promise<{ success: boolean }> {
241
- const a = requireTenantScope(access, "dashboard:write");
242
- const ok = await config.store.delete(a.tenantId, poolId);
243
- if (!ok) throw new ConsoleDomainError("pool_not_found", 404, `Pool ${poolId} not found`);
244
- await config.poolAgentReleaser?.releasePool(poolId, a.tenantId);
245
- // Drop the pool's cooldown markers and their index set with it. Without
246
- // this the Redis keys outlive the row, so a pool later reusing the id
247
- // inherits cooldowns it never earned — and the index set, which has no
248
- // TTL of its own, would keep listing them.
249
- await config.poolSelector?.clearPoolCooldowns(poolId);
250
- await config.auditSink?.record({
251
- access: a,
252
- action: "network_pool.deleted",
253
- target: poolId,
254
- });
255
- await config.snapshotInvalidator?.invalidate();
256
- return { success: true };
257
- },
258
- async listPoolHealthEvents(
259
- access: AccessDecision | undefined,
260
- poolId: string,
261
- ): Promise<readonly PoolHealthEvent[]> {
262
- const a = requireTenantScope(access, "dashboard:read");
263
- return config.store.listHealthEvents(a.tenantId, poolId);
264
- },
265
- async recoverPool(
266
- access: AccessDecision | undefined,
267
- poolId: string,
268
- ): Promise<{ success: true }> {
269
- const a = requireTenantScope(access, "dashboard:write");
270
- const recovered = await config.store.recover(a.tenantId, poolId);
271
- if (!recovered) throw new ConsoleDomainError("pool_not_found", 404, `Pool ${poolId} not found`);
272
- await config.poolAgentReleaser?.releasePool(poolId, a.tenantId);
273
- await config.auditSink?.record({
274
- access: a,
275
- action: "network_pool.recovered",
276
- target: poolId,
277
- });
278
- await config.snapshotInvalidator?.invalidate();
279
- return { success: true };
280
- },
281
- async healthCheck(
282
- access: AccessDecision | undefined,
283
- poolId: string,
284
- ): Promise<HealthCheckResult> {
285
- const a = requireTenantScope(access, "dashboard:read");
286
- const result = await config.store.healthCheck(a.tenantId, poolId);
287
- if (result.httpStatus !== undefined) {
288
- await config.poolAgentReleaser?.releasePool(poolId, a.tenantId);
289
- await config.snapshotInvalidator?.invalidate();
290
- }
291
- return result;
292
- },
293
- /**
294
- * Measures download throughput through one saved pool.
295
- *
296
- * Read-scoped like the health check: it dials the tenant's own pool and
297
- * changes no state. A pool that cannot be dialed reports `failed` rather
298
- * than throwing, so a batch of these returns a per-pool verdict.
299
- */
300
- async speedTest(
301
- access: AccessDecision | undefined,
302
- poolId: string,
303
- bytes: number,
304
- ): Promise<PoolSpeedTestResult> {
305
- const a = requireTenantScope(access, "dashboard:read");
306
- if (!config.store.speedTest) {
307
- throw new ConsoleDomainError("not_supported", 400, "Speed tests are unavailable");
308
- }
309
- if (!Number.isInteger(bytes) || bytes < SPEED_TEST_MIN_BYTES || bytes > SPEED_TEST_MAX_BYTES) {
310
- throw new ConsoleDomainError(
311
- "invalid_speed_test_size",
312
- 422,
313
- `Payload must be between ${SPEED_TEST_MIN_BYTES} and ${SPEED_TEST_MAX_BYTES} bytes`,
314
- );
315
- }
316
- return config.store.speedTest(a.tenantId, poolId, bytes);
317
- },
318
- /**
319
- * Asks one provider whether it will serve traffic from this pool.
320
- *
321
- * Read-only by default: an operator testing a proxy wants to know its
322
- * state, not to change it. `block` is the explicit opt-in that also
323
- * retires the pool for that provider.
324
- */
325
- async providerProbe(
326
- access: AccessDecision | undefined,
327
- poolId: string,
328
- providerId: string,
329
- ): Promise<PoolProviderProbeResult> {
330
- const a = requireTenantScope(access, "dashboard:read");
331
- if (!config.store.providerProbe) {
332
- throw new ConsoleDomainError("not_supported", 400, "Provider probing is unavailable");
333
- }
334
- const result = await config.store.providerProbe(a.tenantId, poolId, providerId);
335
- if (!config.poolSelector) return result;
336
- // The verdict *is* the enforcement. Storing it and leaving the pool in
337
- // rotation is what produced "I tested every pool, some are red, and it
338
- // still picked a red one": the red was a colour, not a decision.
339
- //
340
- // `blocked` retires the egress for this provider, so the next dispatch
341
- // skips it. Anything else clears a previous block, because the address is
342
- // serving again — an operator who replaced a proxy must not have to find
343
- // a separate button to un-retire it.
344
- if (result.status === "blocked") {
345
- await config.poolSelector.flagProviderBlocked(
346
- poolId,
347
- providerId,
348
- "Upstream refused this egress address",
349
- );
350
- return { ...result, blockedPool: true };
351
- }
352
- await config.poolSelector.clearProviderCooldown(poolId, providerId);
353
- return { ...result, blockedPool: false };
354
- },
355
- /**
356
- * Probes many unsaved pool definitions against one provider.
357
- *
358
- * Same shape and bounds as `probeAdHocPoolBatch`: the add form tests a
359
- * pasted list before saving it, and a per-endpoint verdict is what it
360
- * prints. A single failing probe becomes that endpoint's result rather
361
- * than failing the batch.
362
- */
363
- async probeAdHocProviderBatch(
364
- access: AccessDecision | undefined,
365
- requests: readonly CreateNetworkPoolRequest[],
366
- providerId: string,
367
- ): Promise<readonly PoolBatchProviderProbeResult[]> {
368
- const a = requireTenantScope(access, "dashboard:read");
369
- if (requests.length === 0) return [];
370
- if (requests.length > MAX_BATCH_PROBE_TARGETS) {
371
- throw new ConsoleDomainError(
372
- "batch_too_large",
373
- 422,
374
- `At most ${MAX_BATCH_PROBE_TARGETS} targets per batch`,
375
- );
376
- }
377
- if (!config.store.probeAdHocProvider) {
378
- throw new ConsoleDomainError("not_supported", 400, "Provider probing is unavailable");
379
- }
380
- const probe = config.store.probeAdHocProvider.bind(config.store);
381
- const results: PoolBatchProviderProbeResult[] = new Array(requests.length);
382
- let cursor = 0;
383
- const worker = async () => {
384
- while (cursor < requests.length) {
385
- const index = cursor++;
386
- const request = requests[index]!;
387
- let result: PoolProviderProbeResult;
388
- try {
389
- result = await probe(a.tenantId, request, providerId);
390
- } catch (error) {
391
- result = {
392
- poolId: request.endpoint,
393
- providerId,
394
- status: "unreachable",
395
- reason: error instanceof Error ? error.message : "Probe failed",
396
- };
397
- }
398
- results[index] = { endpoint: request.endpoint, result };
399
- }
400
- };
401
- await Promise.all(
402
- Array.from({ length: Math.min(PROVIDER_PROBE_BATCH_CONCURRENCY, requests.length) }, () => worker()),
403
- );
404
- return results;
405
- },
406
- async probeAdHocPool(
407
- access: AccessDecision | undefined,
408
- request: CreateNetworkPoolRequest,
409
- ): Promise<HealthCheckResult> {
410
- const a = requireTenantScope(access, "dashboard:read");
411
- if (config.store.probeAdHoc) {
412
- return config.store.probeAdHoc(a.tenantId, request);
413
- }
414
- throw new ConsoleDomainError("not_supported", 400, "Ad-hoc pool probing is unavailable");
415
- },
416
- /**
417
- * Probes many unsaved pool definitions in one call.
418
- *
419
- * The dashboard tests a pasted list before saving it; doing that one
420
- * request per proxy made a 100-line paste take a minute. The dials still
421
- * happen individually — only the round-trips are batched — and run with
422
- * bounded concurrency so a large list does not open every socket at once.
423
- */
424
- async probeAdHocPoolBatch(
425
- access: AccessDecision | undefined,
426
- requests: readonly CreateNetworkPoolRequest[],
427
- ): Promise<readonly PoolBatchProbeResult[]> {
428
- const a = requireTenantScope(access, "dashboard:read");
429
- if (requests.length === 0) return [];
430
- if (requests.length > MAX_BATCH_PROBE_TARGETS) {
431
- throw new ConsoleDomainError(
432
- "batch_too_large",
433
- 422,
434
- `At most ${MAX_BATCH_PROBE_TARGETS} targets per batch`,
435
- );
436
- }
437
- if (!config.store.probeAdHoc) {
438
- throw new ConsoleDomainError("not_supported", 400, "Ad-hoc pool probing is unavailable");
439
- }
440
- const probe = config.store.probeAdHoc.bind(config.store);
441
- const results: PoolBatchProbeResult[] = new Array(requests.length);
442
- let cursor = 0;
443
- const worker = async () => {
444
- while (cursor < requests.length) {
445
- const index = cursor++;
446
- const request = requests[index]!;
447
- // A single failing probe must not fail the batch: the caller wants a
448
- // per-endpoint verdict, so an error becomes that endpoint's result.
449
- let result: HealthCheckResult;
450
- try {
451
- result = await probe(a.tenantId, request);
452
- } catch (error) {
453
- result = {
454
- poolId: request.endpoint,
455
- status: "unhealthy",
456
- errorMessage: error instanceof Error ? error.message : "Probe failed",
457
- };
458
- }
459
- results[index] = { endpoint: request.endpoint, kind: request.kind, result };
460
- }
461
- };
462
- const workers = Array.from(
463
- { length: Math.min(POOL_BATCH_PROBE_CONCURRENCY, requests.length) },
464
- () => worker(),
465
- );
466
- await Promise.all(workers);
467
- return results;
468
- },
469
- /**
470
- * Deploys a hosted relay (Cloudflare/Vercel/Deno) and registers its URL as
471
- * an active HTTP pool. The provider API token is used for the deploy and
472
- * never persisted; the relay URL is a public host, which is what the pool
473
- * stores. The deploy fetch is the SSRF-validated one, so the provider API
474
- * call is checked like every other egress.
475
- */
476
- async deployRelay(
477
- access: AccessDecision | undefined,
478
- request: RelayDeployRequest,
479
- ): Promise<NetworkPoolResponse & { relayUrl: string }> {
480
- const a = requireTenantScope(access, "dashboard:write");
481
- if (!config.relayFetch)
482
- throw new ConsoleDomainError("not_supported", 503, "Relay deployment is unavailable");
483
- if (!isRelayTarget(request.target))
484
- throw new ConsoleDomainError("invalid_request", 422, `Unsupported relay target: ${String(request.target)}`);
485
- const result = await deployRelayWorker(config.relayFetch, request);
486
- const created = await operations.createPool(access, {
487
- kind: "http",
488
- endpoint: result.relayUrl,
489
- label: request.projectName?.trim() || `${result.target} relay`,
490
- });
491
- await config.auditSink?.record({
492
- access: a,
493
- action: "network_pool.relay_deployed",
494
- target: created.id,
495
- detail: { target: result.target, relayUrl: result.relayUrl },
496
- });
497
- return { ...created, relayUrl: result.relayUrl };
498
- },
499
- };
500
- return operations;
501
- }
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
+ }