cartethyia 2.7.2 → 2.7.4
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 +2 -0
- package/dist/dashboard/assets/{BannedPage-XaUmiBrC.js → BannedPage-ChG5svFb.js} +1 -1
- package/dist/dashboard/assets/BannedPage-ChG5svFb.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-BIcWQun7.js +1 -0
- package/dist/dashboard/assets/CliToolDetailPage-BIcWQun7.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-Bf2VelDe.js +1 -0
- package/dist/dashboard/assets/CliToolsPage-Bf2VelDe.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-CXGS_Ct-.js → CombosPage-DCgm5g6g.js} +1 -1
- package/dist/dashboard/assets/CombosPage-DCgm5g6g.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-a8mD0QF1.js +1 -0
- package/dist/dashboard/assets/ConsoleLogPage-a8mD0QF1.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-ggeMSjte.js +1 -0
- package/dist/dashboard/assets/CustomizationPage-ggeMSjte.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-BKtAPYVF.js → HealthEventsModal-DzSWzE_j.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-DzSWzE_j.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-BdmRLnq2.js → LoginPage-U6AKaJcF.js} +1 -1
- package/dist/dashboard/assets/LoginPage-U6AKaJcF.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-BH8Tehdi.js → ModelPicker-BMS2t8BA.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-BMS2t8BA.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-D2D4IiHl.js +1 -0
- package/dist/dashboard/assets/OverviewPage-D2D4IiHl.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CrNf7j_X.js +5 -0
- package/dist/dashboard/assets/ProviderDetailPage-CrNf7j_X.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-BSDbquc1.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-BSDbquc1.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-lsrx7NYg.js +9 -0
- package/dist/dashboard/assets/ProxyPage-lsrx7NYg.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-L0skzML8.js +1 -0
- package/dist/dashboard/assets/QuotaPage-L0skzML8.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CmAbjRHW.js +1 -0
- package/dist/dashboard/assets/SettingsPage-CmAbjRHW.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-f0DE1h0-.js +1 -0
- package/dist/dashboard/assets/SetupPage-f0DE1h0-.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-Dqf4zpS-.js → SortableList-ClzpwyET.js} +1 -1
- package/dist/dashboard/assets/SortableList-ClzpwyET.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-Byj2lAEm.js → StudioPage-Cq5tW7hW.js} +3 -3
- package/dist/dashboard/assets/StudioPage-Cq5tW7hW.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-Dfc9rWQn.js +56 -0
- package/dist/dashboard/assets/UsagePage-Dfc9rWQn.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-C38iXoO4.js +1 -0
- package/dist/dashboard/assets/api-keys-C38iXoO4.js.gz +0 -0
- package/dist/dashboard/assets/app-a159k0ub.css +2 -0
- package/dist/dashboard/assets/app-a159k0ub.css.gz +0 -0
- package/dist/dashboard/assets/arrow-up-59ms1iiT.js +1 -0
- package/dist/dashboard/assets/arrow-up-59ms1iiT.js.gz +0 -0
- package/dist/dashboard/assets/assert-DbRA8EG2.js +15 -0
- package/dist/dashboard/assets/assert-DbRA8EG2.js.gz +0 -0
- package/dist/dashboard/assets/{badge-DC2L43Mj.js → badge-D9CS6dZk.js} +1 -1
- package/dist/dashboard/assets/badge-D9CS6dZk.js.gz +0 -0
- package/dist/dashboard/assets/{banner-BBVcb0J3.js → banner-DympBNq4.js} +1 -1
- package/dist/dashboard/assets/banner-DympBNq4.js.gz +0 -0
- package/dist/dashboard/assets/{card-Cca1XC6G.js → card-ibRWU_uT.js} +1 -1
- package/dist/dashboard/assets/card-ibRWU_uT.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-63qBGaRw.js +1 -0
- package/dist/dashboard/assets/cli-tools-63qBGaRw.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-DUzEbynq.js → clipboard-button-DJm-9pz7.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-DJm-9pz7.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-DFvWhwTa.js → dialog-8fiE6tec.js} +1 -1
- package/dist/dashboard/assets/dialog-8fiE6tec.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CqGYeOrd.js +1 -0
- package/dist/dashboard/assets/drawer-CqGYeOrd.js.gz +0 -0
- package/dist/dashboard/assets/{entry-Bqy8RFvM.js → entry-BSAsYsmV.js} +1 -1
- package/dist/dashboard/assets/entry-BSAsYsmV.js.gz +0 -0
- package/dist/dashboard/assets/entry-CiIQULZD.js +4 -0
- package/dist/dashboard/assets/entry-CiIQULZD.js.gz +0 -0
- package/dist/dashboard/assets/entry-Daz0lJ-W.js +1 -0
- package/dist/dashboard/assets/entry-Daz0lJ-W.js.gz +0 -0
- package/dist/dashboard/assets/{concurrency-BDF0ok7y.js → fingerprint-pattern-BWuHtbW4.js} +1 -1
- package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
- package/dist/dashboard/assets/{formatters-CWWZ-uCZ.js → formatters-D-MTJxMr.js} +1 -1
- package/dist/dashboard/assets/formatters-D-MTJxMr.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-C9e8Ts7e.js → github-badge-CPK47xQx.js} +1 -1
- package/dist/dashboard/assets/github-badge-CPK47xQx.js.gz +0 -0
- package/dist/dashboard/assets/{index-DFVFrnWW.js → index-DBNPOfhV.js} +2 -2
- package/dist/dashboard/assets/index-DBNPOfhV.js.gz +0 -0
- package/dist/dashboard/assets/{inline-BFUcvllj.js → inline-BT0zntEr.js} +1 -1
- package/dist/dashboard/assets/inline-BT0zntEr.js.gz +0 -0
- package/dist/dashboard/assets/{input-C_ILAvBb.js → input-Da2ZViWg.js} +1 -1
- package/dist/dashboard/assets/input-Da2ZViWg.js.gz +0 -0
- package/dist/dashboard/assets/{layout-CBIyT6Rt.js → layout-BXNeLw6M.js} +1 -1
- package/dist/dashboard/assets/layout-BXNeLw6M.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-ebJ_-Nsb.js → list-row-BycOrrIV.js} +1 -1
- package/dist/dashboard/assets/list-row-BycOrrIV.js.gz +0 -0
- package/dist/dashboard/assets/live-DCQNZpF7.js +1 -0
- package/dist/dashboard/assets/live-DCQNZpF7.js.gz +0 -0
- package/dist/dashboard/assets/minimize-2-BTmMTK4o.js +1 -0
- package/dist/dashboard/assets/minimize-2-BTmMTK4o.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-sMD965oO.js → mutation-Rr0TIvcj.js} +1 -1
- package/dist/dashboard/assets/mutation-Rr0TIvcj.js.gz +0 -0
- package/dist/dashboard/assets/network-9Yj2ZyLo.js +1 -0
- package/dist/dashboard/assets/network-9Yj2ZyLo.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-BO9dKaFr.js → numbers-CEagj6ZZ.js} +2 -2
- package/dist/dashboard/assets/numbers-CEagj6ZZ.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-CfL_Zdoh.js → query-keys-BloKtaXJ.js} +1 -1
- package/dist/dashboard/assets/query-keys-BloKtaXJ.js.gz +0 -0
- package/dist/dashboard/assets/quota-Caw7wqci.js +1 -0
- package/dist/dashboard/assets/quota-Caw7wqci.js.gz +0 -0
- package/dist/dashboard/assets/routing-CO9zCUus.js +1 -0
- package/dist/dashboard/assets/routing-CO9zCUus.js.gz +0 -0
- package/dist/dashboard/assets/{select-DBNVLdUu.js → select-BIYIBkMU.js} +2 -2
- package/dist/dashboard/assets/select-BIYIBkMU.js.gz +0 -0
- package/dist/dashboard/assets/settings-2-Ds9DQp0t.js +1 -0
- package/dist/dashboard/assets/settings-2-Ds9DQp0t.js.gz +0 -0
- package/dist/dashboard/assets/settings-jS3-PNSA.js +1 -0
- package/dist/dashboard/assets/settings-jS3-PNSA.js.gz +0 -0
- package/dist/dashboard/assets/{stack-EMrOMt4I.js → stack-DfgnFDWY.js} +1 -1
- package/dist/dashboard/assets/stack-DfgnFDWY.js.gz +0 -0
- package/dist/dashboard/assets/{state-CWoo7ty5.js → state-CkfdYWel.js} +1 -1
- package/dist/dashboard/assets/state-CkfdYWel.js.gz +0 -0
- package/dist/dashboard/assets/{switch-CCjT6IkF.js → switch-SYm237HG.js} +1 -1
- package/dist/dashboard/assets/switch-SYm237HG.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-DjkLHUcE.js → useMutation-BQqCRNIi.js} +1 -1
- package/dist/dashboard/assets/useMutation-BQqCRNIi.js.gz +0 -0
- package/dist/dashboard/index.html +3 -2
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +118 -115
- package/migrations/full-postgres/0026_add_qoder_native_identity.sql +20 -0
- package/migrations/full-postgres/0027_qoder_identity_genuine_seed.sql +21 -0
- package/migrations/full-postgres/0028_add_family_template_key_telemetry.sql +40 -0
- package/migrations/full-postgres/0029_null_share_child_policy_columns.sql +38 -0
- package/migrations/full-postgres/0030_add_api_key_max_children.sql +10 -0
- package/migrations/full-postgres/0031_tenant_system_prompts.sql +16 -0
- package/migrations/full-postgres/0032_add_provider_pool_accounts.sql +53 -0
- package/migrations/full-postgres/0033_add_api_key_ban.sql +9 -0
- package/migrations/full-postgres/0034_add_api_key_ban_until.sql +7 -0
- package/migrations/full-postgres/0035_align_pool_account_status_enum.sql +21 -0
- package/migrations/lite-sqlite3/0026_add_qoder_native_identity.sql +15 -0
- package/migrations/lite-sqlite3/0027_qoder_identity_genuine_seed.sql +21 -0
- package/migrations/lite-sqlite3/0028_add_family_template_key_telemetry.sql +40 -0
- package/migrations/lite-sqlite3/0029_null_share_child_policy_columns.sql +38 -0
- package/migrations/lite-sqlite3/0030_add_api_key_max_children.sql +10 -0
- package/migrations/lite-sqlite3/0031_tenant_system_prompts.sql +10 -0
- package/migrations/lite-sqlite3/0032_add_provider_pool_accounts.sql +44 -0
- package/migrations/lite-sqlite3/0033_add_api_key_ban.sql +8 -0
- package/migrations/lite-sqlite3/0034_add_api_key_ban_until.sql +5 -0
- package/migrations/lite-sqlite3/0035_align_pool_account_status_enum.sql +6 -0
- package/package.json +2 -1
- package/src/boot/app.ts +9 -8
- package/src/config/network.ts +7 -3
- package/src/console/api-keys/bucket-spend.ts +7 -7
- package/src/console/api-keys/contracts.ts +475 -410
- package/src/console/api-keys/routes.ts +211 -23
- package/src/console/audit/routes.ts +3 -3
- package/src/console/auth/account-reset.ts +4 -4
- package/src/console/auth/session.ts +3 -3
- package/src/console/backup/contracts.ts +2 -0
- package/src/console/backup/export-endpoint.ts +14 -17
- package/src/console/backup/routes.ts +43 -11
- package/src/console/backup/service.ts +11 -6
- package/src/console/backup/store.ts +384 -88
- package/src/console/cli-tools/routes.ts +3 -3
- package/src/console/console-router-guards.ts +2 -2
- package/src/console/console-router.ts +4 -4
- package/src/console/dashboard-assets.ts +16 -1
- package/src/console/data-management/routes.ts +3 -3
- package/src/console/domain-registration.ts +4 -2
- package/src/console/observability/contracts.ts +4 -0
- package/src/console/observability/live.ts +3 -3
- package/src/console/observability/logs.ts +3 -3
- package/src/console/observability/performance.ts +3 -3
- package/src/console/observability/routes.ts +3 -3
- package/src/console/observability/store.ts +16 -16
- package/src/console/providers/catalog/contracts.ts +43 -22
- package/src/console/providers/catalog/provider-operations.ts +17 -2
- package/src/console/providers/catalog/routes.ts +3 -3
- package/src/console/providers/catalog/store-helpers.ts +349 -326
- package/src/console/providers/catalog/store.ts +1523 -1292
- package/src/console/providers/detail/routes.ts +3 -3
- package/src/console/providers/oauth/routes.ts +3 -3
- package/src/console/quota/account-quota-tenant.ts +34 -2
- package/src/console/quota/account-quota.ts +4 -1
- package/src/console/quota/account-test-egress.ts +4 -4
- package/src/console/quota/native-identity.ts +189 -0
- package/src/console/remote-support/contracts.ts +12 -6
- package/src/console/remote-support/service.ts +2 -2
- package/src/console/routing/model/routes.ts +3 -3
- package/src/console/routing/pools/contracts.ts +11 -8
- package/src/console/routing/pools/operations.ts +14 -7
- package/src/console/routing/pools/probe-verdict.ts +8 -3
- package/src/console/routing/pools/relay-deploy.ts +9 -9
- package/src/console/routing/pools/routes.ts +3 -3
- package/src/console/routing/pools/store.ts +21 -1
- package/src/console/settings/routes.ts +3 -3
- package/src/console/share/share-router.ts +51 -18
- package/src/console/share/share-stats.ts +158 -49
- package/src/console/share/share-usage.ts +24 -7
- package/src/console/shared/read-through-cache.ts +38 -2
- package/src/console/system-prompt/contracts.ts +102 -0
- package/src/console/system-prompt/routes.ts +165 -0
- package/src/console/updates/routes.ts +7 -4
- package/src/main.ts +11 -0
- package/src/network/outbound-fetch.ts +75 -4
- package/src/network/pool/resolver.ts +39 -39
- package/src/network/pool-cooldown-store.ts +71 -16
- package/src/network/pool-health-machine.ts +21 -1
- package/src/network/rate-limit-verdict.ts +68 -0
- package/src/network/ssrf.ts +4 -4
- package/src/observability/event-query.ts +437 -22
- package/src/observability/telemetry-status.ts +1 -1
- package/src/persistence/db-pg.ts +42 -0
- package/src/persistence/db-sqlite.ts +7 -2
- package/src/persistence/readiness.ts +18 -6
- package/src/persistence/schema.ts +162 -1
- package/src/persistence/stores/api-key-store.ts +849 -581
- package/src/persistence/stores/share-store.ts +65 -13
- package/src/persistence/stores/telemetry-store.ts +58 -2
- package/src/persistence/system-prompt-store.ts +104 -0
- package/src/protocol/codecs.ts +6 -6
- package/src/protocol/gateway-error.ts +6 -3
- package/src/protocol/image-generation.ts +2 -2
- package/src/protocol/reasoning.ts +65 -1
- package/src/protocol/request/chat.ts +1 -0
- package/src/protocol/response/chat.ts +2 -2
- package/src/protocol/response/codex.ts +1 -1
- package/src/protocol/response/gemini.ts +1 -1
- package/src/protocol/response/messages.ts +5 -5
- package/src/protocol/response/responses.ts +2 -2
- package/src/protocol/sse-decoder.ts +14 -3
- package/src/protocol/translation/capabilities.ts +2 -2
- package/src/protocol/upstream/send-claude-messages.ts +3 -3
- package/src/providers/adapters/base-adapter.ts +1 -1
- package/src/providers/adapters/compatible-adapter.ts +2 -2
- package/src/providers/authentication/oauth-refresh-service.ts +2 -2
- package/src/providers/configured-provider.ts +1 -1
- package/src/providers/discovery/probe-phases.ts +2 -2
- package/src/providers/discovery/probing-service.ts +12 -12
- package/src/providers/integrations/agentrouter/agentrouter.ts +2 -3
- package/src/providers/integrations/alibabacp/alibabacp.ts +2 -2
- package/src/providers/integrations/anthropic.ts +4 -11
- package/src/providers/integrations/antigravity/antigravity.ts +33 -47
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -1
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +30 -2
- package/src/providers/integrations/buddy/buddy-discovery-shared.ts +37 -6
- package/src/providers/integrations/buddy/buddy-identity.ts +22 -1
- package/src/providers/integrations/buddy/buddy-media.ts +21 -17
- package/src/providers/integrations/buddy/codebuddy/provider.ts +6 -2
- package/src/providers/integrations/buddy/workbuddy/provider.ts +6 -2
- package/src/providers/integrations/claude/claude-betas.ts +1 -1
- package/src/providers/integrations/claude/claude-credentials.ts +1 -1
- package/src/providers/integrations/claude/claude-slow-lane-retry.ts +1 -1
- package/src/providers/integrations/claude/claude.ts +3 -10
- package/src/providers/integrations/cline/cline.ts +8 -1
- package/src/providers/integrations/cloudflareai/cloudflareai.ts +4 -7
- package/src/providers/integrations/codex/codex-errors.ts +1 -1
- package/src/providers/integrations/codex/codex.ts +14 -15
- package/src/providers/integrations/commandcode/commandcode.ts +4 -4
- package/src/providers/integrations/commandcode/quota.ts +5 -1
- package/src/providers/integrations/cursor/cursor-http2.ts +11 -11
- package/src/providers/integrations/cursor/cursor.ts +15 -15
- package/src/providers/integrations/factory/factory.ts +4 -4
- package/src/providers/integrations/gemini.ts +11 -11
- package/src/providers/integrations/github/github.ts +26 -7
- package/src/providers/integrations/grok/grok.ts +2 -6
- package/src/providers/integrations/hermes.ts +5 -1
- package/src/providers/integrations/inferhub.ts +4 -11
- package/src/providers/integrations/kilo/kilo.ts +4 -0
- package/src/providers/integrations/kimi/kimi.ts +4 -11
- package/src/providers/integrations/kiro/kiro.ts +10 -10
- package/src/providers/integrations/muse/muse.ts +6 -9
- package/src/providers/integrations/openrouter/provider.ts +4 -0
- package/src/providers/integrations/perplexity.ts +2 -2
- package/src/providers/integrations/qoder/qoder-catalog.ts +47 -9
- package/src/providers/integrations/qoder/qoder-checkin.ts +242 -51
- package/src/providers/integrations/qoder/qoder-native-identity-store.ts +157 -0
- package/src/providers/integrations/qoder/qoder-oauth.ts +12 -3
- package/src/providers/integrations/qoder/qoder-realm.ts +15 -0
- package/src/providers/integrations/qoder/qoder-umid.ts +287 -0
- package/src/providers/integrations/qoder/qoder-virtual.ts +2 -2
- package/src/providers/integrations/qoder/qoder.ts +192 -24
- package/src/providers/integrations/tokenharbor.ts +2 -2
- package/src/providers/integrations/trae/trae.ts +6 -9
- package/src/providers/integrations/zcode/oauth.ts +1 -1
- package/src/providers/operations/account-error-classification.ts +6 -8
- package/src/providers/operations/account-health-service.ts +14 -16
- package/src/providers/operations/client-versions.ts +37 -22
- package/src/providers/operations/provider-catalog-service.ts +4 -4
- package/src/providers/operations/provider-credential-service.ts +16 -4
- package/src/providers/operations/upstream-deadline.ts +3 -3
- package/src/providers/provider-contracts.ts +55 -3
- package/src/providers/provider-credential-resolver.ts +1 -1
- package/src/providers/provider-registry.ts +4 -4
- package/src/providers/quota/quota-contracts.ts +24 -10
- package/src/providers/quota/refresh.ts +2 -2
- package/src/providers/search/search-provider.ts +5 -5
- package/src/security/abuse.ts +4 -4
- package/src/security/access-control.ts +19 -0
- package/src/security/admission/reasons.ts +17 -17
- package/src/security/api-key-auth.ts +18 -5
- package/src/shared/coerce.ts +19 -0
- package/src/transport/dispatch/attempt-finalize.ts +4 -3
- package/src/transport/dispatch/attempt-loop.ts +10 -7
- package/src/transport/dispatch/empty-stream.ts +6 -6
- package/src/transport/dispatch/generation.ts +9 -9
- package/src/transport/dispatch/leases.ts +8 -8
- package/src/transport/dispatch/proxy-request.ts +38 -9
- package/src/transport/dispatch/responses-compact.ts +11 -11
- package/src/transport/dispatch/retry-policy.ts +4 -67
- package/src/transport/dispatch/stream-bridge.ts +1 -1
- package/src/transport/dispatch/streaming/finalize.ts +9 -8
- package/src/transport/dispatch/streaming/stream.ts +16 -1
- package/src/transport/dispatch/systemone.ts +8 -8
- package/src/transport/dispatch/token-count.ts +9 -9
- package/src/transport/dispatch/upstream.ts +10 -0
- package/src/transport/dispatch/websearch.ts +6 -6
- package/src/transport/drain-endpoint.ts +2 -2
- package/src/transport/failure-policy.ts +13 -6
- package/src/transport/guards/auth-guard.ts +6 -5
- package/src/transport/guards/body-policy.ts +4 -4
- package/src/transport/guards/error-lifecycle.ts +18 -3
- package/src/transport/guards/guard-errors.ts +7 -3
- package/src/transport/guards/headroom-guard.ts +1 -1
- package/src/transport/guards/pipeline.ts +6 -6
- package/src/transport/guards/readiness-guard.ts +16 -2
- package/src/transport/guards/request-context.ts +3 -3
- package/src/transport/request/preparer.ts +37 -21
- package/src/transport/request/state.ts +4 -7
- package/src/transport/request/system-prompt-injection.ts +89 -0
- package/src/transport/request/tenant-preferences.ts +43 -0
- package/src/transport/routing/admission.ts +13 -3
- package/src/transport/routing/route-catalog.ts +104 -2
- package/src/transport/routing/route-model.ts +60 -7
- package/src/transport/routing/router.ts +306 -256
- package/src/transport/shutdown-notice.ts +19 -7
- package/src/transport/surface/completion.ts +1 -1
- package/src/transport/surface/messages/encode.ts +1 -1
- package/src/workers/daily-checkin.ts +2 -0
- package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js +0 -1
- package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js +0 -1
- package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js +0 -1
- package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js +0 -1
- package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-BdmRLnq2.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-CqMxibJB.js +0 -1
- package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +0 -4
- package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js +0 -9
- package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js +0 -1
- package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js +0 -1
- package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-Co12Zzaw.js +0 -1
- package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-CTkLAuRc.js +0 -56
- package/dist/dashboard/assets/UsagePage-CTkLAuRc.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-CUi6UXsR.js +0 -1
- package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
- package/dist/dashboard/assets/app-KvsUJUMF.css +0 -2
- package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
- package/dist/dashboard/assets/assert-67xXH5rf.js +0 -1
- package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
- package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
- package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
- package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js +0 -1
- package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
- package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
- package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CFM2sMX1.js +0 -1
- package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
- package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
- package/dist/dashboard/assets/entry-ClCMCWWQ.js +0 -4
- package/dist/dashboard/assets/entry-ClCMCWWQ.js.gz +0 -0
- package/dist/dashboard/assets/entry-_xHo5x6r.js +0 -1
- package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
- package/dist/dashboard/assets/formatters-CWWZ-uCZ.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
- package/dist/dashboard/assets/index-DFVFrnWW.js.gz +0 -0
- package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
- package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
- package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
- package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
- package/dist/dashboard/assets/live-D0mm--ME.js +0 -1
- package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
- package/dist/dashboard/assets/minimize-2-D-MN8W8a.js +0 -1
- package/dist/dashboard/assets/minimize-2-D-MN8W8a.js.gz +0 -0
- package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
- package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
- package/dist/dashboard/assets/quota-DZGyp0N5.js +0 -1
- package/dist/dashboard/assets/quota-DZGyp0N5.js.gz +0 -0
- package/dist/dashboard/assets/routing-BnIpi1IJ.js +0 -1
- package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
- package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
- package/dist/dashboard/assets/settings-BAFhDgX8.js +0 -1
- package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
- package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
- package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
- package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
- /package/dist/dashboard/assets/{app-BaWd0puM.js → app-BayTTy01.js} +0 -0
- /package/dist/dashboard/assets/{app-BaWd0puM.js.gz → app-BayTTy01.js.gz} +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Owns validation, CRUD operations, and route wiring through the ApiKeyStore.
|
|
4
4
|
|
|
5
|
-
import { Elysia, t } from "elysia";
|
|
5
|
+
import { Elysia, t, type AnyElysia } from "elysia";
|
|
6
6
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
7
7
|
import { decryptCredentialToString, encryptCredential } from "../../security/crypto";
|
|
8
8
|
import { invalidateApiKeyCache } from "../../security/api-key-auth";
|
|
@@ -12,9 +12,16 @@ import type { AccessDecision } from "../../security/access-control";
|
|
|
12
12
|
import { ConsoleDomainError, errorResponse, requireTenantScope } from "../shared/errors";
|
|
13
13
|
import { literalUnion } from "../shared/elysia-schema";
|
|
14
14
|
import { API_KEY_MODES, API_KEY_MODEL_ACCESS_MODES, type ShareLinkKind } from "../../persistence/schema";
|
|
15
|
-
import type { ApiKeyConfig, ApiKeyResponse, UpdateApiKeyResponse } from "./contracts";
|
|
16
|
-
import type {
|
|
15
|
+
import type { ApiKeyConfig, ApiKeyResponse, BanApiKeyRequest, UpdateApiKeyResponse } from "./contracts";
|
|
16
|
+
import type {
|
|
17
|
+
ApiKeyPatch,
|
|
18
|
+
ApiKeyRecord,
|
|
19
|
+
SharedChildSortColumn,
|
|
20
|
+
SharedChildrenPageOptions,
|
|
21
|
+
} from "../../persistence/stores/api-key-store";
|
|
17
22
|
import {
|
|
23
|
+
BAN_DURATION_CHOICES,
|
|
24
|
+
DEFAULT_BAN_MINUTES,
|
|
18
25
|
generateApiKeySecret,
|
|
19
26
|
isDefaultGatewayApiKey,
|
|
20
27
|
parseSharePopupImage,
|
|
@@ -34,6 +41,39 @@ import {
|
|
|
34
41
|
import type { SharedKeyActivityDetail, SharedKeySummary } from "../share/share-usage";
|
|
35
42
|
import { popupImageBytes } from "./share-popup-image";
|
|
36
43
|
|
|
44
|
+
const SHARED_CHILD_SORT_COLUMNS = new Set<SharedChildSortColumn>([
|
|
45
|
+
"createdAt",
|
|
46
|
+
"label",
|
|
47
|
+
"keyPrefix",
|
|
48
|
+
"issuedClientIp",
|
|
49
|
+
"revokedAt",
|
|
50
|
+
"enabled",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/** Parses and bounds the paginated share-child list query. */
|
|
54
|
+
function parseSharedChildrenQuery(query: URLSearchParams): SharedChildrenPageOptions {
|
|
55
|
+
const limitRaw = query.get("limit");
|
|
56
|
+
const offsetRaw = query.get("offset");
|
|
57
|
+
const sortRaw = query.get("sort");
|
|
58
|
+
const orderRaw = query.get("order");
|
|
59
|
+
const statusRaw = query.get("status");
|
|
60
|
+
const limit = limitRaw === null ? 50 : Number(limitRaw);
|
|
61
|
+
const offset = offsetRaw === null ? 0 : Number(offsetRaw);
|
|
62
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
|
|
63
|
+
throw new ConsoleDomainError("invalid_request", 400, "limit must be an integer between 1 and 500");
|
|
64
|
+
}
|
|
65
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
66
|
+
throw new ConsoleDomainError("invalid_request", 400, "offset must be a non-negative integer");
|
|
67
|
+
}
|
|
68
|
+
const sortBy = (sortRaw ?? "createdAt") as SharedChildSortColumn;
|
|
69
|
+
if (!SHARED_CHILD_SORT_COLUMNS.has(sortBy)) {
|
|
70
|
+
throw new ConsoleDomainError("invalid_request", 400, `unsupported sort column: ${String(sortBy)}`);
|
|
71
|
+
}
|
|
72
|
+
const order = orderRaw === "asc" ? "asc" : "desc";
|
|
73
|
+
const status = statusRaw === "active" || statusRaw === "revoked" ? statusRaw : "all";
|
|
74
|
+
return { limit, offset, sortBy, order, status };
|
|
75
|
+
}
|
|
76
|
+
|
|
37
77
|
export function createApiKeyOperations(config: ApiKeyConfig) {
|
|
38
78
|
const operations = {
|
|
39
79
|
async listKeys(access: AccessDecision | undefined): Promise<ApiKeyResponse[]> {
|
|
@@ -44,18 +84,19 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
|
|
|
44
84
|
);
|
|
45
85
|
// A share template's own counter never moves for child traffic, so
|
|
46
86
|
// reporting it raw would show a template as untouched while the gateway
|
|
47
|
-
// is refusing its recipients on the parent budget. Report the figure
|
|
48
|
-
// gateway actually enforces: the template plus every
|
|
49
|
-
//
|
|
50
|
-
|
|
87
|
+
// is refusing its recipients on the parent budget. Report the figure
|
|
88
|
+
// the gateway actually enforces: the template's counter plus every
|
|
89
|
+
// telemetry total the family ever recorded — revoked/deleted children
|
|
90
|
+
// included (their spend is already spent), so the number survives
|
|
91
|
+
// cleanup and matches the share page.
|
|
51
92
|
return Promise.all(
|
|
52
93
|
topLevel.map(async (record) => {
|
|
53
94
|
const response = sanitizeApiKeyResponse(record);
|
|
54
|
-
if (record.keyMode !== "share"
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
95
|
+
if (record.keyMode !== "share") return response;
|
|
96
|
+
const family = await config.store
|
|
97
|
+
.sumFamilyTelemetryConsumed(authorized.tenantId, record.id)
|
|
98
|
+
.catch(() => 0);
|
|
99
|
+
return { ...response, tokensConsumed: response.tokensConsumed + family };
|
|
59
100
|
}),
|
|
60
101
|
);
|
|
61
102
|
},
|
|
@@ -146,6 +187,7 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
|
|
|
146
187
|
...(request.monthlyTokenLimit == null ? {} : { monthlyTokenLimit: request.monthlyTokenLimit }),
|
|
147
188
|
...(request.lifetimeTokenBudget == null ? {} : { lifetimeTokenBudget: request.lifetimeTokenBudget }),
|
|
148
189
|
...(request.maxConcurrentRequests == null ? {} : { maxConcurrentRequests: request.maxConcurrentRequests }),
|
|
190
|
+
...(request.maxChildren == null ? {} : { maxChildren: request.maxChildren }),
|
|
149
191
|
...(request.modelPrefix === undefined ? {} : { modelPrefix: request.modelPrefix }),
|
|
150
192
|
modelAccessMode: request.modelAccessMode ?? "whitelist",
|
|
151
193
|
...(request.modelList === undefined ? {} : { modelList: request.modelList }),
|
|
@@ -280,6 +322,7 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
|
|
|
280
322
|
...(patchRequest.monthlyTokenLimit === undefined ? {} : { monthlyTokenLimit: patchRequest.monthlyTokenLimit }),
|
|
281
323
|
...(patchRequest.lifetimeTokenBudget === undefined ? {} : { lifetimeTokenBudget: patchRequest.lifetimeTokenBudget }),
|
|
282
324
|
...(patchRequest.maxConcurrentRequests === undefined ? {} : { maxConcurrentRequests: patchRequest.maxConcurrentRequests }),
|
|
325
|
+
...(patchRequest.maxChildren === undefined ? {} : { maxChildren: patchRequest.maxChildren }),
|
|
283
326
|
...(patchRequest.modelPrefix === undefined ? {} : { modelPrefix: patchRequest.modelPrefix }),
|
|
284
327
|
...(patchRequest.modelAccessMode === undefined ? {} : { modelAccessMode: patchRequest.modelAccessMode }),
|
|
285
328
|
...(patchRequest.modelList === undefined ? {} : { modelList: patchRequest.modelList }),
|
|
@@ -376,7 +419,7 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
|
|
|
376
419
|
});
|
|
377
420
|
return { ...sanitizeApiKeyResponse(updated), ...(secret === undefined ? {} : { secret }) };
|
|
378
421
|
},
|
|
379
|
-
async
|
|
422
|
+
async deleteKey(access: AccessDecision | undefined, keyId: string): Promise<{ success: true }> {
|
|
380
423
|
const authorized = requireTenantScope(access, "dashboard:write");
|
|
381
424
|
const target = await config.store.get(authorized.tenantId, keyId);
|
|
382
425
|
if (!target)
|
|
@@ -401,7 +444,7 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
|
|
|
401
444
|
]);
|
|
402
445
|
await config.auditSink?.record({
|
|
403
446
|
access: authorized,
|
|
404
|
-
action: "api_key.
|
|
447
|
+
action: "api_key.deleted",
|
|
405
448
|
target: keyId,
|
|
406
449
|
});
|
|
407
450
|
return { success: true };
|
|
@@ -643,7 +686,14 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
|
|
|
643
686
|
async listSharedKeys(
|
|
644
687
|
access: AccessDecision | undefined,
|
|
645
688
|
parentKeyId: string,
|
|
646
|
-
|
|
689
|
+
query: URLSearchParams,
|
|
690
|
+
): Promise<{
|
|
691
|
+
rows: readonly SharedKeySummary[];
|
|
692
|
+
total: number;
|
|
693
|
+
active: number;
|
|
694
|
+
revoked: number;
|
|
695
|
+
consumed: number;
|
|
696
|
+
}> {
|
|
647
697
|
const authorized = requireTenantScope(access, "dashboard:read");
|
|
648
698
|
const activity = config.shareActivity;
|
|
649
699
|
if (!activity)
|
|
@@ -652,8 +702,14 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
|
|
|
652
702
|
if (!parent || parent.revokedAt !== undefined ||
|
|
653
703
|
parent.keyMode !== "share" || parent.parentKeyId !== undefined)
|
|
654
704
|
throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
|
|
655
|
-
const
|
|
656
|
-
|
|
705
|
+
const options = parseSharedChildrenQuery(query);
|
|
706
|
+
const page = await config.store.listChildrenPage(authorized.tenantId, parentKeyId, options);
|
|
707
|
+
const rows = await activity.getSharedKeySummaries(
|
|
708
|
+
authorized.tenantId,
|
|
709
|
+
page.rows,
|
|
710
|
+
parentKeyId,
|
|
711
|
+
);
|
|
712
|
+
return { rows, total: page.total, active: page.active, revoked: page.revoked, consumed: page.consumed };
|
|
657
713
|
},
|
|
658
714
|
async getSharedKeyActivity(
|
|
659
715
|
access: AccessDecision | undefined,
|
|
@@ -668,11 +724,37 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
|
|
|
668
724
|
if (!parent || parent.revokedAt !== undefined ||
|
|
669
725
|
parent.keyMode !== "share" || parent.parentKeyId !== undefined)
|
|
670
726
|
throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
|
|
671
|
-
|
|
672
|
-
|
|
727
|
+
// Membership is one indexed row read, not a scan of the whole family.
|
|
728
|
+
const child = await config.store.get(authorized.tenantId, childKeyId);
|
|
729
|
+
if (!child || child.parentKeyId !== parentKeyId)
|
|
673
730
|
throw new ConsoleDomainError("key_not_found", 404, "Shared API key not found");
|
|
674
731
|
return activity.getSharedKeyDetail(authorized.tenantId, childKeyId);
|
|
675
732
|
},
|
|
733
|
+
async revokeSharedScope(
|
|
734
|
+
access: AccessDecision | undefined,
|
|
735
|
+
parentKeyId: string,
|
|
736
|
+
scope: "active" | "unused",
|
|
737
|
+
): Promise<{ success: true; deleted: number }> {
|
|
738
|
+
const authorized = requireTenantScope(access, "dashboard:write");
|
|
739
|
+
const parent = await config.store.get(authorized.tenantId, parentKeyId);
|
|
740
|
+
if (!parent || parent.keyMode !== "share" || parent.parentKeyId !== undefined)
|
|
741
|
+
throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
|
|
742
|
+
const deleted = await config.store.revokeChildrenByScope(
|
|
743
|
+
authorized.tenantId,
|
|
744
|
+
parentKeyId,
|
|
745
|
+
scope,
|
|
746
|
+
);
|
|
747
|
+
// Raw batched deletes bypass the per-key purge path; drop the whole auth
|
|
748
|
+
// cache so no resolved identity survives for a removed recipient.
|
|
749
|
+
invalidateApiKeyCache();
|
|
750
|
+
await config.auditSink?.record({
|
|
751
|
+
access: authorized,
|
|
752
|
+
action: "api_key.share_revoked_scope",
|
|
753
|
+
target: parentKeyId,
|
|
754
|
+
detail: { scope, deleted },
|
|
755
|
+
});
|
|
756
|
+
return { success: true, deleted };
|
|
757
|
+
},
|
|
676
758
|
async revokeShare(
|
|
677
759
|
access: AccessDecision | undefined,
|
|
678
760
|
keyId: string,
|
|
@@ -695,6 +777,69 @@ export function createApiKeyOperations(config: ApiKeyConfig) {
|
|
|
695
777
|
});
|
|
696
778
|
return { success: true };
|
|
697
779
|
},
|
|
780
|
+
async banKey(
|
|
781
|
+
access: AccessDecision | undefined,
|
|
782
|
+
keyId: string,
|
|
783
|
+
request: BanApiKeyRequest,
|
|
784
|
+
): Promise<ApiKeyResponse> {
|
|
785
|
+
const authorized = requireTenantScope(access, "dashboard:write");
|
|
786
|
+
const reason = request.reason.trim();
|
|
787
|
+
if (reason.length === 0)
|
|
788
|
+
throw new ConsoleDomainError("invalid_request", 400, "reason is required");
|
|
789
|
+
// Parse the duration choice. `null` is permanent; absent defaults to one
|
|
790
|
+
// hour. Anything not on the offered list is rejected rather than clamped —
|
|
791
|
+
// clamping would answer a request for 90 days with a week and report
|
|
792
|
+
// success.
|
|
793
|
+
const chosenMinutes = request.minutes === undefined ? DEFAULT_BAN_MINUTES : request.minutes;
|
|
794
|
+
const allowed = new Set(BAN_DURATION_CHOICES.map((choice) => choice.minutes));
|
|
795
|
+
if (!allowed.has(chosenMinutes)) {
|
|
796
|
+
throw new ConsoleDomainError(
|
|
797
|
+
"invalid_request",
|
|
798
|
+
400,
|
|
799
|
+
"Unsupported ban duration. Choose one of the offered durations.",
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
const bannedUntil = chosenMinutes === null ? null : new Date(Date.now() + chosenMinutes * 60_000);
|
|
803
|
+
const current = await config.store.get(authorized.tenantId, keyId);
|
|
804
|
+
if (!current || current.revokedAt !== undefined)
|
|
805
|
+
throw new ConsoleDomainError("key_not_found", 404, "Key not found");
|
|
806
|
+
const banned = await config.store.ban(authorized.tenantId, keyId, reason, bannedUntil);
|
|
807
|
+
if (!banned) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
|
|
808
|
+
// A banned child key does not affect its share template or sibling keys:
|
|
809
|
+
// `store.ban` only turns off the one row named by `keyId`, and the auth
|
|
810
|
+
// path rejects per key hash. The template keeps issuing working children.
|
|
811
|
+
// A banned key must leave the auth cache for every process at once.
|
|
812
|
+
invalidateApiKeyCache();
|
|
813
|
+
await config.auditSink?.record({
|
|
814
|
+
access: authorized,
|
|
815
|
+
action: "api_key.banned",
|
|
816
|
+
target: keyId,
|
|
817
|
+
detail: { reason, ...(bannedUntil === null ? { permanent: true } : { bannedUntil: bannedUntil.toISOString() }) },
|
|
818
|
+
});
|
|
819
|
+
const record = await config.store.get(authorized.tenantId, keyId);
|
|
820
|
+
if (!record) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
|
|
821
|
+
return sanitizeApiKeyResponse(record);
|
|
822
|
+
},
|
|
823
|
+
async unbanKey(
|
|
824
|
+
access: AccessDecision | undefined,
|
|
825
|
+
keyId: string,
|
|
826
|
+
): Promise<ApiKeyResponse> {
|
|
827
|
+
const authorized = requireTenantScope(access, "dashboard:write");
|
|
828
|
+
const current = await config.store.get(authorized.tenantId, keyId);
|
|
829
|
+
if (!current || current.revokedAt !== undefined)
|
|
830
|
+
throw new ConsoleDomainError("key_not_found", 404, "Key not found");
|
|
831
|
+
const unbanned = await config.store.unban(authorized.tenantId, keyId);
|
|
832
|
+
if (!unbanned) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
|
|
833
|
+
invalidateApiKeyCache();
|
|
834
|
+
await config.auditSink?.record({
|
|
835
|
+
access: authorized,
|
|
836
|
+
action: "api_key.unbanned",
|
|
837
|
+
target: keyId,
|
|
838
|
+
});
|
|
839
|
+
const record = await config.store.get(authorized.tenantId, keyId);
|
|
840
|
+
if (!record) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
|
|
841
|
+
return sanitizeApiKeyResponse(record);
|
|
842
|
+
},
|
|
698
843
|
};
|
|
699
844
|
return operations;
|
|
700
845
|
}
|
|
@@ -750,7 +895,7 @@ function jsonNull(): Response {
|
|
|
750
895
|
});
|
|
751
896
|
}
|
|
752
897
|
/** Creates tenant-bound API-key routes with request-local authorization. */
|
|
753
|
-
export function createApiKeyRoutes(config: ApiKeyConfig):
|
|
898
|
+
export function createApiKeyRoutes(config: ApiKeyConfig): AnyElysia {
|
|
754
899
|
const factory = createApiKeyOperations(config);
|
|
755
900
|
return new Elysia({ prefix: "/api-keys" })
|
|
756
901
|
.get("/", async ({ request, set }) => {
|
|
@@ -798,7 +943,11 @@ export function createApiKeyRoutes(config: ApiKeyConfig): Elysia {
|
|
|
798
943
|
})
|
|
799
944
|
.get("/:keyId/shared-keys", async ({ request, params, set }) => {
|
|
800
945
|
try {
|
|
801
|
-
return await factory.listSharedKeys(
|
|
946
|
+
return await factory.listSharedKeys(
|
|
947
|
+
config.accessResolver(request),
|
|
948
|
+
params.keyId,
|
|
949
|
+
new URL(request.url).searchParams,
|
|
950
|
+
);
|
|
802
951
|
} catch (error) {
|
|
803
952
|
return errorResponse(error, set, "API-key operation failed");
|
|
804
953
|
}
|
|
@@ -814,6 +963,21 @@ export function createApiKeyRoutes(config: ApiKeyConfig): Elysia {
|
|
|
814
963
|
return errorResponse(error, set, "API-key operation failed");
|
|
815
964
|
}
|
|
816
965
|
})
|
|
966
|
+
.post(
|
|
967
|
+
"/:keyId/shared-keys/revoke-scope",
|
|
968
|
+
{ body: t.Object({ scope: t.Union([t.Literal("active"), t.Literal("unused")]) }) },
|
|
969
|
+
async ({ request, params, body, set }) => {
|
|
970
|
+
try {
|
|
971
|
+
return await factory.revokeSharedScope(
|
|
972
|
+
config.accessResolver(request),
|
|
973
|
+
params.keyId,
|
|
974
|
+
body.scope,
|
|
975
|
+
);
|
|
976
|
+
} catch (error) {
|
|
977
|
+
return errorResponse(error, set, "API-key operation failed");
|
|
978
|
+
}
|
|
979
|
+
},
|
|
980
|
+
)
|
|
817
981
|
.delete("/:keyId/shares/:shareId", async ({ request, params, set }) => {
|
|
818
982
|
try {
|
|
819
983
|
return await factory.revokeShare(
|
|
@@ -846,7 +1010,31 @@ export function createApiKeyRoutes(config: ApiKeyConfig): Elysia {
|
|
|
846
1010
|
})
|
|
847
1011
|
.delete("/:keyId", async ({ request, params, set }) => {
|
|
848
1012
|
try {
|
|
849
|
-
return await factory.
|
|
1013
|
+
return await factory.deleteKey(config.accessResolver(request), params.keyId);
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
return errorResponse(error, set, "API-key operation failed");
|
|
1016
|
+
}
|
|
1017
|
+
})
|
|
1018
|
+
.post(
|
|
1019
|
+
"/:keyId/ban",
|
|
1020
|
+
{
|
|
1021
|
+
body: t.Object({
|
|
1022
|
+
reason: t.String(),
|
|
1023
|
+
reasonLabel: t.Optional(t.String()),
|
|
1024
|
+
minutes: t.Optional(t.Union([t.Number({ minimum: 1 }), t.Null()])),
|
|
1025
|
+
}),
|
|
1026
|
+
},
|
|
1027
|
+
async ({ request, params, body, set }) => {
|
|
1028
|
+
try {
|
|
1029
|
+
return await factory.banKey(config.accessResolver(request), params.keyId, body);
|
|
1030
|
+
} catch (error) {
|
|
1031
|
+
return errorResponse(error, set, "API-key operation failed");
|
|
1032
|
+
}
|
|
1033
|
+
},
|
|
1034
|
+
)
|
|
1035
|
+
.post("/:keyId/unban", async ({ request, params, set }) => {
|
|
1036
|
+
try {
|
|
1037
|
+
return await factory.unbanKey(config.accessResolver(request), params.keyId);
|
|
850
1038
|
} catch (error) {
|
|
851
1039
|
return errorResponse(error, set, "API-key operation failed");
|
|
852
1040
|
}
|
|
@@ -889,5 +1077,5 @@ export function createApiKeyRoutes(config: ApiKeyConfig): Elysia {
|
|
|
889
1077
|
} catch (error) {
|
|
890
1078
|
return errorResponse(error, set, "API-key operation failed");
|
|
891
1079
|
}
|
|
892
|
-
})
|
|
1080
|
+
});
|
|
893
1081
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Elysia, t } from "elysia";
|
|
1
|
+
import { Elysia, t, type AnyElysia } from "elysia";
|
|
2
2
|
import { errorResponse, requireGlobalAdmin, requireTenantScope } from "../shared/errors";
|
|
3
3
|
import { parseQueryLimit } from "../shared/query";
|
|
4
4
|
import type { AuditRoutesConfig } from "./contracts";
|
|
@@ -10,7 +10,7 @@ const listQuery = t.Object({
|
|
|
10
10
|
actor: t.Optional(t.String()),
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
-
export function createAuditRoutes(config: AuditRoutesConfig):
|
|
13
|
+
export function createAuditRoutes(config: AuditRoutesConfig): AnyElysia {
|
|
14
14
|
return new Elysia().get("/audit", { query: listQuery }, async ({ request, query, set }) => {
|
|
15
15
|
try {
|
|
16
16
|
const resolved = config.accessResolver(request);
|
|
@@ -28,5 +28,5 @@ export function createAuditRoutes(config: AuditRoutesConfig): Elysia {
|
|
|
28
28
|
} catch (e) {
|
|
29
29
|
return errorResponse(e, set, "Audit read failed");
|
|
30
30
|
}
|
|
31
|
-
})
|
|
31
|
+
});
|
|
32
32
|
}
|
|
@@ -42,12 +42,12 @@ export function createConsoleAccountResetHandler(
|
|
|
42
42
|
return async ({ request, body }) => {
|
|
43
43
|
if (!isLoopbackPeer(deps.resolvePeerAddress(request)))
|
|
44
44
|
throw new GatewayError(
|
|
45
|
-
"
|
|
45
|
+
"policy_rejected",
|
|
46
46
|
403,
|
|
47
|
-
"console account reset is only available from loopback",
|
|
47
|
+
"console account reset is only available from loopback (request {requestId}). This operation is only allowed from a trusted loopback connection — no hard feelings 🫂",
|
|
48
48
|
);
|
|
49
49
|
if (!tokenMatches(request.headers.get("x-drain-token"), deps.token))
|
|
50
|
-
throw new GatewayError("
|
|
50
|
+
throw new GatewayError("authentication_failed", 401, "invalid reset token (request {requestId}). Wrong key, right door — check the token 🔑");
|
|
51
51
|
const confirm =
|
|
52
52
|
body !== null && typeof body === "object" && "confirm" in body
|
|
53
53
|
? body.confirm
|
|
@@ -56,7 +56,7 @@ export function createConsoleAccountResetHandler(
|
|
|
56
56
|
throw new GatewayError(
|
|
57
57
|
"invalid_request",
|
|
58
58
|
400,
|
|
59
|
-
`send {"confirm":"${CONFIRMATION}"} to confirm; this deletes every console account
|
|
59
|
+
`send {"confirm":"${CONFIRMATION}"} to confirm (request {requestId}); this deletes every console account. Make sure you mean it — there's no undo 🫂`,
|
|
60
60
|
);
|
|
61
61
|
|
|
62
62
|
const removed = await deps.resetConsoleAccount();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { asCookie, clearCsrfCookie, newCsrfToken, setCsrfCookie } from "../../security/csrf";
|
|
2
|
-
import { Elysia, t } from "elysia";
|
|
2
|
+
import { Elysia, t, type AnyElysia } from "elysia";
|
|
3
3
|
import { eq } from "drizzle-orm";
|
|
4
4
|
import { isRecord } from "../../protocol/codecs";
|
|
5
5
|
import { userRecord, returningRows, assertMutationApplied } from "./row-coercion";
|
|
@@ -217,7 +217,7 @@ export function createConsoleAuthRoutes(
|
|
|
217
217
|
cookiePolicy: SessionCookiePolicy = defaultSessionCookiePolicy,
|
|
218
218
|
trustedProxyBoundary: TrustedProxyBoundary = { mode: "disabled" },
|
|
219
219
|
resolvePeerAddress: (request: Request) => string | undefined = () => undefined,
|
|
220
|
-
):
|
|
220
|
+
): AnyElysia {
|
|
221
221
|
const app = new Elysia({ prefix: "/auth" });
|
|
222
222
|
const policy = cookiePolicy;
|
|
223
223
|
const database = db;
|
|
@@ -532,5 +532,5 @@ export function createConsoleAuthRoutes(
|
|
|
532
532
|
},
|
|
533
533
|
);
|
|
534
534
|
|
|
535
|
-
return app
|
|
535
|
+
return app;
|
|
536
536
|
}
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
poolRoutingSettings,
|
|
34
34
|
providerAccounts,
|
|
35
35
|
providerOauthStates,
|
|
36
|
+
providerPoolAccounts,
|
|
36
37
|
providerRoutingSettings,
|
|
37
38
|
providers,
|
|
38
39
|
shareLinks,
|
|
@@ -96,6 +97,7 @@ export const CONFIG_TABLES = [
|
|
|
96
97
|
models,
|
|
97
98
|
providerRoutingSettings,
|
|
98
99
|
providerAccounts,
|
|
100
|
+
providerPoolAccounts,
|
|
99
101
|
poolRoutingSettings,
|
|
100
102
|
networkPools,
|
|
101
103
|
providers,
|
|
@@ -24,7 +24,7 @@ import { join } from "node:path";
|
|
|
24
24
|
import { GatewayError } from "../../protocol/gateway-error";
|
|
25
25
|
import { isLoopbackPeer, tokenMatches } from "../../transport/drain-endpoint";
|
|
26
26
|
import { BACKUP_SECTIONS, tablesForSection } from "./contracts";
|
|
27
|
-
import {
|
|
27
|
+
import { compressBackupStream, exportBackupStream } from "./store";
|
|
28
28
|
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
29
29
|
|
|
30
30
|
export interface BackupExportDeps {
|
|
@@ -52,31 +52,28 @@ export function createBackupExportHandler(
|
|
|
52
52
|
// came from, the second is the credential it carried — and `invalid_request`
|
|
53
53
|
// told an operator to fix a body that was never the problem.
|
|
54
54
|
if (!isLoopbackPeer(deps.resolvePeerAddress(request)))
|
|
55
|
-
throw new GatewayError("policy_rejected", 403, "backup export is only available from loopback");
|
|
55
|
+
throw new GatewayError("policy_rejected", 403, "backup export is only available from loopback (request {requestId}). This operation is only allowed from a trusted loopback connection — no hard feelings 🫂");
|
|
56
56
|
if (!tokenMatches(request.headers.get("x-drain-token"), deps.token))
|
|
57
|
-
throw new GatewayError("authentication_failed", 401, "invalid export token");
|
|
57
|
+
throw new GatewayError("authentication_failed", 401, "invalid export token (request {requestId}). Wrong key, right door — check the token 🔑");
|
|
58
58
|
|
|
59
59
|
const directory =
|
|
60
60
|
body !== null && typeof body === "object" && "directory" in body
|
|
61
61
|
? body.directory
|
|
62
62
|
: undefined;
|
|
63
63
|
if (directory !== undefined && typeof directory !== "string")
|
|
64
|
-
throw new GatewayError("invalid_request", 400, "directory must be a string");
|
|
64
|
+
throw new GatewayError("invalid_request", 400, "directory must be a string (request {requestId}). Check the shape of what you sent — then resend 🪞");
|
|
65
65
|
|
|
66
66
|
const tenantId = await deps.resolveTenantId();
|
|
67
67
|
if (tenantId === null)
|
|
68
|
-
throw new GatewayError("invalid_request", 409, "no tenant exists; run setup first");
|
|
68
|
+
throw new GatewayError("invalid_request", 409, "no tenant exists; run setup first (request {requestId}). Nothing here but dust — run setup first 🌙");
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
// backup are the same file format and either can be restored by the other.
|
|
78
|
-
const json = JSON.stringify(payload);
|
|
79
|
-
const gzipped = Bun.gzipSync(new TextEncoder().encode(json));
|
|
70
|
+
// One gzip pass over the streamed document: a tenant with hundreds of
|
|
71
|
+
// thousands of rows is read page by page and only the compressed bytes
|
|
72
|
+
// land in the buffer — the same file format a console backup produces, so
|
|
73
|
+
// a CLI backup and a dashboard backup remain restorable by the other.
|
|
74
|
+
const stream = await exportBackupStream(deps.db, tablesForSection("config"), tenantId);
|
|
75
|
+
const gzipped = await compressBackupStream(stream.jsonChunks);
|
|
76
|
+
const counts = await stream.counts;
|
|
80
77
|
const target = directory ?? downloadDirectory();
|
|
81
78
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
82
79
|
const path = join(target, `cartethyia-backup-${stamp}.json.gz`);
|
|
@@ -86,9 +83,9 @@ export function createBackupExportHandler(
|
|
|
86
83
|
writeFileSync(path, gzipped);
|
|
87
84
|
} catch (error) {
|
|
88
85
|
throw new GatewayError(
|
|
89
|
-
"
|
|
86
|
+
"internal_error",
|
|
90
87
|
500,
|
|
91
|
-
`could not write ${path}: ${error instanceof Error ? error.message : "unknown error"}
|
|
88
|
+
`could not write ${path}: ${error instanceof Error ? error.message : "unknown error"} (request {requestId}). Even we have bad days — retry, and quote the request id if it persists 🐻`,
|
|
92
89
|
);
|
|
93
90
|
}
|
|
94
91
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* The body bound is enforced here, before the JSON parse, because the whole
|
|
15
15
|
* point of the limit is to not buffer an unbounded file.
|
|
16
16
|
*/
|
|
17
|
-
import { Elysia, t } from "elysia";
|
|
17
|
+
import { Elysia, t, type AnyElysia } from "elysia";
|
|
18
18
|
import type { ConsoleAccessResolver } from "../auth/access";
|
|
19
19
|
import { invalidateApiKeyCache } from "../../security/api-key-auth";
|
|
20
20
|
import { invalidatePublicModels } from "../providers/catalog/public-model-store";
|
|
@@ -22,7 +22,7 @@ import { ConsoleDomainError, errorResponse, requireAnyScope } from "../shared/er
|
|
|
22
22
|
import { NO_COMPRESS_HEADER } from "../response-compression";
|
|
23
23
|
import { MAX_BACKUP_BYTES } from "./contracts";
|
|
24
24
|
import type { BackupSection } from "./contracts";
|
|
25
|
-
import { DELETE_ALL_SCOPES,
|
|
25
|
+
import { DELETE_ALL_SCOPES, compressBackupStream } from "./store";
|
|
26
26
|
import type { BackupService } from "./service";
|
|
27
27
|
import type { AuditRecorder } from "../auth/audit-recorder";
|
|
28
28
|
|
|
@@ -48,7 +48,7 @@ export interface BackupRoutesConfig {
|
|
|
48
48
|
/**
|
|
49
49
|
* The tenant's API keys, read after a restore so their admission counters can
|
|
50
50
|
* be purged. A restore replaces `api_keys` wholesale, so a key that was
|
|
51
|
-
* revoked after the backup — and had its counters purged by `
|
|
51
|
+
* revoked after the backup — and had its counters purged by `deleteKey` — can
|
|
52
52
|
* come back with `revoked_at = NULL`; without purging, its lifetime counter
|
|
53
53
|
* is re-seeded from the restored total, granting a fresh full budget on top
|
|
54
54
|
* of everything already spent. Optional so a host that never restores keys
|
|
@@ -118,7 +118,28 @@ function parseSections(raw: string | undefined): readonly BackupSection[] | unde
|
|
|
118
118
|
.filter((part) => part.length > 0) as readonly BackupSection[];
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Streams the export document into the response body as it is written; one
|
|
123
|
+
* row-page of the tenant's data is in memory at a time.
|
|
124
|
+
*/
|
|
125
|
+
function payloadDocument(chunks: AsyncGenerator<string, void, void>): ReadableStream<Uint8Array> {
|
|
126
|
+
const encoder = new TextEncoder();
|
|
127
|
+
return new ReadableStream<Uint8Array>({
|
|
128
|
+
async pull(controller) {
|
|
129
|
+
const next = await chunks.next();
|
|
130
|
+
if (next.done) {
|
|
131
|
+
controller.close();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
controller.enqueue(encoder.encode(next.value));
|
|
135
|
+
},
|
|
136
|
+
async cancel() {
|
|
137
|
+
await chunks.return();
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function createBackupRoutes(config: BackupRoutesConfig): AnyElysia {
|
|
122
143
|
return new Elysia({ prefix: "/backup" })
|
|
123
144
|
.get("/export", { query: exportQuery }, async ({ request, query, set }) => {
|
|
124
145
|
try {
|
|
@@ -129,7 +150,7 @@ export function createBackupRoutes(config: BackupRoutesConfig): Elysia {
|
|
|
129
150
|
const backup = config.backupFor(request);
|
|
130
151
|
const sections = parseSections(query.sections);
|
|
131
152
|
const compressed = query.compressed === "1";
|
|
132
|
-
const
|
|
153
|
+
const exportStream = await backup.export({
|
|
133
154
|
password: query.password,
|
|
134
155
|
tenantId: access.tenantId,
|
|
135
156
|
compressed,
|
|
@@ -138,11 +159,22 @@ export function createBackupRoutes(config: BackupRoutesConfig): Elysia {
|
|
|
138
159
|
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
|
139
160
|
set.headers["cache-control"] = "no-store";
|
|
140
161
|
if (!compressed) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
162
|
+
// The document is written through the stream, so a tenant with
|
|
163
|
+
// hundreds of thousands of rows is serialized here while only one
|
|
164
|
+
// row-page of it is ever in memory. The headers live on the
|
|
165
|
+
// Response itself: a native Response carries the stream, and the
|
|
166
|
+
// response wrapper would otherwise buffer it whole to gzip it —
|
|
167
|
+
// the same trade its ceiling path already made for large exports
|
|
168
|
+
// (rebuilt, uncompressed) — so this branch opts out of it.
|
|
169
|
+
const headers = new Headers({
|
|
170
|
+
"cache-control": "no-store",
|
|
171
|
+
"content-disposition": `attachment; filename="cartethyia_backup_${stamp}.json"`,
|
|
172
|
+
"content-type": "application/json; charset=utf-8",
|
|
173
|
+
[NO_COMPRESS_HEADER]: "1",
|
|
174
|
+
});
|
|
175
|
+
return new Response(payloadDocument(exportStream.jsonChunks), { headers });
|
|
144
176
|
}
|
|
145
|
-
const bytes =
|
|
177
|
+
const bytes = await compressBackupStream(exportStream.jsonChunks);
|
|
146
178
|
set.headers["content-type"] = "application/json; charset=utf-8";
|
|
147
179
|
// Opt out of the response wrapper's gzip rather than declaring
|
|
148
180
|
// `content-encoding: gzip`: the bytes here are base64 of a gzip
|
|
@@ -193,7 +225,7 @@ export function createBackupRoutes(config: BackupRoutesConfig): Elysia {
|
|
|
193
225
|
// snapshot must be rebuilt before the next `/v1/*` dispatch.
|
|
194
226
|
await config.snapshotInvalidator?.invalidate();
|
|
195
227
|
// A restore can re-insert keys the tenant revoked after the backup, and
|
|
196
|
-
// `
|
|
228
|
+
// `deleteKey` purged their counters when they went away. Drop the whole
|
|
197
229
|
// auth cache (the restore is rare, and a stale entry for any key must
|
|
198
230
|
// not survive it) and purge admission state for every key the tenant
|
|
199
231
|
// now has, so a resurrected key cannot start with a fresh budget.
|
|
@@ -272,5 +304,5 @@ export function createBackupRoutes(config: BackupRoutesConfig): Elysia {
|
|
|
272
304
|
} catch (error) {
|
|
273
305
|
return errorResponse(error, set, "Backup inspection failed");
|
|
274
306
|
}
|
|
275
|
-
})
|
|
307
|
+
});
|
|
276
308
|
}
|
|
@@ -40,7 +40,8 @@ import {
|
|
|
40
40
|
applyRestore,
|
|
41
41
|
decompressBackup,
|
|
42
42
|
deleteAll,
|
|
43
|
-
|
|
43
|
+
exportBackupStream,
|
|
44
|
+
type BackupExportStream,
|
|
44
45
|
type DeleteAllResult,
|
|
45
46
|
type DeleteAllScope,
|
|
46
47
|
} from "./store";
|
|
@@ -269,17 +270,16 @@ async function omitRowsForUnavailableProviders(
|
|
|
269
270
|
export class BackupService {
|
|
270
271
|
constructor(private readonly options: BackupServiceOptions) {}
|
|
271
272
|
|
|
272
|
-
async export(options: ExportOptions): Promise<
|
|
273
|
+
async export(options: ExportOptions): Promise<BackupExportStream> {
|
|
273
274
|
if (!(await this.options.verifyPassword(options.password))) {
|
|
274
275
|
throw new ConsoleDomainError("unauthorized", 401, "password is incorrect");
|
|
275
276
|
}
|
|
276
277
|
const sections = sectionsOrDefault(options.sections);
|
|
277
|
-
|
|
278
|
+
return exportBackupStream(
|
|
278
279
|
this.options.db,
|
|
279
280
|
sections.flatMap((section) => tablesForSection(section)),
|
|
280
281
|
options.tenantId,
|
|
281
282
|
);
|
|
282
|
-
return { payload, counts };
|
|
283
283
|
}
|
|
284
284
|
|
|
285
285
|
/**
|
|
@@ -453,11 +453,16 @@ export class BackupService {
|
|
|
453
453
|
sections: readonly BackupSection[] | undefined,
|
|
454
454
|
tenantId: string,
|
|
455
455
|
): Promise<Record<string, number>> {
|
|
456
|
-
const
|
|
456
|
+
const stream = exportBackupStream(
|
|
457
457
|
this.options.db,
|
|
458
458
|
sectionsOrDefault(sections).flatMap((section) => tablesForSection(section)),
|
|
459
459
|
tenantId,
|
|
460
460
|
);
|
|
461
|
-
|
|
461
|
+
// Walk the stream so the tally settles, discarding the bytes: a preview
|
|
462
|
+
// must not hold the payload either.
|
|
463
|
+
for await (const _chunk of stream.jsonChunks) {
|
|
464
|
+
// discard
|
|
465
|
+
}
|
|
466
|
+
return stream.counts;
|
|
462
467
|
}
|
|
463
468
|
}
|