cartethyia 2.6.7 → 2.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +1 -7
- package/README.md +25 -2
- package/dist/dashboard/assets/{BannedPage-Dskhyp0l.js → BannedPage-D-J75hBM.js} +1 -1
- package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage--SJhk7qQ.js → CliToolDetailPage-CVe6RaBH.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-C203VT-c.js → CliToolsPage-Dd8ZSamp.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-BcS7kGG1.js → CombosPage-QoJ6foxo.js} +1 -1
- package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js +1 -0
- package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-uujbBYwC.js → CustomizationPage-CKUFTcW4.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DubdQcvX.js +1 -0
- package/dist/dashboard/assets/LoginPage-DubdQcvX.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-CA1kLs45.js → ModelPicker-BH8Tehdi.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js +1 -0
- package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +4 -0
- package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js +1 -0
- package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js +9 -0
- package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-BueSWX5D.js → QuotaPage-ATpaxrbl.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CETophpx.js +1 -0
- package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-BdgECjqq.js +1 -0
- package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-DrKeMcGN.js → SortableList-CAK-EN9z.js} +1 -1
- package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-qjuYtccc.js → StudioPage-B7W2_RhT.js} +3 -3
- package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-6T4EFDjW.js +56 -0
- package/dist/dashboard/assets/UsagePage-6T4EFDjW.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-C23S9sgp.js → api-keys-CUi6UXsR.js} +1 -1
- package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
- package/dist/dashboard/assets/app-KvsUJUMF.css +2 -0
- package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
- package/dist/dashboard/assets/{assert-DU6qrMA_.js → assert-67xXH5rf.js} +1 -1
- package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
- package/dist/dashboard/assets/{badge-Bkz_-RrM.js → badge-DC2L43Mj.js} +1 -1
- package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
- package/dist/dashboard/assets/{banner-C-7y3VKV.js → banner-BBVcb0J3.js} +1 -1
- package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
- package/dist/dashboard/assets/{card-BOPBuBzD.js → card-Cca1XC6G.js} +1 -1
- package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-BaFUuFFF.js → cli-tools-KvsWa1Jl.js} +1 -1
- package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-e-Mlk0FH.js → clipboard-button-DUzEbynq.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-Iec_ywbD.js → dialog-DFvWhwTa.js} +1 -1
- package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-Bs8MIIHc.js → drawer-CFM2sMX1.js} +1 -1
- package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
- package/dist/dashboard/assets/{entry-DOddwoc0.js → entry-BLBLxX0h.js} +3 -3
- package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
- package/dist/dashboard/assets/{entry-DcobaKFu.js → entry-Bqy8RFvM.js} +1 -1
- package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
- package/dist/dashboard/assets/{entry-BRszm4_Z.js → entry-_xHo5x6r.js} +1 -1
- package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
- package/dist/dashboard/assets/formatters-mpBPqAQ8.js +1 -0
- package/dist/dashboard/assets/formatters-mpBPqAQ8.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-BuDsrsPn.js → github-badge-C9e8Ts7e.js} +1 -1
- package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
- package/dist/dashboard/assets/{index-BeLRhsmt.js → index-D6QzEC_n.js} +2 -2
- package/dist/dashboard/assets/index-D6QzEC_n.js.gz +0 -0
- package/dist/dashboard/assets/{inline-DToMICwl.js → inline-BFUcvllj.js} +1 -1
- package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
- package/dist/dashboard/assets/{input-WlyHWeG2.js → input-C_ILAvBb.js} +1 -1
- package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
- package/dist/dashboard/assets/{layout-DaJ5baJr.js → layout-CBIyT6Rt.js} +1 -1
- package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-B3oSK13z.js → list-row-ebJ_-Nsb.js} +1 -1
- package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
- package/dist/dashboard/assets/{live-ta6GMb-8.js → live-D0mm--ME.js} +1 -1
- package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
- package/dist/dashboard/assets/minimize-2-D-MN8W8a.js +1 -0
- package/dist/dashboard/assets/minimize-2-D-MN8W8a.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-g_fduiNw.js → mutation-sMD965oO.js} +1 -1
- package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-Dulqgys5.js → numbers-BO9dKaFr.js} +1 -1
- package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
- package/dist/dashboard/assets/providers/inception.svg +5 -0
- package/dist/dashboard/assets/providers/inception.svg.gz +0 -0
- package/dist/dashboard/assets/{query-keys-UETqfytp.js → query-keys-CfL_Zdoh.js} +1 -1
- package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
- package/dist/dashboard/assets/{routing-BPUo-W8d.js → routing-BnIpi1IJ.js} +1 -1
- package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
- package/dist/dashboard/assets/{select-BfZ3SCTM.js → select-Cj5Nw43g.js} +1 -1
- package/dist/dashboard/assets/select-Cj5Nw43g.js.gz +0 -0
- package/dist/dashboard/assets/settings-BAFhDgX8.js +1 -0
- package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
- package/dist/dashboard/assets/{stack-B1w5BM0u.js → stack-EMrOMt4I.js} +1 -1
- package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
- package/dist/dashboard/assets/{state-DhZBfWgx.js → state-CWoo7ty5.js} +1 -1
- package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
- package/dist/dashboard/assets/{switch-yD7D99Lg.js → switch-CCjT6IkF.js} +1 -1
- package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-EDFRkn1i.js → useMutation-DjkLHUcE.js} +1 -1
- package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +55 -54
- package/migrations/full-postgres/0024_add_pool_provider_cooldowns.sql +12 -0
- package/migrations/full-postgres/0025_add_pool_random_strategy.sql +3 -0
- package/migrations/lite-sqlite3/0024_add_pool_provider_cooldowns.sql +12 -0
- package/migrations/lite-sqlite3/0025_add_pool_random_strategy.sql +20 -0
- package/package.json +1 -1
- package/scripts/cli/console-menu.ts +5 -2
- package/scripts/cli/tray.ts +21 -6
- package/src/boot/app.ts +24 -62
- package/src/boot/dependencies.ts +732 -750
- package/src/config/cooldowns.ts +16 -0
- package/src/config/providers.ts +13 -1
- package/src/config/resources.ts +13 -80
- package/src/config/security.ts +2 -38
- package/src/config/spec.ts +16 -23
- package/src/console/console-router.ts +7 -7
- package/src/console/domain-registration.ts +19 -12
- package/src/console/providers/catalog/catalog-projections.ts +2 -6
- package/src/console/providers/catalog/contracts.ts +19 -0
- package/src/console/providers/catalog/provider-operations.ts +38 -0
- package/src/console/providers/catalog/routes.ts +14 -0
- package/src/console/providers/catalog/store-helpers.ts +326 -0
- package/src/console/providers/catalog/store.ts +1292 -1563
- package/src/console/quota/account-quota-global.ts +2 -2
- package/src/console/quota/account-quota-shared.ts +1 -1
- package/src/console/quota/account-quota-tenant.ts +35 -12
- package/src/console/quota/quota-view.ts +2 -2
- package/src/console/remote-support/contracts.ts +3 -1
- package/src/console/remote-support/service.ts +115 -6
- package/src/console/routing/pools/contracts.ts +41 -1
- package/src/console/routing/pools/operations.ts +183 -4
- package/src/console/routing/pools/relay-deploy.ts +17 -4
- package/src/console/routing/pools/routes.ts +30 -0
- package/src/console/routing/pools/store.ts +98 -8
- package/src/main.ts +16 -2
- package/src/network/outbound-fetch.ts +226 -365
- package/src/network/pool/agent.ts +8 -0
- package/src/network/pool/pool-state.ts +7 -0
- package/src/network/pool/selector.ts +110 -2
- package/src/network/pool-cooldown-store.ts +252 -0
- package/src/network/pool-health-machine.ts +5 -3
- package/src/network/pool-health.ts +36 -9
- package/src/observability/event-query.ts +57 -0
- package/src/observability/metrics.ts +15 -5
- package/src/observability/runtime-metrics.ts +30 -15
- package/src/persistence/health-event-flush.ts +2 -0
- package/src/persistence/schema.ts +19 -0
- package/src/persistence/usage-flush.ts +8 -3
- package/src/protocol/canonical-model.ts +1 -1
- package/src/protocol/dsml.ts +377 -0
- package/src/protocol/gateway-error.ts +0 -1
- package/src/{providers/provider-reasoning.ts → protocol/reasoning.ts} +10 -9
- package/src/protocol/request/chat.ts +7 -7
- package/src/protocol/request/codex.ts +2 -2
- package/src/protocol/request/gemini.ts +1 -1
- package/src/protocol/request/messages.ts +6 -6
- package/src/protocol/request/responses.ts +7 -7
- package/src/protocol/response/dsml.ts +12 -133
- package/src/{transport → protocol}/translation/attachment-integrity.ts +2 -2
- package/src/{transport → protocol}/translation/cache-controls.ts +1 -1
- package/src/{transport → protocol}/translation/capabilities.ts +2 -2
- package/src/{transport → protocol}/translation/normalize.ts +1 -1
- package/src/{transport → protocol}/translation/quirks.ts +1 -1
- package/src/{transport → protocol}/translation/search.ts +1 -1
- package/src/{transport → protocol}/translation/thinking.ts +23 -1
- package/src/{transport → protocol}/translation/tool-id.ts +1 -1
- package/src/{transport → protocol}/translation/tool-repair.ts +2 -2
- package/src/protocol/upstream-parsing.ts +176 -0
- package/src/providers/adapters/compatible-adapter.ts +11 -2
- package/src/providers/authentication/oauth-refresh-service.ts +9 -5
- package/src/providers/default-registry.ts +21 -5
- package/src/providers/discovery/probe-phases.ts +16 -3
- package/src/providers/discovery/probing-service.ts +4 -1
- package/src/providers/integrations/anthropic.ts +1 -1
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -4
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +141 -8
- package/src/providers/integrations/buddy/buddy-checkin.ts +5 -1
- package/src/providers/integrations/buddy/buddy-growth-shared.ts +0 -82
- package/src/providers/integrations/buddy/buddy-identity.ts +25 -0
- package/src/providers/integrations/buddy/buddy-server-tools.ts +240 -0
- package/src/providers/integrations/buddy/codebuddy/cn-catalog.ts +36 -0
- package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +6 -31
- package/src/providers/integrations/buddy/codebuddy/provider.ts +2 -0
- package/src/providers/integrations/buddy/codebuddy/shared.ts +19 -4
- package/src/providers/integrations/buddy/turing-device-token.ts +262 -0
- package/src/providers/integrations/buddy/workbuddy/provider.ts +2 -0
- package/src/providers/integrations/buddy/workbuddy/shared.ts +56 -3
- package/src/providers/integrations/claude/claude.ts +1 -1
- package/src/providers/integrations/cloudflareai/cloudflareai.ts +1 -1
- package/src/providers/integrations/codex/codex-errors.ts +1 -1
- package/src/providers/integrations/codex/codex.ts +1 -1
- package/src/providers/integrations/commandcode/commandcode.ts +134 -104
- package/src/providers/integrations/commandcode/discovery.ts +6 -5
- package/src/providers/integrations/factory/factory.ts +1 -1
- package/src/providers/integrations/github/github.ts +1 -1
- package/src/providers/integrations/grok/grok.ts +1 -1
- package/src/providers/integrations/inception/provider.ts +91 -0
- package/src/providers/integrations/inferhub.ts +1 -1
- package/src/providers/integrations/kiro/kiro-request.ts +1 -1
- package/src/providers/integrations/mistral.ts +193 -19
- package/src/providers/integrations/opencode/fingerprint.ts +19 -2
- package/src/providers/integrations/opencode/prompt.ts +111 -0
- package/src/providers/integrations/opencode/provider.ts +101 -23
- package/src/providers/integrations/opencode/tool-definitions.ts +4 -4
- package/src/providers/integrations/qoder/qoder-checkin.ts +303 -0
- package/src/providers/integrations/qoder/qoder.ts +1641 -884
- package/src/providers/integrations/shared/install-id.ts +2 -1
- package/src/providers/integrations/trae/trae-cache.ts +110 -0
- package/src/providers/integrations/trae/trae-oauth.ts +29 -4
- package/src/providers/integrations/trae/trae-protocol.ts +2 -1
- package/src/providers/integrations/trae/trae.ts +106 -23
- package/src/providers/operations/account-error-classification.ts +1660 -0
- package/src/providers/operations/account-health-service.ts +621 -2097
- package/src/providers/operations/account-reset-service.ts +2 -0
- package/src/providers/provider-contracts.ts +0 -2
- package/src/providers/provider-metadata.ts +25 -4
- package/src/providers/provider-probe.ts +2 -2
- package/src/{console → providers}/quota/cache.ts +1 -1
- package/src/{console → providers}/quota/refresh.ts +5 -5
- package/src/security/abuse.ts +17 -0
- package/src/transport/dispatch/attempt-finalize.ts +114 -12
- package/src/transport/dispatch/attempt-loop.ts +3 -0
- package/src/transport/dispatch/empty-stream.ts +224 -217
- package/src/transport/dispatch/generation.ts +37 -55
- package/src/transport/dispatch/handler-deps.ts +32 -0
- package/src/transport/dispatch/leases.ts +9 -4
- package/src/transport/dispatch/proxy-request.ts +6 -30
- package/src/transport/dispatch/responses-compact.ts +22 -43
- package/src/transport/dispatch/streaming/finalize.ts +3 -0
- package/src/transport/dispatch/streaming/stream.ts +0 -8
- package/src/transport/dispatch/systemone.ts +22 -42
- package/src/transport/dispatch/token-count.ts +23 -39
- package/src/transport/dispatch/upstream.ts +1 -3
- package/src/transport/dispatch/websearch.ts +22 -41
- package/src/transport/failure-policy.ts +59 -178
- package/src/transport/guards/abuse-guard.ts +10 -0
- package/src/transport/guards/auth-guard.ts +0 -18
- package/src/transport/guards/error-lifecycle.ts +1 -1
- package/src/transport/guards/pipeline.ts +0 -6
- package/src/transport/guards/request-context.ts +18 -91
- package/src/transport/request/degradation.ts +20 -1
- package/src/transport/request/inflight.ts +4 -95
- package/src/transport/request/preparer.ts +6 -6
- package/src/transport/request/state.ts +66 -38
- package/src/transport/request/tenant-preferences.ts +1 -1
- package/src/transport/routing/route-catalog.ts +41 -31
- package/src/transport/routing/router.ts +2 -2
- package/src/transport/surface/messages/parse.ts +1 -1
- package/src/workers/buddy-growth-tasks.ts +346 -425
- package/src/workers/daily-checkin.ts +17 -6
- package/src/workers/quota-refresh-sweep.ts +2 -2
- package/src/workers/scheduler.ts +17 -1
- package/dist/dashboard/assets/BannedPage-Dskhyp0l.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage--SJhk7qQ.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-C203VT-c.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-BcS7kGG1.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-ChmiSLwO.js +0 -1
- package/dist/dashboard/assets/ConsoleLogPage-ChmiSLwO.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-uujbBYwC.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-fKAWd-OQ.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-fKAWd-OQ.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DoOUthbG.js +0 -1
- package/dist/dashboard/assets/LoginPage-DoOUthbG.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-CA1kLs45.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-C5Z-UBz1.js +0 -1
- package/dist/dashboard/assets/OverviewPage-C5Z-UBz1.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CFu1qGcT.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-CFu1qGcT.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +0 -1
- package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-CnlJZbxF.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-CnlJZbxF.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-B9mBTweh.js +0 -7
- package/dist/dashboard/assets/ProxyPage-B9mBTweh.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BueSWX5D.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-B0x00MVY.js +0 -1
- package/dist/dashboard/assets/SettingsPage-B0x00MVY.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-DQ9oDRpi.js +0 -1
- package/dist/dashboard/assets/SetupPage-DQ9oDRpi.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DrKeMcGN.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-qjuYtccc.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-Be7aNviQ.js +0 -56
- package/dist/dashboard/assets/UsagePage-Be7aNviQ.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
- package/dist/dashboard/assets/app-BUSiYL6F.css +0 -2
- package/dist/dashboard/assets/app-BUSiYL6F.css.gz +0 -0
- package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
- package/dist/dashboard/assets/badge-Bkz_-RrM.js.gz +0 -0
- package/dist/dashboard/assets/banner-C-7y3VKV.js.gz +0 -0
- package/dist/dashboard/assets/card-BOPBuBzD.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-e-Mlk0FH.js.gz +0 -0
- package/dist/dashboard/assets/cog-CdWhsajs.js +0 -1
- package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
- package/dist/dashboard/assets/dialog-Iec_ywbD.js.gz +0 -0
- package/dist/dashboard/assets/drawer-Bs8MIIHc.js.gz +0 -0
- package/dist/dashboard/assets/entry-BRszm4_Z.js.gz +0 -0
- package/dist/dashboard/assets/entry-DOddwoc0.js.gz +0 -0
- package/dist/dashboard/assets/entry-DcobaKFu.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-BuDsrsPn.js.gz +0 -0
- package/dist/dashboard/assets/index-BeLRhsmt.js.gz +0 -0
- package/dist/dashboard/assets/inline-DToMICwl.js.gz +0 -0
- package/dist/dashboard/assets/input-WlyHWeG2.js.gz +0 -0
- package/dist/dashboard/assets/layout-DaJ5baJr.js.gz +0 -0
- package/dist/dashboard/assets/list-row-B3oSK13z.js.gz +0 -0
- package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-BmYTmbAk.js +0 -1
- package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
- package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
- package/dist/dashboard/assets/numbers-Dulqgys5.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
- package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
- package/dist/dashboard/assets/select-BfZ3SCTM.js.gz +0 -0
- package/dist/dashboard/assets/settings--3hxSyry.js +0 -1
- package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
- package/dist/dashboard/assets/stack-B1w5BM0u.js.gz +0 -0
- package/dist/dashboard/assets/state-DhZBfWgx.js.gz +0 -0
- package/dist/dashboard/assets/switch-yD7D99Lg.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
- package/src/console/admin/model-abuse/contracts.ts +0 -38
- package/src/console/admin/model-abuse/routes.ts +0 -97
- package/src/security/model-abuse.ts +0 -704
- package/src/transport/guards/concurrency-guard.ts +0 -125
- /package/dist/dashboard/assets/{app-MdVfwraB.js → app-BaWd0puM.js} +0 -0
- /package/dist/dashboard/assets/{app-MdVfwraB.js.gz → app-BaWd0puM.js.gz} +0 -0
|
@@ -3,7 +3,7 @@ import type { Elysia } from "elysia";
|
|
|
3
3
|
import { and, eq, isNull } from "drizzle-orm";
|
|
4
4
|
import { adminAuditLog, providerAccounts } from "../../persistence/schema";
|
|
5
5
|
import { requireGlobalAdmin } from "../shared/errors";
|
|
6
|
-
import { GLOBAL_QUOTA_LENS, invalidateQuotaCache } from "
|
|
6
|
+
import { GLOBAL_QUOTA_LENS, invalidateQuotaCache } from "../../providers/quota/cache";
|
|
7
7
|
import { invalidateCredentialCache } from "../../providers/operations/provider-credential-service";
|
|
8
8
|
import {
|
|
9
9
|
QUOTA_REFRESH_TIMEOUT_MS,
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
signalFetch,
|
|
13
13
|
timeoutSignal,
|
|
14
14
|
type QuotaRefreshDeps,
|
|
15
|
-
} from "
|
|
15
|
+
} from "../../providers/quota/refresh";
|
|
16
16
|
import { sanitizeGlobalAccount } from "./quota-view";
|
|
17
17
|
import {
|
|
18
18
|
ACCOUNT_STATUSES,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ConsoleAccessResolver } from "../auth/access";
|
|
2
2
|
import type { AuditRecorder } from "../auth/audit-recorder";
|
|
3
3
|
import { markClineApiKeyCredential } from "../../providers/integrations/cline/cline-quota";
|
|
4
|
-
import { type QuotaRefreshDeps } from "
|
|
4
|
+
import { type QuotaRefreshDeps } from "../../providers/quota/refresh";
|
|
5
5
|
import type { AccountTestEgressDeps } from "./account-test-egress";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
getCachedQuotaEntries,
|
|
15
15
|
getCachedQuotaEntry,
|
|
16
16
|
invalidateQuotaCache,
|
|
17
|
-
} from "
|
|
17
|
+
} from "../../providers/quota/cache";
|
|
18
18
|
import {
|
|
19
19
|
QUOTA_REFRESH_TIMEOUT_MS,
|
|
20
20
|
refreshAccountQuota,
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
timeoutSignal,
|
|
23
23
|
type QuotaRefreshDeps,
|
|
24
24
|
type QuotaRefreshFailureCode,
|
|
25
|
-
} from "
|
|
25
|
+
} from "../../providers/quota/refresh";
|
|
26
26
|
import { selectAccountTestEgress } from "./account-test-egress";
|
|
27
27
|
import type { ValidatedFetch } from "../../network/outbound-fetch";
|
|
28
28
|
import type { ProviderId } from "../../providers/provider-contracts";
|
|
@@ -39,6 +39,10 @@ import {
|
|
|
39
39
|
type BuddyActivityReportResult,
|
|
40
40
|
type DailyCheckinResult,
|
|
41
41
|
} from "../../providers/integrations/buddy/buddy-checkin";
|
|
42
|
+
import {
|
|
43
|
+
fetchQoderDailyCheckin,
|
|
44
|
+
isQoderCheckinProvider,
|
|
45
|
+
} from "../../providers/integrations/qoder/qoder-checkin";
|
|
42
46
|
import { buddyAccountUid } from "../../providers/integrations/buddy/buddy-oauth-shared";
|
|
43
47
|
import {
|
|
44
48
|
supportsAccountReset,
|
|
@@ -99,12 +103,20 @@ function toBuddyUsageView(
|
|
|
99
103
|
};
|
|
100
104
|
}
|
|
101
105
|
|
|
102
|
-
/** One account's check-in through the buddy billing facade. */
|
|
106
|
+
/** One account's check-in through the buddy billing facade (Qoder uses its own surface). */
|
|
103
107
|
async function runAccountCheckin(
|
|
104
108
|
account: { id: string; providerId: string },
|
|
105
109
|
credential: string,
|
|
106
110
|
forceClaim: boolean,
|
|
107
111
|
): Promise<DailyCheckinResult> {
|
|
112
|
+
if (isQoderCheckinProvider(account.providerId)) {
|
|
113
|
+
return fetchQoderDailyCheckin({
|
|
114
|
+
providerId: account.providerId,
|
|
115
|
+
accountId: account.id,
|
|
116
|
+
credential,
|
|
117
|
+
fetcher: globalThis.fetch,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
108
120
|
return fetchDailyCheckin({
|
|
109
121
|
providerId: account.providerId,
|
|
110
122
|
accountId: account.id,
|
|
@@ -153,10 +165,14 @@ async function runDailyGrowthForAccount(
|
|
|
153
165
|
}
|
|
154
166
|
|
|
155
167
|
// Report leg: needs a uid, which only a JWT carries, so an opaque API key
|
|
156
|
-
// fails closed instead of reporting under another account's identity.
|
|
168
|
+
// fails closed instead of reporting under another account's identity. Qoder
|
|
169
|
+
// has no activity-report leg — its daily grant is purely the check-in.
|
|
170
|
+
const qoderAccount = isQoderCheckinProvider(String(providerId));
|
|
157
171
|
const userId = buddyAccountUid(credential);
|
|
158
172
|
let report: { ok: boolean; message: string };
|
|
159
|
-
if (
|
|
173
|
+
if (qoderAccount) {
|
|
174
|
+
report = { ok: true, message: "Qoder has no activity-report leg" };
|
|
175
|
+
} else if (userId === undefined || userId.length === 0) {
|
|
160
176
|
report = { ok: false, message: "Report needs an OAuth account (no uid in this credential)" };
|
|
161
177
|
} else {
|
|
162
178
|
try {
|
|
@@ -180,13 +196,20 @@ async function runDailyGrowthForAccount(
|
|
|
180
196
|
|
|
181
197
|
let checkin: { ok: boolean; message: string };
|
|
182
198
|
try {
|
|
183
|
-
const result =
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
199
|
+
const result = qoderAccount
|
|
200
|
+
? await fetchQoderDailyCheckin({
|
|
201
|
+
providerId: String(providerId),
|
|
202
|
+
accountId: account.id,
|
|
203
|
+
credential,
|
|
204
|
+
fetcher,
|
|
205
|
+
})
|
|
206
|
+
: await fetchDailyCheckin({
|
|
207
|
+
providerId,
|
|
208
|
+
accountId: account.id,
|
|
209
|
+
credential,
|
|
210
|
+
forceClaim: true,
|
|
211
|
+
fetcher,
|
|
212
|
+
});
|
|
190
213
|
checkin =
|
|
191
214
|
result.state === "error"
|
|
192
215
|
? { ok: false, message: result.error ?? "Daily check-in failed" }
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { providerAccounts } from "../../persistence/schema";
|
|
2
2
|
import type { ProviderQuotaResult } from "../../providers/quota/quota-contracts";
|
|
3
3
|
import { log } from "../../observability/logger";
|
|
4
|
-
import { GLOBAL_QUOTA_LENS } from "
|
|
4
|
+
import { GLOBAL_QUOTA_LENS } from "../../providers/quota/cache";
|
|
5
5
|
import {
|
|
6
6
|
QUOTA_REFRESH_TIMEOUT_MS,
|
|
7
7
|
refreshAccountQuota,
|
|
8
8
|
signalFetch,
|
|
9
9
|
type QuotaRefreshDeps,
|
|
10
10
|
type QuotaRefreshTarget,
|
|
11
|
-
} from "
|
|
11
|
+
} from "../../providers/quota/refresh";
|
|
12
12
|
|
|
13
13
|
/** Shape returned for quota data on dashboard account responses. */
|
|
14
14
|
export interface QuotaRefreshView {
|
|
@@ -21,7 +21,9 @@ export type RemoteSupportTool = (typeof REMOTE_SUPPORT_TOOLS)[number];
|
|
|
21
21
|
export type RemoteSupportBlocker =
|
|
22
22
|
| "not_installed"
|
|
23
23
|
| "unsupported_platform"
|
|
24
|
-
| "launch_failed"
|
|
24
|
+
| "launch_failed"
|
|
25
|
+
/** The publisher launched but has not published an address yet. */
|
|
26
|
+
| "not_ready";
|
|
25
27
|
|
|
26
28
|
export interface RemoteSupportStatus {
|
|
27
29
|
/** Which publisher is up, or `null` when neither is. */
|
|
@@ -186,9 +186,95 @@ async function waitForUrl(dir: string, tool: RemoteSupportTool): Promise<string
|
|
|
186
186
|
* live hostname comes from the daemon's own status. The JSON shape has drifted
|
|
187
187
|
* across releases (`DNSName` at the root in some, nested under `Self` in
|
|
188
188
|
* others), so both locations are probed and the first hit wins.
|
|
189
|
+
*
|
|
190
|
+
* `status` is probed as well as `serve status`: the latter answers `{}` until
|
|
191
|
+
* the node has actually joined the tailnet, which is exactly when the operator
|
|
192
|
+
* is staring at a "started" publisher with no link. `status` carries
|
|
193
|
+
* `Self.DNSName` and `BackendState` throughout, so it names the node while
|
|
194
|
+
* serve is still coming up.
|
|
189
195
|
*/
|
|
190
196
|
function tailscaleUrlFromDaemon(): string | null {
|
|
197
|
+
const name = tailscaleDnsName();
|
|
198
|
+
return name === null ? null : `https://${name}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Why a publisher that started has not published an address.
|
|
203
|
+
*
|
|
204
|
+
* For Tailscale the answer is almost always that the node is not on the
|
|
205
|
+
* tailnet yet — still starting, or never logged in — and both are the
|
|
206
|
+
* operator's move, not the gateway's. Naming which one turns a dead end into
|
|
207
|
+
* something to do.
|
|
208
|
+
*/
|
|
209
|
+
function notReadyReason(tool: RemoteSupportTool): string {
|
|
210
|
+
if (tool !== "tailscale") return `${tool} started but has not published an address yet.`;
|
|
211
|
+
const state = spawnSync("tailscale", ["status", "--json"], { encoding: "utf8", windowsHide: true });
|
|
212
|
+
let backend: string | null = null;
|
|
213
|
+
let authUrl: string | null = null;
|
|
214
|
+
try {
|
|
215
|
+
const bag = JSON.parse(state.stdout) as Record<string, unknown>;
|
|
216
|
+
backend = typeof bag["BackendState"] === "string" ? (bag["BackendState"] as string) : null;
|
|
217
|
+
authUrl = typeof bag["AuthURL"] === "string" && (bag["AuthURL"] as string).length > 0 ? (bag["AuthURL"] as string) : null;
|
|
218
|
+
} catch {
|
|
219
|
+
// An unreadable status still leaves the generic message below.
|
|
220
|
+
}
|
|
221
|
+
if (authUrl !== null) return `Tailscale needs to be logged in. Open ${authUrl} once, then start the tunnel again.`;
|
|
222
|
+
if (backend !== null && backend !== "Running") {
|
|
223
|
+
return `Tailscale is not connected yet (state: ${backend}). Wait for it to finish starting, then start the tunnel again.`;
|
|
224
|
+
}
|
|
225
|
+
return "Tailscale has no tailnet address yet. Run `tailscale up` once, then start the tunnel again.";
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** This node's MagicDNS name, or `null` when the daemon has none yet. */
|
|
229
|
+
function tailscaleDnsName(): string | null {
|
|
230
|
+
for (const args of [
|
|
231
|
+
["serve", "status", "--json"],
|
|
232
|
+
["status", "--json"],
|
|
233
|
+
] as const) {
|
|
234
|
+
const name = dnsNameFromStatus(args);
|
|
235
|
+
if (name !== null) return name;
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Whether `tailscale serve` is actually publishing anything.
|
|
242
|
+
*
|
|
243
|
+
* `serve status --json` is `{}` when nothing is served and gains `Web`
|
|
244
|
+
* (handlers) or `TCP` entries when something is. The node being online says
|
|
245
|
+
* nothing about this: stopping the tunnel leaves the node up and empties the
|
|
246
|
+
* status, which is why an online node alone was read as "still active".
|
|
247
|
+
*/
|
|
248
|
+
export function tailscaleServing(): boolean {
|
|
191
249
|
const result = spawnSync("tailscale", ["serve", "status", "--json"], { encoding: "utf8", windowsHide: true });
|
|
250
|
+
if (result.status !== 0) return false;
|
|
251
|
+
let parsed: unknown;
|
|
252
|
+
try {
|
|
253
|
+
parsed = JSON.parse(result.stdout);
|
|
254
|
+
} catch {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
return serveStatusHasHandler(parsed);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Whether a parsed `serve status` body publishes anything.
|
|
262
|
+
*
|
|
263
|
+
* Split from the spawn so the shape rules are testable without a daemon: `{}`
|
|
264
|
+
* is the empty answer, and any entry under `Web` or `TCP` is a live one.
|
|
265
|
+
*/
|
|
266
|
+
export function serveStatusHasHandler(parsed: unknown): boolean {
|
|
267
|
+
const bag: Record<string, unknown> =
|
|
268
|
+
typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
|
|
269
|
+
for (const key of ["Web", "TCP"]) {
|
|
270
|
+
const entries = bag[key];
|
|
271
|
+
if (typeof entries === "object" && entries !== null && Object.keys(entries).length > 0) return true;
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function dnsNameFromStatus(args: readonly string[]): string | null {
|
|
277
|
+
const result = spawnSync("tailscale", [...args], { encoding: "utf8", windowsHide: true });
|
|
192
278
|
if (result.status !== 0) return null;
|
|
193
279
|
let parsed: unknown;
|
|
194
280
|
try {
|
|
@@ -198,12 +284,24 @@ function tailscaleUrlFromDaemon(): string | null {
|
|
|
198
284
|
}
|
|
199
285
|
const bag: Record<string, unknown> =
|
|
200
286
|
typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
|
|
287
|
+
// Only `status` can prove the node is up — `serve status` carries no node
|
|
288
|
+
// state. A stopped node still reports its last DNS name, and publishing that
|
|
289
|
+
// as a live link is worse than publishing nothing: the operator opens an
|
|
290
|
+
// address that cannot resolve.
|
|
291
|
+
if (args[0] === "status") {
|
|
292
|
+
const self = selfOf(bag);
|
|
293
|
+
const online = self === null ? null : self["Online"];
|
|
294
|
+
if (online === false) return null;
|
|
295
|
+
}
|
|
201
296
|
const direct = typeof bag["DNSName"] === "string" ? (bag["DNSName"] as string) : null;
|
|
202
|
-
const self =
|
|
203
|
-
typeof bag["Self"] === "object" && bag["Self"] !== null ? (bag["Self"] as Record<string, unknown>) : null;
|
|
297
|
+
const self = selfOf(bag);
|
|
204
298
|
const nested = self && typeof self["DNSName"] === "string" ? (self["DNSName"] as string) : null;
|
|
205
|
-
const hostname = (direct ?? nested)?.trim().replace(/\/+$/, "");
|
|
206
|
-
return hostname && hostname.length > 0 ?
|
|
299
|
+
const hostname = (direct ?? nested)?.trim().replace(/\/+$/, "").replace(/\.$/, "");
|
|
300
|
+
return hostname && hostname.length > 0 ? hostname : null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function selfOf(bag: Record<string, unknown>): Record<string, unknown> | null {
|
|
304
|
+
return typeof bag["Self"] === "object" && bag["Self"] !== null ? (bag["Self"] as Record<string, unknown>) : null;
|
|
207
305
|
}
|
|
208
306
|
|
|
209
307
|
/**
|
|
@@ -281,7 +379,11 @@ export function createRemoteSupportService(config: RemoteSupportServiceConfig):
|
|
|
281
379
|
// Tailscale runs as a system service — its serve daemon owns its own
|
|
282
380
|
// lifecycle, so a pid file is not reliable. Ask the daemon.
|
|
283
381
|
if (tool === "tailscale") {
|
|
284
|
-
|
|
382
|
+
// Both halves are required. A node that is up but serving nothing is
|
|
383
|
+
// not publishing an address, and reporting it as active left the
|
|
384
|
+
// toggle on and a stale link in place after the operator stopped it —
|
|
385
|
+
// `serve status` goes back to `{}` but the node stays `Online`.
|
|
386
|
+
const url = tailscaleServing() ? tailscaleUrlFromDaemon() : null;
|
|
285
387
|
if (url) return { tool: "tailscale", url };
|
|
286
388
|
continue;
|
|
287
389
|
}
|
|
@@ -348,7 +450,14 @@ export function createRemoteSupportService(config: RemoteSupportServiceConfig):
|
|
|
348
450
|
installed: await detectInstalled(),
|
|
349
451
|
};
|
|
350
452
|
}
|
|
351
|
-
|
|
453
|
+
const status = await readStatus();
|
|
454
|
+
// A publisher that started but published no address is the state that
|
|
455
|
+
// used to look like a silent success: the UI showed the tool as active
|
|
456
|
+
// with no link and no reason. Say what is missing instead.
|
|
457
|
+
if (status.active !== null && status.url === null) {
|
|
458
|
+
return { ...status, blocker: "not_ready", message: notReadyReason(status.active) };
|
|
459
|
+
}
|
|
460
|
+
return status;
|
|
352
461
|
},
|
|
353
462
|
|
|
354
463
|
async stop(tool) {
|
|
@@ -221,7 +221,7 @@ export type PoolStatus = (typeof POOL_STATUSES)[number];
|
|
|
221
221
|
* direct callers against the same list (mirrors `POOL_STATUSES`). Values must
|
|
222
222
|
* stay aligned with the `pool_routing_strategy` enum in `schema.ts`.
|
|
223
223
|
*/
|
|
224
|
-
export const POOL_ROUTING_STRATEGIES = ["least_loaded", "round_robin"] as const;
|
|
224
|
+
export const POOL_ROUTING_STRATEGIES = ["least_loaded", "round_robin", "random"] as const;
|
|
225
225
|
export type PoolRoutingStrategyValue = (typeof POOL_ROUTING_STRATEGIES)[number];
|
|
226
226
|
|
|
227
227
|
export interface PoolStrategySetting {
|
|
@@ -351,12 +351,22 @@ export interface NetworkPoolStore {
|
|
|
351
351
|
list(tenantId: string): Promise<readonly NetworkPoolRecord[]>;
|
|
352
352
|
get(tenantId: string, poolId: string): Promise<NetworkPoolRecord | undefined>;
|
|
353
353
|
create(record: NetworkPoolRecord): Promise<void>;
|
|
354
|
+
/** Inserts many rows in one statement; returns how many landed. */
|
|
355
|
+
createMany?(records: readonly NetworkPoolRecord[]): Promise<number>;
|
|
354
356
|
update(
|
|
355
357
|
tenantId: string,
|
|
356
358
|
poolId: string,
|
|
357
359
|
patch: Partial<NetworkPoolRecord>,
|
|
358
360
|
): Promise<NetworkPoolRecord | undefined>;
|
|
361
|
+
/** Marks many rows `deleted`; returns how many matched. */
|
|
362
|
+
updateManyStatus?(
|
|
363
|
+
tenantId: string,
|
|
364
|
+
poolIds: readonly string[],
|
|
365
|
+
status: NetworkPoolRecord["status"],
|
|
366
|
+
): Promise<number>;
|
|
359
367
|
delete(tenantId: string, poolId: string): Promise<boolean>;
|
|
368
|
+
/** Deletes many rows in one statement; returns how many matched. */
|
|
369
|
+
deleteMany?(tenantId: string, poolIds: readonly string[]): Promise<number>;
|
|
360
370
|
healthCheck(tenantId: string, poolId: string): Promise<HealthCheckResult>;
|
|
361
371
|
/** Measures download throughput through the pool; never throws for a slow
|
|
362
372
|
* or refused transfer — a failed measurement is itself the result. */
|
|
@@ -379,6 +389,36 @@ export interface NetworkPoolStore {
|
|
|
379
389
|
): Promise<PoolProviderProbeResult>;
|
|
380
390
|
listHealthEvents(tenantId: string, poolId: string): Promise<readonly PoolHealthEvent[]>;
|
|
381
391
|
recover(tenantId: string, poolId: string): Promise<boolean>;
|
|
392
|
+
/**
|
|
393
|
+
* Drops the durable provider-cooldown markers on one pool row — the mirror
|
|
394
|
+
* the selector restores from at boot. The in-memory/Redis state is cleared
|
|
395
|
+
* separately by the selector; this keeps the two from drifting after a
|
|
396
|
+
* restart. `onlyBlocked` limits the drop to retired-egress markers, mirroring
|
|
397
|
+
* `clearProviderBlockedOnly`: a 429 probe may prove an address was never
|
|
398
|
+
* retired but must not release its live cooldown. Optional (and a no-op in
|
|
399
|
+
* tests without a backing row store).
|
|
400
|
+
*/
|
|
401
|
+
dropProviderCooldownMarker?(
|
|
402
|
+
tenantId: string,
|
|
403
|
+
poolId: string,
|
|
404
|
+
providerId: string,
|
|
405
|
+
onlyBlocked: boolean,
|
|
406
|
+
): Promise<void>;
|
|
407
|
+
/** Drops every provider's marker on one row (disable/replace paths). */
|
|
408
|
+
dropAllProviderCooldownMarkers?(tenantId: string, poolId: string): Promise<void>;
|
|
409
|
+
/**
|
|
410
|
+
* Writes a probe-measured 429 into the durable mirror and the pool's health
|
|
411
|
+
* history. The probe is the only writer that can *flag* a cooldown from the
|
|
412
|
+
* control plane — its 429 is a fresh measurement of the same condition a
|
|
413
|
+
* dispatch-side 429 records, so it lands in both stores and appears as
|
|
414
|
+
* activity. Optional; absent in tests without a backing row store.
|
|
415
|
+
*/
|
|
416
|
+
recordProviderProbeCooldown?(
|
|
417
|
+
tenantId: string,
|
|
418
|
+
poolId: string,
|
|
419
|
+
providerId: string,
|
|
420
|
+
marker: { until: number; reason: string; blocked?: boolean },
|
|
421
|
+
): Promise<void>;
|
|
382
422
|
/** Per-tenant pool-group selection strategy; an absent row reads as
|
|
383
423
|
* `DEFAULT_POOL_STRATEGY`. */
|
|
384
424
|
getStrategy(tenantId: string): Promise<PoolStrategySetting>;
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// behind the routes. Depends only on the contracts and the shared helpers;
|
|
3
3
|
// `routes.ts` is the only HTTP surface.
|
|
4
4
|
import { ConsoleDomainError, requireTenantScope } from "../../shared/errors";
|
|
5
|
+
import { providerRateLimitIsIpScoped } from "../../../providers/provider-metadata";
|
|
6
|
+
import { resolvePoolProviderRateLimitCooldownMs } from "../../../config/cooldowns";
|
|
5
7
|
import type { PoolHealthEvent } from "../../../network/pool-health-machine";
|
|
6
8
|
import type { AccessDecision } from "../../../security/access-control";
|
|
7
9
|
import {
|
|
@@ -28,6 +30,7 @@ import {
|
|
|
28
30
|
type NetworkPoolConfig,
|
|
29
31
|
type NetworkPoolRecord,
|
|
30
32
|
type NetworkPoolResponse,
|
|
33
|
+
type PoolStatus,
|
|
31
34
|
type PoolBatchProbeResult,
|
|
32
35
|
type PoolSpeedTestResult,
|
|
33
36
|
type PoolStrategySetting,
|
|
@@ -194,6 +197,130 @@ export function createNetworkPoolOperations(config: NetworkPoolConfig) {
|
|
|
194
197
|
}
|
|
195
198
|
return { created, skipped };
|
|
196
199
|
},
|
|
200
|
+
async createPoolBatch(
|
|
201
|
+
access: AccessDecision | undefined,
|
|
202
|
+
requests: readonly CreateNetworkPoolRequest[],
|
|
203
|
+
): Promise<{ created: NetworkPoolResponse[]; failed: number }> {
|
|
204
|
+
const a = requireTenantScope(access, "dashboard:write");
|
|
205
|
+
let failed = 0;
|
|
206
|
+
const records: NetworkPoolRecord[] = [];
|
|
207
|
+
for (const request of requests) {
|
|
208
|
+
try {
|
|
209
|
+
const normalizedConfig = normalizePoolConfig(request.config);
|
|
210
|
+
const endpoint = request.endpoint;
|
|
211
|
+
validateEndpoint(request.kind, endpoint, config.ssrfPolicy);
|
|
212
|
+
validateTransportConfig(request.kind, normalizedConfig);
|
|
213
|
+
validatePoolLimits(request.maxInflight, request.weight);
|
|
214
|
+
records.push({
|
|
215
|
+
id: crypto.randomUUID(),
|
|
216
|
+
kind: request.kind,
|
|
217
|
+
endpoint,
|
|
218
|
+
maxInflight: request.maxInflight ?? 10,
|
|
219
|
+
weight: request.weight ?? 100,
|
|
220
|
+
status: "active",
|
|
221
|
+
inflight: 0,
|
|
222
|
+
consecutiveFailures: 0,
|
|
223
|
+
tenantId: a.tenantId,
|
|
224
|
+
createdAt: new Date().toISOString(),
|
|
225
|
+
...(request.label === undefined ? {} : { label: request.label }),
|
|
226
|
+
...(Object.keys(normalizedConfig).length > 0 ? { config: normalizedConfig } : {}),
|
|
227
|
+
...(request.credential === undefined ? {} : { credential: request.credential }),
|
|
228
|
+
...(request.credential !== undefined ? { hasCredential: true } : {}),
|
|
229
|
+
...(typeof request.quotaBytes === "number" ? { quotaBytes: request.quotaBytes } : {}),
|
|
230
|
+
...(request.family === undefined ? {} : { family: request.family }),
|
|
231
|
+
});
|
|
232
|
+
} catch {
|
|
233
|
+
failed += 1;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// All rows land in one statement: a long paste was one round trip per
|
|
237
|
+
// proxy, which made a 40-line import take minutes.
|
|
238
|
+
let created: NetworkPoolResponse[] = [];
|
|
239
|
+
if (records.length > 0) {
|
|
240
|
+
if (config.store.createMany) {
|
|
241
|
+
await config.store.createMany(records);
|
|
242
|
+
} else {
|
|
243
|
+
for (const record of records) await config.store.create(record);
|
|
244
|
+
}
|
|
245
|
+
created = records.map((record) => sanitizePoolResponse(record));
|
|
246
|
+
await config.auditSink?.record({
|
|
247
|
+
access: a,
|
|
248
|
+
action: "network_pool.created",
|
|
249
|
+
target: created[0]!.id,
|
|
250
|
+
detail: { count: created.length, failed },
|
|
251
|
+
});
|
|
252
|
+
await config.snapshotInvalidator?.invalidate();
|
|
253
|
+
}
|
|
254
|
+
return { created, failed };
|
|
255
|
+
},
|
|
256
|
+
async deletePoolBatch(
|
|
257
|
+
access: AccessDecision | undefined,
|
|
258
|
+
poolIds: readonly string[],
|
|
259
|
+
): Promise<{ deleted: number; failed: number }> {
|
|
260
|
+
const a = requireTenantScope(access, "dashboard:write");
|
|
261
|
+
let ok = 0;
|
|
262
|
+
if (config.store.deleteMany) {
|
|
263
|
+
ok = await config.store.deleteMany(a.tenantId, poolIds);
|
|
264
|
+
} else {
|
|
265
|
+
for (const poolId of poolIds) {
|
|
266
|
+
try {
|
|
267
|
+
if (await config.store.delete(a.tenantId, poolId)) ok += 1;
|
|
268
|
+
} catch {
|
|
269
|
+
// Missing rows are not failures for a bulk delete.
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Cooldown markers and cached agents are best-effort here: the rows are
|
|
274
|
+
// already gone, so a failed cleanup must not report a failed delete.
|
|
275
|
+
await Promise.all(
|
|
276
|
+
poolIds.map(async (poolId) => {
|
|
277
|
+
await config.poolAgentReleaser?.releasePool(poolId, a.tenantId).catch(() => undefined);
|
|
278
|
+
await config.poolSelector?.clearPoolCooldowns(poolId).catch(() => undefined);
|
|
279
|
+
}),
|
|
280
|
+
);
|
|
281
|
+
await config.auditSink?.record({
|
|
282
|
+
access: a,
|
|
283
|
+
action: "network_pool.deleted",
|
|
284
|
+
target: poolIds[0] ?? "",
|
|
285
|
+
detail: { count: ok, failed: poolIds.length - ok },
|
|
286
|
+
});
|
|
287
|
+
if (ok > 0) await config.snapshotInvalidator?.invalidate();
|
|
288
|
+
return { deleted: ok, failed: poolIds.length - ok };
|
|
289
|
+
},
|
|
290
|
+
async setPoolStatusBatch(
|
|
291
|
+
access: AccessDecision | undefined,
|
|
292
|
+
poolIds: readonly string[],
|
|
293
|
+
status: PoolStatus,
|
|
294
|
+
): Promise<{ updated: number }> {
|
|
295
|
+
const a = requireTenantScope(access, "dashboard:write");
|
|
296
|
+
let updated = 0;
|
|
297
|
+
if (config.store.updateManyStatus) {
|
|
298
|
+
updated = await config.store.updateManyStatus(a.tenantId, poolIds, status);
|
|
299
|
+
} else {
|
|
300
|
+
for (const poolId of poolIds) {
|
|
301
|
+
try {
|
|
302
|
+
if (await config.store.update(a.tenantId, poolId, { status })) updated += 1;
|
|
303
|
+
} catch {
|
|
304
|
+
// Missing rows are not failures for a bulk status change.
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// Any mutation can invalidate the cached dial agent; the batch releases
|
|
309
|
+
// each selected pool's agent so the next dispatch rebuilds fresh.
|
|
310
|
+
await Promise.all(
|
|
311
|
+
poolIds.map(async (poolId) => {
|
|
312
|
+
await config.poolAgentReleaser?.releasePool(poolId, a.tenantId).catch(() => undefined);
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
315
|
+
await config.auditSink?.record({
|
|
316
|
+
access: a,
|
|
317
|
+
action: "network_pool.updated",
|
|
318
|
+
target: poolIds[0] ?? "",
|
|
319
|
+
detail: { status, count: updated },
|
|
320
|
+
});
|
|
321
|
+
if (updated > 0) await config.snapshotInvalidator?.invalidate();
|
|
322
|
+
return { updated };
|
|
323
|
+
},
|
|
197
324
|
async createPool(
|
|
198
325
|
access: AccessDecision | undefined,
|
|
199
326
|
request: CreateNetworkPoolRequest,
|
|
@@ -255,6 +382,15 @@ export function createNetworkPoolOperations(config: NetworkPoolConfig) {
|
|
|
255
382
|
validateEndpoint(effectiveKind, effectiveEndpoint, config.ssrfPolicy);
|
|
256
383
|
validateTransportConfig(effectiveKind, normalizedConfig);
|
|
257
384
|
request = { ...request, endpoint: effectiveEndpoint, config: normalizedConfig };
|
|
385
|
+
// A changed endpoint is a different egress address, so its cooldowns
|
|
386
|
+
// — like its probe verdict — no longer describe this pool. Drop the
|
|
387
|
+
// durable markers with the volatile ones, or a replaced proxy would
|
|
388
|
+
// come back online and immediately be skipped because the previous
|
|
389
|
+
// address's flags were restored at boot.
|
|
390
|
+
if (request.endpoint !== undefined && request.endpoint !== existing.endpoint) {
|
|
391
|
+
await config.poolSelector?.clearPoolCooldowns(poolId).catch(() => undefined);
|
|
392
|
+
await config.store.dropAllProviderCooldownMarkers?.(a.tenantId, poolId);
|
|
393
|
+
}
|
|
258
394
|
}
|
|
259
395
|
const updated = await config.store.update(
|
|
260
396
|
a.tenantId,
|
|
@@ -380,18 +516,61 @@ export function createNetworkPoolOperations(config: NetworkPoolConfig) {
|
|
|
380
516
|
// still picked a red one": the red was a colour, not a decision.
|
|
381
517
|
//
|
|
382
518
|
// `blocked` retires the egress for this provider, so the next dispatch
|
|
383
|
-
// skips it.
|
|
519
|
+
// skips it. `allowed` clears a previous block, because the address is
|
|
384
520
|
// serving again — an operator who replaced a proxy must not have to find
|
|
385
521
|
// a separate button to un-retire it.
|
|
522
|
+
//
|
|
523
|
+
// `blocked` retires the egress — but only for a provider whose limits
|
|
524
|
+
// follow the address. `flagProviderBlocked` refuses everyone else, so the
|
|
525
|
+
// verdict reported back is what actually happened, not what was asked.
|
|
386
526
|
if (result.status === "blocked") {
|
|
387
|
-
await config.poolSelector.flagProviderBlocked(
|
|
527
|
+
const entry = await config.poolSelector.flagProviderBlocked(
|
|
388
528
|
poolId,
|
|
389
529
|
providerId,
|
|
390
530
|
"Upstream refused this egress address",
|
|
391
531
|
);
|
|
392
|
-
|
|
532
|
+
if (entry.blocked === true) {
|
|
533
|
+
// The retirement claim lands in the mirror and the health history
|
|
534
|
+
// with its provider named, like every other verdict — an enforced
|
|
535
|
+
// state the operator cannot see is the "colour, not a decision"
|
|
536
|
+
// failure mode again.
|
|
537
|
+
await config.store.recordProviderProbeCooldown?.(a.tenantId, poolId, providerId, {
|
|
538
|
+
until: entry.until,
|
|
539
|
+
reason: entry.reason,
|
|
540
|
+
blocked: true,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
return { ...result, blockedPool: entry.blocked === true };
|
|
544
|
+
}
|
|
545
|
+
// A measured success releases the address — in rotation and in the
|
|
546
|
+
// durable mirror the next boot restores from.
|
|
547
|
+
if (result.status === "allowed") {
|
|
548
|
+
await config.poolSelector.clearProviderCooldown(poolId, providerId);
|
|
549
|
+
await config.store.dropProviderCooldownMarker?.(a.tenantId, poolId, providerId, false);
|
|
550
|
+
return { ...result, blockedPool: false };
|
|
551
|
+
}
|
|
552
|
+
// `throttled` (429) proves the address is *serving* — its rate limiter
|
|
553
|
+
// answered — so a stale "retired" claim is corrected. And the throttle
|
|
554
|
+
// itself is a measurement: the egress just demonstrated it is out of
|
|
555
|
+
// allowance, which is exactly when dispatch should skip it **and** the
|
|
556
|
+
// operator sees why. Flag the same (configured) window a dispatch-side
|
|
557
|
+
// 429 would, in rotation memory *and* in the durable mirror, plus the
|
|
558
|
+
// health event the activity panel lists. Only a 429 gap was leaving a
|
|
559
|
+
// rate-limited pool invisible and unflagged.
|
|
560
|
+
if (result.status === "throttled") {
|
|
561
|
+
await config.poolSelector.clearProviderBlockedOnly(poolId, providerId);
|
|
562
|
+
await config.store.dropProviderCooldownMarker?.(a.tenantId, poolId, providerId, true);
|
|
563
|
+
if (providerRateLimitIsIpScoped(providerId)) {
|
|
564
|
+
const durationMs = resolvePoolProviderRateLimitCooldownMs();
|
|
565
|
+
const reason = "Operator probe measured a provider 429";
|
|
566
|
+
await config.poolSelector.flagProviderCooldown(poolId, providerId, durationMs, reason);
|
|
567
|
+
await config.store.recordProviderProbeCooldown?.(a.tenantId, poolId, providerId, {
|
|
568
|
+
until: Date.now() + durationMs,
|
|
569
|
+
reason,
|
|
570
|
+
});
|
|
571
|
+
}
|
|
393
572
|
}
|
|
394
|
-
|
|
573
|
+
// `unreachable` learned nothing about the address, so nothing changes.
|
|
395
574
|
return { ...result, blockedPool: false };
|
|
396
575
|
},
|
|
397
576
|
/**
|
|
@@ -63,7 +63,14 @@ const handler = {
|
|
|
63
63
|
}
|
|
64
64
|
try {
|
|
65
65
|
const response = await fetch(targetUrl, init);
|
|
66
|
-
|
|
66
|
+
// Forward the body verbatim. Content-encoding/content-length must be
|
|
67
|
+
// stripped: the relay's own runtime already decoded the stream it hands
|
|
68
|
+
// us, so re-sending the original gzip headers makes the caller decompress
|
|
69
|
+
// twice (ZlibError). The body bytes are already plaintext here.
|
|
70
|
+
const headers = new Headers(response.headers);
|
|
71
|
+
headers.delete("content-encoding");
|
|
72
|
+
headers.delete("content-length");
|
|
73
|
+
return new Response(response.body, { status: response.status, headers });
|
|
67
74
|
} catch (error) {
|
|
68
75
|
return new Response(JSON.stringify({ error: String((error && error.message) || error) }), {
|
|
69
76
|
status: 502,
|
|
@@ -217,10 +224,14 @@ async function deployVercel(
|
|
|
217
224
|
// PATCH is used rather than POST /v1/projects because the project already
|
|
218
225
|
// exists once the first deployment lands; the first deploy below creates it,
|
|
219
226
|
// and this call is what pins its region before that deploy runs its build.
|
|
227
|
+
//
|
|
228
|
+
// `ssoProtection: null` disables Vercel Authentication (SSO) on the project.
|
|
229
|
+
// Without it every deployment is protected and answers `302 → vercel.com/
|
|
230
|
+
// sso-api` to anonymous callers — the relay URL exists but is not public.
|
|
220
231
|
await fetchImpl(`https://api.vercel.com/v9/projects/${encodeURIComponent(projectName)}`, {
|
|
221
232
|
method: "PATCH",
|
|
222
233
|
headers: auth,
|
|
223
|
-
body: JSON.stringify({ serverlessFunctionRegion: region }),
|
|
234
|
+
body: JSON.stringify({ serverlessFunctionRegion: region, ssoProtection: null }),
|
|
224
235
|
}).catch(() => undefined);
|
|
225
236
|
const deployRes = await fetchImpl("https://api.vercel.com/v13/deployments", {
|
|
226
237
|
method: "POST",
|
|
@@ -243,11 +254,13 @@ async function deployVercel(
|
|
|
243
254
|
throw new GatewayError("platform_unavailable", deployRes.status, `Vercel deploy failed: ${await readError(deployRes)}`);
|
|
244
255
|
// Pin the region again after the deploy created the project: the PATCH above
|
|
245
256
|
// is a no-op on first deploy (the project does not exist yet), and a redeploy
|
|
246
|
-
// is what Vercel requires for a region change to take effect anyway.
|
|
257
|
+
// is what Vercel requires for a region change to take effect anyway. The SSO
|
|
258
|
+
// protection reset is repeated for the same reason — the first PATCH had no
|
|
259
|
+
// project to act on.
|
|
247
260
|
await fetchImpl(`https://api.vercel.com/v9/projects/${encodeURIComponent(projectName)}`, {
|
|
248
261
|
method: "PATCH",
|
|
249
262
|
headers: auth,
|
|
250
|
-
body: JSON.stringify({ serverlessFunctionRegion: region }),
|
|
263
|
+
body: JSON.stringify({ serverlessFunctionRegion: region, ssoProtection: null }),
|
|
251
264
|
}).catch(() => undefined);
|
|
252
265
|
await fetchImpl("https://api.vercel.com/v13/deployments", {
|
|
253
266
|
method: "POST",
|