cartethyia 2.7.4 → 2.7.5
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/README.md +17 -5
- package/dist/dashboard/assets/{BannedPage-ChG5svFb.js → BannedPage-Cj15GsMp.js} +1 -1
- package/dist/dashboard/assets/BannedPage-Cj15GsMp.js.gz +0 -0
- package/dist/dashboard/assets/ClaimGiftsButton-D83pBJ6J.js +1 -0
- package/dist/dashboard/assets/ClaimGiftsButton-D83pBJ6J.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-BIcWQun7.js → CliToolDetailPage-DZKmVuJN.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DZKmVuJN.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-Bf2VelDe.js → CliToolsPage-BIccZmeL.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-BIccZmeL.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-DEEKkYD-.js +1 -0
- package/dist/dashboard/assets/CombosPage-DEEKkYD-.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-D5rol2j2.js +1 -0
- package/dist/dashboard/assets/ConsoleLogPage-D5rol2j2.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-ggeMSjte.js → CustomizationPage-BKj_r0rp.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-BKj_r0rp.js.gz +0 -0
- package/dist/dashboard/assets/InjectSystemPromptPanel-B5nZvmf0.js +656 -0
- package/dist/dashboard/assets/InjectSystemPromptPanel-B5nZvmf0.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-U6AKaJcF.js → LoginPage-DkXb6dx-.js} +1 -1
- package/dist/dashboard/assets/LoginPage-DkXb6dx-.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-BMS2t8BA.js → ModelPicker-DnQPSIA8.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-DnQPSIA8.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-Ci2wQKUz.js +1 -0
- package/dist/dashboard/assets/OverviewPage-Ci2wQKUz.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-odlgYrOg.js +5 -0
- package/dist/dashboard/assets/ProviderDetailPage-odlgYrOg.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-EfeloBn4.js +1 -0
- package/dist/dashboard/assets/ProviderIcon-EfeloBn4.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-CqFBQEB8.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-CqFBQEB8.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-lsrx7NYg.js → ProxyPage-5SnUSy3j.js} +1 -1
- package/dist/dashboard/assets/ProxyPage-5SnUSy3j.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-CmnMDzhP.js +1 -0
- package/dist/dashboard/assets/QuotaPage-CmnMDzhP.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-BJtRZ4qD.js +1 -0
- package/dist/dashboard/assets/SettingsPage-BJtRZ4qD.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-f0DE1h0-.js → SetupPage-WTa0sktC.js} +1 -1
- package/dist/dashboard/assets/SetupPage-WTa0sktC.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-Cq5tW7hW.js → StudioPage-BfAb5-o_.js} +3 -3
- package/dist/dashboard/assets/StudioPage-BfAb5-o_.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-BNzeZ2Vk.js +56 -0
- package/dist/dashboard/assets/UsagePage-BNzeZ2Vk.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-l29JROPv.js +1 -0
- package/dist/dashboard/assets/api-keys-l29JROPv.js.gz +0 -0
- package/dist/dashboard/assets/app--PJWq8_C.css +2 -0
- package/dist/dashboard/assets/app--PJWq8_C.css.gz +0 -0
- package/dist/dashboard/assets/assert-C0K-c4eo.js +1 -0
- package/dist/dashboard/assets/assert-C0K-c4eo.js.gz +0 -0
- package/dist/dashboard/assets/{badge-D9CS6dZk.js → badge-DfbhQU4k.js} +1 -1
- package/dist/dashboard/assets/badge-DfbhQU4k.js.gz +0 -0
- package/dist/dashboard/assets/{banner-DympBNq4.js → banner-BllT4LcF.js} +1 -1
- package/dist/dashboard/assets/banner-BllT4LcF.js.gz +0 -0
- package/dist/dashboard/assets/brand/login_register.jpg +0 -0
- package/dist/dashboard/assets/brand/login_register_initial.jpg +0 -0
- package/dist/dashboard/assets/brand/og_bansos.jpg +0 -0
- package/dist/dashboard/assets/brand/og_image.jpg +0 -0
- package/dist/dashboard/assets/{card-ibRWU_uT.js → card-CtkX66Wd.js} +1 -1
- package/dist/dashboard/assets/card-CtkX66Wd.js.gz +0 -0
- package/dist/dashboard/assets/chevron-left-Bf6CaTSJ.js +1 -0
- package/dist/dashboard/assets/chevron-left-Bf6CaTSJ.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-CQXVMn4P.js +1 -0
- package/dist/dashboard/assets/cli-tools-CQXVMn4P.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-DJm-9pz7.js → clipboard-button-BQqBQd2n.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-BQqBQd2n.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-8fiE6tec.js → dialog-BEK7KP0H.js} +1 -1
- package/dist/dashboard/assets/dialog-BEK7KP0H.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-CqGYeOrd.js → drawer-BrTEZ8zu.js} +1 -1
- package/dist/dashboard/assets/drawer-BrTEZ8zu.js.gz +0 -0
- package/dist/dashboard/assets/entry-CHWxbS56.js +1 -0
- package/dist/dashboard/assets/entry-CHWxbS56.js.gz +0 -0
- package/dist/dashboard/assets/{entry-BSAsYsmV.js → entry-EBHJ3DrE.js} +1 -1
- package/dist/dashboard/assets/entry-EBHJ3DrE.js.gz +0 -0
- package/dist/dashboard/assets/entry-LCS85hiq.js +4 -0
- package/dist/dashboard/assets/entry-LCS85hiq.js.gz +0 -0
- package/dist/dashboard/assets/{formatters-D-MTJxMr.js → formatters-BAACA727.js} +1 -1
- package/dist/dashboard/assets/formatters-BAACA727.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-CPK47xQx.js → github-badge-YSCE4UC2.js} +1 -1
- package/dist/dashboard/assets/github-badge-YSCE4UC2.js.gz +0 -0
- package/dist/dashboard/assets/{index-DBNPOfhV.js → index-FQlj0HUT.js} +2 -2
- package/dist/dashboard/assets/index-FQlj0HUT.js.gz +0 -0
- package/dist/dashboard/assets/{inline-BT0zntEr.js → inline-C4gU5iOL.js} +1 -1
- package/dist/dashboard/assets/inline-C4gU5iOL.js.gz +0 -0
- package/dist/dashboard/assets/{input-Da2ZViWg.js → input-BHHX_Tmm.js} +1 -1
- package/dist/dashboard/assets/input-BHHX_Tmm.js.gz +0 -0
- package/dist/dashboard/assets/{layout-BXNeLw6M.js → layout-CajrYUnD.js} +1 -1
- package/dist/dashboard/assets/layout-CajrYUnD.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-BycOrrIV.js → list-row-Bx2I7XUe.js} +1 -1
- package/dist/dashboard/assets/list-row-Bx2I7XUe.js.gz +0 -0
- package/dist/dashboard/assets/live-DNNqRbj2.js +1 -0
- package/dist/dashboard/assets/live-DNNqRbj2.js.gz +0 -0
- package/dist/dashboard/assets/network-C5UBACrJ.js +1 -0
- package/dist/dashboard/assets/network-C5UBACrJ.js.gz +0 -0
- package/dist/dashboard/assets/numbers-CUWAZK4J.js +2 -0
- package/dist/dashboard/assets/numbers-CUWAZK4J.js.gz +0 -0
- package/dist/dashboard/assets/pagination-Xsi9Qx04.js +1 -0
- package/dist/dashboard/assets/pagination-Xsi9Qx04.js.gz +0 -0
- package/dist/dashboard/assets/quota-DvQbD_sr.js +1 -0
- package/dist/dashboard/assets/quota-DvQbD_sr.js.gz +0 -0
- package/dist/dashboard/assets/routing-in0f_IfK.js +1 -0
- package/dist/dashboard/assets/routing-in0f_IfK.js.gz +0 -0
- package/dist/dashboard/assets/{select-BIYIBkMU.js → select-BLULNqTp.js} +1 -1
- package/dist/dashboard/assets/select-BLULNqTp.js.gz +0 -0
- package/dist/dashboard/assets/settings-CcGczj6I.js +1 -0
- package/dist/dashboard/assets/settings-CcGczj6I.js.gz +0 -0
- package/dist/dashboard/assets/shield-BMQNxORh.js +1 -0
- package/dist/dashboard/assets/shield-BMQNxORh.js.gz +0 -0
- package/dist/dashboard/assets/{stack-DfgnFDWY.js → stack-D2lIR0tg.js} +1 -1
- package/dist/dashboard/assets/stack-D2lIR0tg.js.gz +0 -0
- package/dist/dashboard/assets/{state-CkfdYWel.js → state-DTARjfiW.js} +1 -1
- package/dist/dashboard/assets/state-DTARjfiW.js.gz +0 -0
- package/dist/dashboard/assets/{switch-SYm237HG.js → switch-c0oPSnqW.js} +1 -1
- package/dist/dashboard/assets/switch-c0oPSnqW.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-BQqCRNIi.js → useMutation-m8WGNPrz.js} +1 -1
- package/dist/dashboard/assets/useMutation-m8WGNPrz.js.gz +0 -0
- package/dist/dashboard/console-backgrounds.jpg +0 -0
- package/dist/dashboard/console-backgrounds_initial.jpg +0 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +56 -55
- package/migrations/full-postgres/0036_system_prompt_presets_refs.sql +10 -0
- package/migrations/full-postgres/0037_share_concurrency_scope.sql +3 -0
- package/migrations/full-postgres/0038_add_accounts_quota_snapshot.sql +7 -0
- package/migrations/full-postgres/0039_add_telemetry_provider_model_indexes.sql +13 -0
- package/migrations/full-postgres/0040_add_models_dialect.sql +3 -0
- package/migrations/full-postgres/0041_add_daily_checkin_ledger_account_fk.sql +7 -0
- package/migrations/lite-sqlite3/0036_system_prompt_presets_refs.sql +14 -0
- package/migrations/lite-sqlite3/0037_share_concurrency_scope.sql +3 -0
- package/migrations/lite-sqlite3/0038_add_accounts_quota_snapshot.sql +4 -0
- package/migrations/lite-sqlite3/0039_add_telemetry_provider_model_indexes.sql +6 -0
- package/migrations/lite-sqlite3/0040_add_models_dialect.sql +3 -0
- package/migrations/lite-sqlite3/0041_add_daily_checkin_ledger_account_fk.sql +26 -0
- package/package.json +4 -6
- package/scripts/cli/update.ts +15 -41
- package/src/boot/app.ts +58 -2
- package/src/boot/dependencies.ts +786 -732
- package/src/boot/lifecycle.ts +11 -12
- package/src/config/cooldowns.ts +34 -55
- package/src/config/network.ts +1 -1
- package/src/config/package-paths.ts +1 -1
- package/src/config/providers.ts +13 -14
- package/src/config/resources.ts +0 -5
- package/src/config/runtime.ts +2 -5
- package/src/config/spec.ts +26 -6
- package/src/console/api-keys/ban-constants.ts +38 -0
- package/src/console/api-keys/contracts.ts +71 -62
- package/src/console/api-keys/routes.ts +48 -12
- package/src/console/auth/access.ts +4 -4
- package/src/console/auth/account-reset.ts +1 -1
- package/src/console/auth/audit-recorder.ts +1 -1
- package/src/console/auth/lockout.ts +7 -3
- package/src/console/auth/row-coercion.ts +57 -12
- package/src/console/auth/session-resolver.ts +1 -1
- package/src/console/auth/session-service.ts +8 -2
- package/src/console/auth/session.ts +37 -31
- package/src/console/auth/setup-service.ts +4 -3
- package/src/console/auth/user-store.ts +1 -1
- package/src/console/backup/9router-map.ts +0 -3
- package/src/console/backup/contracts.ts +9 -9
- package/src/console/backup/export-endpoint.ts +5 -6
- package/src/console/backup/nine-router.ts +1 -7
- package/src/console/backup/routes.ts +29 -33
- package/src/console/backup/service.ts +468 -468
- package/src/console/backup/store.ts +47 -29
- package/src/console/backup/validate.ts +11 -1
- package/src/console/cli-tools/contracts.ts +7 -7
- package/src/console/cli-tools/injectors/cline.ts +110 -110
- package/src/console/cli-tools/injectors/codex.ts +181 -181
- package/src/console/cli-tools/injectors/copilot.ts +108 -108
- package/src/console/cli-tools/injectors/driver.ts +247 -247
- package/src/console/cli-tools/injectors/jcode.ts +115 -115
- package/src/console/cli-tools/injectors/openclaw.ts +161 -161
- package/src/console/cli-tools/injectors/opencode.ts +153 -153
- package/src/console/cli-tools/routes.ts +1 -1
- package/src/console/cli-tools/service.ts +1 -1
- package/src/console/cli-tools/store.ts +2 -2
- package/src/console/console-router.ts +32 -0
- package/src/console/dashboard-assets.ts +9 -9
- package/src/console/domain-registration.ts +16 -2
- package/src/console/inferhub/client.ts +0 -7
- package/src/console/inferhub/key-store.ts +1 -1
- package/src/console/inferhub/normalize.ts +2 -2
- package/src/console/inferhub/routes.ts +1 -1
- package/src/console/inferhub/types.ts +1 -1
- package/src/console/observability/contracts.ts +1 -1
- package/src/console/observability/live.ts +31 -18
- package/src/console/observability/logs.ts +1 -1
- package/src/console/observability/sse.ts +62 -8
- package/src/console/observability/store.ts +20 -4
- package/src/console/providers/catalog/catalog-projections.ts +1 -1
- package/src/console/providers/catalog/contracts.ts +39 -0
- package/src/console/providers/catalog/model-operations.ts +1 -1
- package/src/console/providers/catalog/provider-operations.ts +95 -17
- package/src/console/providers/catalog/public-model-store.ts +0 -0
- package/src/console/providers/catalog/routes.ts +27 -3
- package/src/console/providers/catalog/store.ts +147 -8
- package/src/console/providers/detail/routes.ts +1 -1
- package/src/console/providers/oauth/callback-listener.ts +100 -18
- package/src/console/providers/oauth/routes.ts +92 -13
- package/src/console/quota/account-quota-global.ts +41 -3
- package/src/console/quota/account-quota-shared.ts +10 -3
- package/src/console/quota/account-quota-tenant.ts +259 -66
- package/src/console/quota/daily-growth-job.ts +308 -0
- package/src/console/quota/native-identity.ts +2 -2
- package/src/console/quota/quota-view.ts +1 -1
- package/src/console/remote-support/routes.ts +1 -1
- package/src/console/remote-support/service.ts +2 -2
- package/src/console/routing/model/contracts.ts +1 -1
- package/src/console/routing/pools/contracts.ts +2 -2
- package/src/console/routing/pools/import-parse.ts +3 -3
- package/src/console/routing/pools/relay-deploy.ts +3 -3
- package/src/console/settings/contracts.ts +29 -2
- package/src/console/settings/store.ts +20 -0
- package/src/console/share/share-router.ts +100 -32
- package/src/console/share/share-stats.ts +4 -4
- package/src/console/share/share-usage.ts +1 -1
- package/src/console/shared/errors.ts +2 -2
- package/src/console/shared/object.ts +18 -0
- package/src/console/shared/read-through-cache.ts +4 -4
- package/src/console/shared/transaction.ts +14 -0
- package/src/console/system-prompt/contracts.ts +89 -12
- package/src/console/system-prompt/routes.ts +98 -45
- package/src/console/updates/apply-update.ts +244 -0
- package/src/console/updates/routes.ts +252 -97
- package/src/main.ts +11 -5
- package/src/network/http2-bidi.ts +1 -1
- package/src/network/ip-utils.ts +298 -0
- package/src/network/outbound-fetch.ts +1 -1
- package/src/network/pool/agent.ts +7 -6
- package/src/network/pool/byte-accounting.ts +1 -1
- package/src/network/pool/resolver.ts +16 -12
- package/src/network/pool/selector.ts +150 -136
- package/src/network/pool-health-machine.ts +1 -1
- package/src/network/rate-limit-verdict.ts +1 -1
- package/src/network/retry.ts +1 -1
- package/src/network/ssrf.ts +20 -71
- package/src/observability/event-log.ts +2 -2
- package/src/observability/event-query.ts +59 -291
- package/src/observability/log-sanitize.ts +1 -1
- package/src/observability/metrics.ts +4 -4
- package/src/observability/payload-capture.ts +6 -5
- package/src/observability/payload-store.ts +1 -1
- package/src/observability/runtime-metrics.ts +1 -1
- package/src/observability/telemetry-buffer.ts +12 -3
- package/src/observability/telemetry-reconciliation.ts +3 -3
- package/src/observability/token-speed.ts +1 -1
- package/src/persistence/connection-url.ts +1 -1
- package/src/persistence/db-handle.ts +1 -1
- package/src/persistence/db-pg.ts +2 -2
- package/src/persistence/db-sqlite.ts +3 -3
- package/src/persistence/dialect.ts +5 -1
- package/src/persistence/readiness.ts +1 -1
- package/src/persistence/redis.ts +1 -1
- package/src/persistence/schema.ts +113 -40
- package/src/persistence/shared-cache.ts +2 -2
- package/src/persistence/sort-order.ts +1 -1
- package/src/persistence/stores/api-key-store.ts +10 -1
- package/src/persistence/stores/share-store.ts +20 -8
- package/src/persistence/system-prompt-store.ts +63 -18
- package/src/persistence/tenant-preferences.ts +1 -1
- package/src/persistence/ttl-cache.ts +3 -3
- package/src/persistence/usage-flush.ts +7 -2
- package/src/protocol/canonical-model.ts +1 -1
- package/src/protocol/codecs.ts +6 -10
- package/src/protocol/dsml.ts +3 -49
- package/src/protocol/image-clamp.ts +2 -2
- package/src/protocol/reasoning-text.ts +1 -1
- package/src/protocol/reasoning.ts +7 -52
- package/src/protocol/registry.ts +50 -15
- package/src/protocol/request/chat.ts +46 -19
- package/src/protocol/request/codex.ts +1 -1
- package/src/protocol/request/messages.ts +46 -14
- package/src/protocol/request/responses.ts +19 -10
- package/src/protocol/response/chat.ts +25 -10
- package/src/protocol/response/codex.ts +1 -1
- package/src/protocol/response/messages.ts +1 -1
- package/src/protocol/response/omp-tool-call-markup.ts +1 -1
- package/src/protocol/response/responses.ts +1 -1
- package/src/protocol/sse-decoder.ts +50 -1
- package/src/protocol/stop-reason.ts +2 -2
- package/src/protocol/think-split.ts +347 -0
- package/src/protocol/translation/attachment-integrity.ts +3 -3
- package/src/protocol/translation/cache-controls.ts +8 -20
- package/src/protocol/translation/capabilities.ts +6 -6
- package/src/protocol/translation/dialect/deepseek.ts +10 -0
- package/src/protocol/translation/dialect/inband.ts +39 -0
- package/src/protocol/translation/dialect/model-thinking.ts +89 -0
- package/src/protocol/translation/dialect/qwen.ts +114 -0
- package/src/protocol/translation/dialect/resolver.ts +83 -0
- package/src/protocol/translation/dialect/rules.ts +93 -0
- package/src/protocol/translation/dialect/thinking-ladder.ts +245 -0
- package/src/protocol/translation/dialect/types.ts +58 -0
- package/src/protocol/translation/quirks.ts +18 -4
- package/src/protocol/translation/thinking.ts +12 -252
- package/src/protocol/translation/tool-id.ts +3 -3
- package/src/protocol/translation/tool-repair.ts +1 -1
- package/src/protocol/upstream/post-json.ts +6 -1
- package/src/providers/adapters/compatible-adapter.ts +42 -3
- package/src/providers/authentication/jwt-validator.ts +0 -5
- package/src/providers/authentication/oauth-client.ts +254 -253
- package/src/providers/authentication/oauth-flow-store.ts +42 -7
- package/src/providers/bundled-discovery-providers.ts +6 -30
- package/src/providers/configured-openai-providers.ts +82 -97
- package/src/providers/default-registry.ts +23 -35
- package/src/providers/discovery/catalog/catalog-format.ts +13 -2
- package/src/providers/discovery/discovery-types.ts +0 -1
- package/src/providers/discovery/openai-model-discovery.ts +2 -2
- package/src/providers/discovery/probe-phases.ts +48 -13
- package/src/providers/discovery/probing-service.ts +6 -8
- package/src/providers/integrations/agentrouter/agentrouter.ts +12 -2
- package/src/providers/integrations/antigravity/antigravity-oauth.ts +0 -1
- package/src/providers/integrations/antigravity/antigravity-protocol.ts +0 -14
- package/src/providers/integrations/antigravity/antigravity.ts +2 -2
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -1
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +20 -4
- package/src/providers/integrations/buddy/buddy-cli-headers.ts +19 -52
- package/src/providers/integrations/buddy/buddy-identity.ts +86 -0
- package/src/providers/integrations/buddy/buddy-oauth-shared.ts +1 -1
- package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +1 -2
- package/src/providers/integrations/buddy/codebuddy/provider.ts +4 -15
- package/src/providers/integrations/buddy/codebuddy/shared.ts +30 -9
- package/src/providers/integrations/buddy/workbuddy/provider.ts +5 -9
- package/src/providers/integrations/buddy/workbuddy/shared.ts +21 -3
- package/src/providers/integrations/claude/claude-cch.ts +148 -148
- package/src/providers/integrations/claude/fallback-credit.ts +3 -7
- package/src/providers/integrations/cline/cline.ts +1 -1
- package/src/providers/integrations/codex/codex-device-code.ts +101 -101
- package/src/providers/integrations/codex/codex-oauth.ts +0 -1
- package/src/providers/integrations/commandcode/commandcode.ts +75 -115
- package/src/providers/integrations/commandcode/quota.ts +131 -51
- package/src/providers/integrations/cursor/cursor-http2.ts +0 -3
- package/src/providers/integrations/cursor/cursor-quota.ts +2 -6
- package/src/providers/integrations/cursor/cursor-request.ts +0 -87
- package/src/providers/integrations/cursor/cursor-shared.ts +0 -4
- package/src/providers/integrations/cursor/cursor.ts +38 -5
- package/src/providers/integrations/cursor/protocol/framing.ts +0 -56
- package/src/providers/integrations/cursor/protocol/messages.ts +0 -96
- package/src/providers/integrations/factory/factory.ts +9 -9
- package/src/providers/integrations/github/github-discovery.ts +55 -10
- package/src/providers/integrations/github/github-oauth.ts +6 -5
- package/src/providers/integrations/github/github.ts +41 -14
- package/src/providers/integrations/grok/grok-oauth.ts +261 -261
- package/src/providers/integrations/grok/grok-quota-frame.ts +1 -1
- package/src/providers/integrations/grok/grok-quota.ts +16 -7
- package/src/providers/integrations/grok/grok-ratelimit-store.ts +0 -5
- package/src/providers/integrations/grok/grok-ratelimit.ts +3 -0
- package/src/providers/integrations/grok/grok.ts +86 -36
- package/src/providers/integrations/kilo/kilo-oauth.ts +1 -1
- package/src/providers/integrations/kiro/kiro-cache.ts +31 -0
- package/src/providers/integrations/kiro/kiro-catalog.ts +31 -5
- package/src/providers/integrations/kiro/kiro-discovery.ts +47 -43
- package/src/providers/integrations/kiro/kiro-machine-id.ts +6 -3
- package/src/providers/integrations/kiro/kiro-oauth.ts +523 -255
- package/src/providers/integrations/kiro/kiro-quota.ts +35 -47
- package/src/providers/integrations/kiro/kiro-request.ts +8 -11
- package/src/providers/integrations/kiro/kiro-stream.ts +13 -0
- package/src/providers/integrations/kiro/kiro.ts +81 -68
- package/src/providers/integrations/muse/muse-oauth.ts +0 -1
- package/src/providers/integrations/muse/muse-quota.ts +0 -4
- package/src/providers/integrations/opencode/fingerprint.ts +7 -5
- package/src/providers/integrations/opencode/provider.ts +17 -24
- package/src/providers/integrations/qoder/qoder-catalog.ts +85 -8
- package/src/providers/integrations/qoder/qoder-oauth.ts +165 -25
- package/src/providers/integrations/qoder/qoder-quota.ts +48 -6
- package/src/providers/integrations/qoder/qoder-realm.ts +10 -31
- package/src/providers/integrations/qoder/qoder-sse.ts +159 -0
- package/src/providers/integrations/qoder/qoder-umid.ts +21 -25
- package/src/providers/integrations/qoder/qoder-virtual.ts +12 -0
- package/src/providers/integrations/qoder/qoder.ts +354 -62
- package/src/providers/integrations/shared/connect.ts +0 -54
- package/src/providers/integrations/shared/hash.ts +15 -0
- package/src/providers/integrations/shared/session-cache.ts +136 -0
- package/src/providers/integrations/trae/trae-cache.ts +7 -87
- package/src/providers/integrations/trae/trae-catalog.ts +1 -6
- package/src/providers/integrations/trae/trae-protocol.ts +17 -8
- package/src/providers/integrations/trae/trae-realm.ts +0 -7
- package/src/providers/integrations/trae/trae.ts +53 -10
- package/src/providers/integrations/xai/xai-oauth.ts +5 -5
- package/src/providers/integrations/zcode-start-plan/gifts.ts +265 -0
- package/src/providers/integrations/zcode-start-plan/oauth.ts +154 -0
- package/src/providers/integrations/zcode-start-plan/provider.ts +131 -0
- package/src/providers/integrations/zcode-start-plan/quota.ts +152 -0
- package/src/providers/integrations/zcode-start-plan/zcode_prompt.json +10 -0
- package/src/providers/operations/account-error-classification.ts +131 -116
- package/src/providers/operations/account-health-service.ts +31 -39
- package/src/providers/operations/account-reset-service.ts +58 -1
- package/src/providers/operations/cli-platform.ts +31 -5
- package/src/providers/operations/client-version-resolver.ts +4 -6
- package/src/providers/operations/client-versions.ts +10 -7
- package/src/providers/operations/provider-catalog-seeder.ts +2 -0
- package/src/providers/operations/provider-catalog-service.ts +18 -2
- package/src/providers/operations/provider-credential-service.ts +19 -2
- package/src/providers/provider-contracts.ts +18 -7
- package/src/providers/provider-metadata.ts +17 -2
- package/src/providers/provider-model-definition.ts +22 -1
- package/src/providers/provider-probe.ts +4 -2
- package/src/providers/provider-usage.ts +81 -16
- package/src/providers/quota/cache.ts +29 -0
- package/src/providers/quota/quota-contracts.ts +4 -0
- package/src/providers/quota/refresh.ts +27 -4
- package/src/providers/quota/snapshot.ts +94 -0
- package/src/providers/search/search-provider.ts +1 -3
- package/src/providers/search/search-providers.ts +2 -1
- package/src/security/abuse.ts +4 -4
- package/src/security/admission/service.ts +2 -2
- package/src/security/admission/ttl.ts +61 -61
- package/src/security/api-key-auth.ts +16 -4
- package/src/security/client-router-fingerprint.ts +4 -4
- package/src/security/credential-codec.ts +3 -3
- package/src/security/csrf.ts +2 -2
- package/src/security/ip-boundary.ts +16 -189
- package/src/security/model-access-rule.ts +1 -1
- package/src/security/outbound-headers.ts +18 -0
- package/src/shared/invoke-guard.ts +47 -0
- package/src/transport/contracts.ts +13 -4
- package/src/transport/dispatch/attempt-finalize.ts +1 -1
- package/src/transport/dispatch/capture-policy.ts +34 -37
- package/src/transport/dispatch/empty-stream.ts +23 -20
- package/src/transport/dispatch/generation.ts +25 -45
- package/src/transport/dispatch/native-shared.ts +127 -0
- package/src/transport/dispatch/proxy-request.ts +3 -1
- package/src/transport/dispatch/responses-compact.ts +25 -46
- package/src/transport/dispatch/streaming/encoder.ts +18 -72
- package/src/transport/dispatch/streaming/finalize.ts +2 -2
- package/src/transport/dispatch/streaming/stream.ts +7 -3
- package/src/transport/dispatch/streaming/thinking-loop.ts +1 -1
- package/src/transport/dispatch/systemone.ts +20 -48
- package/src/transport/dispatch/token-count.ts +41 -38
- package/src/transport/dispatch/websearch.ts +19 -45
- package/src/transport/drain-endpoint.ts +1 -1
- package/src/transport/failure-policy.ts +6 -0
- package/src/transport/guards/auth-guard.ts +0 -6
- package/src/transport/guards/body-policy.ts +3 -3
- package/src/transport/guards/headroom-guard.ts +1 -1
- package/src/transport/guards/pipeline.ts +1 -1
- package/src/transport/guards/request-context.ts +12 -17
- package/src/transport/request/preparer.ts +286 -7
- package/src/transport/request/rtk/compress-request.ts +3 -3
- package/src/transport/request/rtk/rtk-constants.ts +2 -2
- package/src/transport/request/rtk/rtk-filters.ts +8 -8
- package/src/transport/request/state.ts +1 -13
- package/src/transport/request/system-prompt-injection.ts +74 -11
- package/src/transport/request/tenant-preferences.ts +22 -0
- package/src/transport/routing/candidate-index.ts +2 -2
- package/src/transport/routing/round-robin.ts +1 -1
- package/src/transport/routing/route-catalog.ts +17 -18
- package/src/transport/routing/route-model.ts +126 -33
- package/src/transport/routing/router.ts +152 -37
- package/src/transport/shutdown-notice.ts +2 -2
- package/src/transport/surface/adapters.ts +3 -3
- package/src/transport/surface/chat/encode.ts +7 -7
- package/src/transport/surface/chat/parse.ts +7 -7
- package/src/transport/surface/completion.ts +2 -2
- package/src/transport/surface/content-parts.ts +1 -1
- package/src/transport/surface/dialects.ts +2 -2
- package/src/transport/surface/messages/parse.ts +3 -3
- package/src/transport/surface/responses/adapter.ts +1 -1
- package/src/transport/surface/responses/errors.ts +0 -9
- package/src/transport/vision-handoff-cache.ts +58 -0
- package/src/transport/vision-handoff.ts +376 -0
- package/src/workers/account-identity-sweep.ts +2 -2
- package/src/workers/buddy-growth-tasks.ts +392 -346
- package/src/workers/daily-checkin.ts +102 -49
- package/src/workers/maintenance-lease.ts +2 -35
- package/src/workers/oauth-refresh-sweep.ts +8 -1
- package/src/workers/quota-refresh-sweep.ts +120 -82
- package/src/workers/scheduler.ts +67 -4
- package/src/workers/sweep-runner.ts +24 -5
- package/src/workers/zcode-gift-claim.ts +108 -0
- package/dist/dashboard/assets/AccountCooldown-Bjo92Qat.js +0 -1
- package/dist/dashboard/assets/AccountCooldown-Bjo92Qat.js.gz +0 -0
- package/dist/dashboard/assets/BannedPage-ChG5svFb.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-BIcWQun7.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-Bf2VelDe.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-DCgm5g6g.js +0 -1
- package/dist/dashboard/assets/CombosPage-DCgm5g6g.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-a8mD0QF1.js +0 -1
- package/dist/dashboard/assets/ConsoleLogPage-a8mD0QF1.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-ggeMSjte.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-DzSWzE_j.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-DzSWzE_j.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-U6AKaJcF.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-BMS2t8BA.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-D2D4IiHl.js +0 -1
- package/dist/dashboard/assets/OverviewPage-D2D4IiHl.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CrNf7j_X.js +0 -5
- package/dist/dashboard/assets/ProviderDetailPage-CrNf7j_X.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js +0 -1
- package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-BSDbquc1.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-BSDbquc1.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-lsrx7NYg.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-L0skzML8.js +0 -1
- package/dist/dashboard/assets/QuotaPage-L0skzML8.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CmAbjRHW.js +0 -1
- package/dist/dashboard/assets/SettingsPage-CmAbjRHW.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-f0DE1h0-.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-ClzpwyET.js +0 -1
- package/dist/dashboard/assets/SortableList-ClzpwyET.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-Cq5tW7hW.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-Dfc9rWQn.js +0 -56
- package/dist/dashboard/assets/UsagePage-Dfc9rWQn.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-C38iXoO4.js +0 -1
- package/dist/dashboard/assets/api-keys-C38iXoO4.js.gz +0 -0
- package/dist/dashboard/assets/app-a159k0ub.css +0 -2
- package/dist/dashboard/assets/app-a159k0ub.css.gz +0 -0
- package/dist/dashboard/assets/assert-DbRA8EG2.js +0 -15
- package/dist/dashboard/assets/assert-DbRA8EG2.js.gz +0 -0
- package/dist/dashboard/assets/badge-D9CS6dZk.js.gz +0 -0
- package/dist/dashboard/assets/banner-DympBNq4.js.gz +0 -0
- package/dist/dashboard/assets/card-ibRWU_uT.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-63qBGaRw.js +0 -1
- package/dist/dashboard/assets/cli-tools-63qBGaRw.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-DJm-9pz7.js.gz +0 -0
- package/dist/dashboard/assets/dialog-8fiE6tec.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CqGYeOrd.js.gz +0 -0
- package/dist/dashboard/assets/entry-BSAsYsmV.js.gz +0 -0
- package/dist/dashboard/assets/entry-CiIQULZD.js +0 -4
- package/dist/dashboard/assets/entry-CiIQULZD.js.gz +0 -0
- package/dist/dashboard/assets/entry-Daz0lJ-W.js +0 -1
- package/dist/dashboard/assets/entry-Daz0lJ-W.js.gz +0 -0
- package/dist/dashboard/assets/formatters-D-MTJxMr.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-CPK47xQx.js.gz +0 -0
- package/dist/dashboard/assets/index-DBNPOfhV.js.gz +0 -0
- package/dist/dashboard/assets/inline-BT0zntEr.js.gz +0 -0
- package/dist/dashboard/assets/input-Da2ZViWg.js.gz +0 -0
- package/dist/dashboard/assets/layout-BXNeLw6M.js.gz +0 -0
- package/dist/dashboard/assets/list-row-BycOrrIV.js.gz +0 -0
- package/dist/dashboard/assets/live-DCQNZpF7.js +0 -1
- package/dist/dashboard/assets/live-DCQNZpF7.js.gz +0 -0
- package/dist/dashboard/assets/network-9Yj2ZyLo.js +0 -1
- package/dist/dashboard/assets/network-9Yj2ZyLo.js.gz +0 -0
- package/dist/dashboard/assets/numbers-CEagj6ZZ.js +0 -2
- package/dist/dashboard/assets/numbers-CEagj6ZZ.js.gz +0 -0
- package/dist/dashboard/assets/quota-Caw7wqci.js +0 -1
- package/dist/dashboard/assets/quota-Caw7wqci.js.gz +0 -0
- package/dist/dashboard/assets/routing-CO9zCUus.js +0 -1
- package/dist/dashboard/assets/routing-CO9zCUus.js.gz +0 -0
- package/dist/dashboard/assets/select-BIYIBkMU.js.gz +0 -0
- package/dist/dashboard/assets/settings-jS3-PNSA.js +0 -1
- package/dist/dashboard/assets/settings-jS3-PNSA.js.gz +0 -0
- package/dist/dashboard/assets/stack-DfgnFDWY.js.gz +0 -0
- package/dist/dashboard/assets/state-CkfdYWel.js.gz +0 -0
- package/dist/dashboard/assets/stream-retry-5QnVuSSo.js +0 -1
- package/dist/dashboard/assets/stream-retry-5QnVuSSo.js.gz +0 -0
- package/dist/dashboard/assets/switch-SYm237HG.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-BQqCRNIi.js.gz +0 -0
- package/src/network/response-headers.ts +0 -39
- package/src/protocol/response/dsml.ts +0 -38
- package/src/providers/integrations/xiaomi-mimo/think-stream.ts +0 -229
- /package/dist/dashboard/assets/{app-BayTTy01.js → app-Bgd3zANQ.js} +0 -0
- /package/dist/dashboard/assets/{app-BayTTy01.js.gz → app-Bgd3zANQ.js.gz} +0 -0
- /package/dist/dashboard/assets/brand/{og_bansos.webp → og_bansos_initial.webp} +0 -0
- /package/dist/dashboard/assets/brand/{og_image.webp → og_image_initial.webp} +0 -0
|
@@ -1,468 +1,468 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Backup and restore operations, plus the import path for a router export.
|
|
3
|
-
*
|
|
4
|
-
* Two things every entry point here has in common:
|
|
5
|
-
*
|
|
6
|
-
* 1. **Nothing is written before the whole payload validates.** Validation
|
|
7
|
-
* resolves every table and column against the live schema, so a stale or
|
|
8
|
-
* hostile file fails with the offending name and the database is untouched.
|
|
9
|
-
* 2. **Everything happens in one transaction** (see `store.applyRestore`), so a
|
|
10
|
-
* mid-restore SQL error rolls back rather than leaving half a
|
|
11
|
-
* configuration — which is the state that makes an operator's day worse than
|
|
12
|
-
* a plain failure would.
|
|
13
|
-
*
|
|
14
|
-
* Export is a JSON object at the operator's explicit request, and it carries
|
|
15
|
-
* provider credentials and API keys decrypted — the only form in which they can
|
|
16
|
-
* be restored into an install with a different encryption key (see
|
|
17
|
-
* {@link sealedColumns}). So the file is as sensitive as the database: the layer
|
|
18
|
-
* doc and the dashboard copy say so, and export requires password
|
|
19
|
-
* re-authentication.
|
|
20
|
-
*/
|
|
21
|
-
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
22
|
-
import type { Table } from "drizzle-orm";
|
|
23
|
-
import {
|
|
24
|
-
models,
|
|
25
|
-
providerAccounts,
|
|
26
|
-
providerOauthStates,
|
|
27
|
-
providerRoutingSettings,
|
|
28
|
-
providers,
|
|
29
|
-
tenantDisabledModels,
|
|
30
|
-
} from "../../persistence/schema";
|
|
31
|
-
import { ConsoleDomainError } from "../shared/errors";
|
|
32
|
-
import { tablesForSection, tableName } from "./contracts";
|
|
33
|
-
import type {
|
|
34
|
-
BackupSection,
|
|
35
|
-
RestoreWarning,
|
|
36
|
-
ValidatedRestore,
|
|
37
|
-
ValidatedTable,
|
|
38
|
-
} from "./contracts";
|
|
39
|
-
import {
|
|
40
|
-
applyRestore,
|
|
41
|
-
decompressBackup,
|
|
42
|
-
deleteAll,
|
|
43
|
-
exportBackupStream,
|
|
44
|
-
type BackupExportStream,
|
|
45
|
-
type DeleteAllResult,
|
|
46
|
-
type DeleteAllScope,
|
|
47
|
-
} from "./store";
|
|
48
|
-
import { detectFormat, restoreOrder, validateRestorePayload } from "./validate";
|
|
49
|
-
import { convert9RouterBackup, type ImportReport } from "./nine-router";
|
|
50
|
-
import { isRecord } from "../../protocol/codecs";
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
readonly db: CartethyiaDatabase;
|
|
54
|
-
/** Verifies the signed-in operator's console password; supplied by the router. */
|
|
55
|
-
readonly verifyPassword: (password: string) => Promise<boolean>;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
readonly sections?: readonly BackupSection[];
|
|
60
|
-
readonly password: string;
|
|
61
|
-
/** The tenant whose rows are exported. Never another tenant's. */
|
|
62
|
-
readonly tenantId: string;
|
|
63
|
-
/** Gzips the file. Changes nothing about what the payload carries. */
|
|
64
|
-
readonly compressed?: boolean;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** One table as it appears in a backup file, and whether it can be restored. */
|
|
68
|
-
|
|
69
|
-
readonly section: string;
|
|
70
|
-
readonly table: string;
|
|
71
|
-
readonly count: number;
|
|
72
|
-
/** False when the current schema has no such table: restore will skip it. */
|
|
73
|
-
readonly supported: boolean;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* A backup's cover, read without writing.
|
|
78
|
-
*
|
|
79
|
-
* `warnings` carries what a real restore would leave out, so the operator sees
|
|
80
|
-
* the loss before committing rather than after.
|
|
81
|
-
*/
|
|
82
|
-
export interface BackupInspection {
|
|
83
|
-
readonly format: "native" | "nine_router";
|
|
84
|
-
readonly version: number;
|
|
85
|
-
readonly exportedAt: string;
|
|
86
|
-
readonly rows: readonly BackupInspectionRow[];
|
|
87
|
-
readonly total: number;
|
|
88
|
-
readonly warnings: readonly RestoreWarning[];
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export interface ImportResult {
|
|
92
|
-
readonly restored: Record<string, number>;
|
|
93
|
-
readonly skipped: Record<string, number>;
|
|
94
|
-
/** Parts of the file that could not be restored. Empty means it all applied. */
|
|
95
|
-
readonly warnings: readonly RestoreWarning[];
|
|
96
|
-
readonly report?: ImportReport;
|
|
97
|
-
readonly format: "native" | "nine_router";
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Which parts of a backup file a restore should actually write.
|
|
102
|
-
*
|
|
103
|
-
* Absent or empty means "everything", which is the historical behavior and
|
|
104
|
-
* what the dashboard's checklist defaults to. A table named here but absent
|
|
105
|
-
* from the file is simply not restored — it is not an error, because the
|
|
106
|
-
* checklist is built from an inspection of *this* file and a selection never
|
|
107
|
-
* invents a table.
|
|
108
|
-
*/
|
|
109
|
-
|
|
110
|
-
/** Table names to restore; absent or empty restores every table present. */
|
|
111
|
-
readonly tables?: readonly string[];
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Narrows a validated backup to the tables the operator selected.
|
|
116
|
-
*
|
|
117
|
-
* The tenant row is the one thing that is not independently selectable: it is
|
|
118
|
-
* not a table in either section (`BACKUP_SECTIONS` are config and telemetry),
|
|
119
|
-
* so it never appears on the checklist, and every tenant-scoped table
|
|
120
|
-
* cascades from it. Dropping it because it was not named would make a partial
|
|
121
|
-
* restore fail on a foreign key for a table the operator *did* ask for. So it
|
|
122
|
-
* rides along with any config selection and is written only when one is made.
|
|
123
|
-
*
|
|
124
|
-
* A table named in the selection but missing from the file is a no-op rather
|
|
125
|
-
* than an error: the checklist is built from an inspection of this file, but
|
|
126
|
-
* the selection is validated as names only, and refusing to restore anything
|
|
127
|
-
* over one stale name would be worse than ignoring it.
|
|
128
|
-
*/
|
|
129
|
-
function restrictToSelection(
|
|
130
|
-
validation: ValidatedRestore,
|
|
131
|
-
selection: BackupRestoreSelection | undefined,
|
|
132
|
-
): ValidatedRestore {
|
|
133
|
-
const wanted = selection?.tables;
|
|
134
|
-
if (wanted === undefined || wanted.length === 0) return validation;
|
|
135
|
-
const selected = new Set(wanted);
|
|
136
|
-
const tables = new Map<Table, ValidatedTable>();
|
|
137
|
-
for (const [table, entry] of validation.tables) {
|
|
138
|
-
if (selected.has(tableName(table))) tables.set(table, entry);
|
|
139
|
-
}
|
|
140
|
-
return {
|
|
141
|
-
...validation,
|
|
142
|
-
tables,
|
|
143
|
-
tenantRows: tables.size > 0 ? validation.tenantRows : [],
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function sectionsOrDefault(sections: readonly BackupSection[] | undefined): readonly BackupSection[] {
|
|
148
|
-
if (sections === undefined || sections.length === 0) return ["config"];
|
|
149
|
-
for (const section of sections) {
|
|
150
|
-
if (section !== "config" && section !== "telemetry") {
|
|
151
|
-
throw new ConsoleDomainError("invalid_request", 400, `unknown backup section "${String(section)}"`);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
return sections;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** The bytes of an uploaded backup file, as carried by a string. */
|
|
158
|
-
function toBackupBytes(input: string): Uint8Array {
|
|
159
|
-
// Base64 is the only way file bytes travel inside a JSON body. A string that
|
|
160
|
-
// already is JSON is taken as the file's text; anything else is the encoded
|
|
161
|
-
// file, and a malformed payload still fails at `JSON.parse` rather than here.
|
|
162
|
-
if (input.trimStart().startsWith("{")) return Buffer.from(input, "utf8");
|
|
163
|
-
return Buffer.from(input, "base64");
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Accepts a backup in any form the operator can hand it over, and parses it.
|
|
168
|
-
*
|
|
169
|
-
* The export offers a compressed download and names it `.json.gz`, so the file
|
|
170
|
-
* on the operator's disk is gzip bytes. The restore path used to read its body
|
|
171
|
-
* as JSON text, which meant their own backup was the one file it could not
|
|
172
|
-
* import. The shapes handled here are the ways a file actually arrives: a JSON
|
|
173
|
-
* object (the dashboard decodes the file in the browser), the export's own
|
|
174
|
-
* `{ bytes }` envelope, the file's text, and its bytes — base64-encoded for a
|
|
175
|
-
* JSON body, or raw.
|
|
176
|
-
*
|
|
177
|
-
* Whether it is compressed is decided by the bytes' magic, never by the
|
|
178
|
-
* filename: a `.json` that is really gzip is restored, and a `.gz` that is not
|
|
179
|
-
* is too.
|
|
180
|
-
*/
|
|
181
|
-
function normalizeBackupInput(input: unknown): unknown {
|
|
182
|
-
if (input === null || input === undefined) return input;
|
|
183
|
-
if (input instanceof Uint8Array) return parseBackupText(decompressBackup(input));
|
|
184
|
-
if (input instanceof ArrayBuffer) return parseBackupText(decompressBackup(new Uint8Array(input)));
|
|
185
|
-
if (typeof input === "string") return parseBackupText(decompressBackup(toBackupBytes(input)));
|
|
186
|
-
// The export's own envelope, handed straight back.
|
|
187
|
-
if (isRecord(input) && typeof input["bytes"] === "string" && Object.keys(input).length <= 3) {
|
|
188
|
-
return parseBackupText(decompressBackup(toBackupBytes(input["bytes"])));
|
|
189
|
-
}
|
|
190
|
-
return input;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function parseBackupText(text: string): unknown {
|
|
194
|
-
try {
|
|
195
|
-
return JSON.parse(text) as unknown;
|
|
196
|
-
} catch {
|
|
197
|
-
throw new ConsoleDomainError("invalid_request", 400, "that file is not a JSON backup");
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async function omitRowsForUnavailableProviders(
|
|
202
|
-
db: CartethyiaDatabase,
|
|
203
|
-
validation: ValidatedRestore,
|
|
204
|
-
tenantId: string,
|
|
205
|
-
): Promise<ValidatedRestore> {
|
|
206
|
-
const existingProviders = await db
|
|
207
|
-
.select({ id: providers.id, tenantId: providers.tenantId })
|
|
208
|
-
.from(providers);
|
|
209
|
-
const importedProviders = validation.tables.get(providers)?.rows ?? [];
|
|
210
|
-
const providerIds = new Set([
|
|
211
|
-
...existingProviders
|
|
212
|
-
.filter((provider) => provider.tenantId === null || provider.tenantId === tenantId)
|
|
213
|
-
.map((provider) => provider.id),
|
|
214
|
-
...importedProviders.map((provider) => String(provider["id"])),
|
|
215
|
-
]);
|
|
216
|
-
const tables = new Map(validation.tables);
|
|
217
|
-
const warnings = [...validation.warnings];
|
|
218
|
-
const counts = { ...validation.counts };
|
|
219
|
-
|
|
220
|
-
for (const table of [providerAccounts, models, tenantDisabledModels, providerRoutingSettings]) {
|
|
221
|
-
const entry = tables.get(table);
|
|
222
|
-
if (entry === undefined) continue;
|
|
223
|
-
const rows = entry.rows.filter(
|
|
224
|
-
(row) => typeof row["provider_id"] === "string" && providerIds.has(row["provider_id"]),
|
|
225
|
-
);
|
|
226
|
-
if (rows.length === entry.rows.length) continue;
|
|
227
|
-
tables.set(table, { ...entry, rows });
|
|
228
|
-
counts[tableName(table)] = rows.length;
|
|
229
|
-
warnings.push({
|
|
230
|
-
table: tableName(table),
|
|
231
|
-
column: "provider_id",
|
|
232
|
-
reason: "referenced a provider not available in this installation; those rows were skipped",
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const accountRows = tables.get(providerAccounts)?.rows ?? [];
|
|
237
|
-
const replacesTenantAccounts = accountRows.length > 0;
|
|
238
|
-
const existingAccounts = await db
|
|
239
|
-
.select({ id: providerAccounts.id, tenantId: providerAccounts.tenantId })
|
|
240
|
-
.from(providerAccounts);
|
|
241
|
-
const accountIds = new Set(
|
|
242
|
-
existingAccounts
|
|
243
|
-
.filter((account) => !replacesTenantAccounts || account.tenantId !== tenantId)
|
|
244
|
-
.map((account) => account.id),
|
|
245
|
-
);
|
|
246
|
-
for (const account of accountRows) {
|
|
247
|
-
if (typeof account["id"] === "string") accountIds.add(account["id"]);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const oauthEntry = tables.get(providerOauthStates);
|
|
251
|
-
if (oauthEntry !== undefined) {
|
|
252
|
-
const rows = oauthEntry.rows.filter(
|
|
253
|
-
(row) =>
|
|
254
|
-
typeof row["provider_account_id"] === "string" && accountIds.has(row["provider_account_id"]),
|
|
255
|
-
);
|
|
256
|
-
if (rows.length !== oauthEntry.rows.length) {
|
|
257
|
-
tables.set(providerOauthStates, { ...oauthEntry, rows });
|
|
258
|
-
counts[tableName(providerOauthStates)] = rows.length;
|
|
259
|
-
warnings.push({
|
|
260
|
-
table: tableName(providerOauthStates),
|
|
261
|
-
column: "provider_account_id",
|
|
262
|
-
reason: "referenced an account not restored or available in this installation; those rows were skipped",
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
return { ...validation, tables, counts, warnings };
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
export class BackupService {
|
|
271
|
-
constructor(private readonly options: BackupServiceOptions) {}
|
|
272
|
-
|
|
273
|
-
async export(options: ExportOptions): Promise<BackupExportStream> {
|
|
274
|
-
if (!(await this.options.verifyPassword(options.password))) {
|
|
275
|
-
throw new ConsoleDomainError("unauthorized", 401, "password is incorrect");
|
|
276
|
-
}
|
|
277
|
-
const sections = sectionsOrDefault(options.sections);
|
|
278
|
-
return exportBackupStream(
|
|
279
|
-
this.options.db,
|
|
280
|
-
sections.flatMap((section) => tablesForSection(section)),
|
|
281
|
-
options.tenantId,
|
|
282
|
-
);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Restores a payload, detecting its format first.
|
|
287
|
-
*
|
|
288
|
-
* A native payload is validated as-is. A router export is converted first —
|
|
289
|
-
* and conversion is itself validation, because it refuses provider ids it
|
|
290
|
-
* cannot map rather than guessing — then the converted payload goes through
|
|
291
|
-
* the identical validation and transaction as a native one. There is one
|
|
292
|
-
* write path, not two.
|
|
293
|
-
*
|
|
294
|
-
* `tenantId` is the restoring tenant, and it is the *only* tenant any write
|
|
295
|
-
* can reach: the payload cannot name a different one, because every table is
|
|
296
|
-
* filtered by ownership and the tenant row is forced to this id.
|
|
297
|
-
*/
|
|
298
|
-
async restore(
|
|
299
|
-
password: unknown,
|
|
300
|
-
payload: unknown,
|
|
301
|
-
tenantId: string,
|
|
302
|
-
selection?: BackupRestoreSelection,
|
|
303
|
-
): Promise<ImportResult> {
|
|
304
|
-
if (typeof password !== "string" || !(await this.options.verifyPassword(password))) {
|
|
305
|
-
throw new ConsoleDomainError("unauthorized", 401, "password is incorrect");
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const detected = detectFormat(normalizeBackupInput(payload));
|
|
309
|
-
if (detected.kind === "unknown") {
|
|
310
|
-
throw new ConsoleDomainError("invalid_request", 400, detected.reason);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
if (detected.kind === "nine_router") {
|
|
314
|
-
const { payload: converted, report } = convert9RouterBackup(detected.payload, tenantId);
|
|
315
|
-
const result = await this.apply(converted, tenantId, selection);
|
|
316
|
-
return { ...result, report, format: "nine_router" };
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const result = await this.apply(detected.payload, tenantId, selection);
|
|
320
|
-
return { ...result, format: "native" };
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* Restores a native backup onto a brand-new tenant during first boot.
|
|
325
|
-
*
|
|
326
|
-
* There is no operator to re-authenticate, and no tenant to scope the write
|
|
327
|
-
* to. The caller has already proven that no console user exists and holds
|
|
328
|
-
* the setup lock.
|
|
329
|
-
*
|
|
330
|
-
* The configuration is restored; the sign-in accounts are not, because a
|
|
331
|
-
* backup does not carry them (see {@link CONFIG_TABLES}). The caller creates
|
|
332
|
-
* the administrator for the install it just recovered into.
|
|
333
|
-
*/
|
|
334
|
-
async restoreFirstBoot(payload: unknown, tenantId: string): Promise<ImportResult> {
|
|
335
|
-
const detected = detectFormat(normalizeBackupInput(payload));
|
|
336
|
-
if (detected.kind !== "native") {
|
|
337
|
-
throw new ConsoleDomainError(
|
|
338
|
-
"invalid_request",
|
|
339
|
-
400,
|
|
340
|
-
detected.kind === "unknown" ? detected.reason : "first-boot restore accepts a Cartethyia backup only",
|
|
341
|
-
);
|
|
342
|
-
}
|
|
343
|
-
const result = await this.apply(detected.payload, tenantId);
|
|
344
|
-
return { ...result, format: "native" };
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* Describes a backup without writing any of it.
|
|
349
|
-
*
|
|
350
|
-
* The dashboard's restore wizard peeks at a file before committing to it, and
|
|
351
|
-
* "which of these tables does the current build actually have" is a question
|
|
352
|
-
* only the live schema can answer — a hand-maintained list of table names in
|
|
353
|
-
* the browser would drift the moment a migration landed. So the answer comes
|
|
354
|
-
* from the same validation a restore runs, and writes nothing.
|
|
355
|
-
*/
|
|
356
|
-
inspect(payload: unknown): BackupInspection {
|
|
357
|
-
const detected = detectFormat(normalizeBackupInput(payload));
|
|
358
|
-
if (detected.kind === "unknown") {
|
|
359
|
-
throw new ConsoleDomainError("invalid_request", 400, detected.reason);
|
|
360
|
-
}
|
|
361
|
-
const candidate = detected.kind === "native" ? detected.payload : null;
|
|
362
|
-
if (candidate === null) {
|
|
363
|
-
// A router export has no sections to list; it is converted at restore
|
|
364
|
-
// time, so all that can be said here is what it is.
|
|
365
|
-
return {
|
|
366
|
-
format: "nine_router",
|
|
367
|
-
version: 0,
|
|
368
|
-
exportedAt: "",
|
|
369
|
-
rows: [],
|
|
370
|
-
total: 0,
|
|
371
|
-
warnings: [],
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
const validation = validateRestorePayload(candidate, "");
|
|
375
|
-
const warnings = validation.ok ? validation.value.warnings : [];
|
|
376
|
-
const supported = new Set(validation.ok ? [...validation.value.tables.keys()].map(tableName) : []);
|
|
377
|
-
// `tenants` is diverted to `validation.value.tenantRows` during validate —
|
|
378
|
-
// not included in `tables` — so it would show as "unsupported" in the
|
|
379
|
-
// preview for any backup that carries it. Mark it supported explicitly.
|
|
380
|
-
if (validation.ok) supported.add("tenants");
|
|
381
|
-
|
|
382
|
-
const rows: BackupInspectionRow[] = [];
|
|
383
|
-
let total = 0;
|
|
384
|
-
const sections = isRecord(candidate.sections) ? candidate.sections : {};
|
|
385
|
-
for (const [section, value] of Object.entries(sections)) {
|
|
386
|
-
if (!isRecord(value)) continue;
|
|
387
|
-
for (const [table, list] of Object.entries(value)) {
|
|
388
|
-
if (!Array.isArray(list)) continue;
|
|
389
|
-
rows.push({ section, table, count: list.length, supported: supported.has(table) });
|
|
390
|
-
total += list.length;
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
rows.sort((left, right) => right.count - left.count || left.table.localeCompare(right.table));
|
|
394
|
-
|
|
395
|
-
return {
|
|
396
|
-
format: "native",
|
|
397
|
-
version: typeof candidate.version === "number" ? candidate.version : 0,
|
|
398
|
-
exportedAt: typeof candidate.exportedAt === "string" ? candidate.exportedAt : "",
|
|
399
|
-
rows,
|
|
400
|
-
total,
|
|
401
|
-
warnings,
|
|
402
|
-
};
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
/** Validates and applies a native payload. Shared by both formats. */
|
|
406
|
-
private async apply(
|
|
407
|
-
payload: unknown,
|
|
408
|
-
tenantId: string,
|
|
409
|
-
selection?: BackupRestoreSelection,
|
|
410
|
-
): Promise<{ restored: Record<string, number>; skipped: Record<string, number>; warnings: readonly RestoreWarning[] }> {
|
|
411
|
-
const validation = validateRestorePayload(payload, tenantId);
|
|
412
|
-
if (!validation.ok) {
|
|
413
|
-
throw new ConsoleDomainError("invalid_request", 400, validation.error);
|
|
414
|
-
}
|
|
415
|
-
try {
|
|
416
|
-
const scoped = restrictToSelection(validation.value, selection);
|
|
417
|
-
const compatible = await omitRowsForUnavailableProviders(this.options.db, scoped, tenantId);
|
|
418
|
-
const applied = await applyRestore(this.options.db, compatible, restoreOrder(), tenantId);
|
|
419
|
-
return { ...applied, warnings: compatible.warnings };
|
|
420
|
-
} catch (error) {
|
|
421
|
-
// The message carries table and column names only — the store never
|
|
422
|
-
// interpolates a cell value — so a failed restore cannot leak credentials
|
|
423
|
-
// into a log or an HTTP response.
|
|
424
|
-
throw new ConsoleDomainError(
|
|
425
|
-
"restore_failed",
|
|
426
|
-
500,
|
|
427
|
-
`restore failed and was rolled back: ${error instanceof Error ? error.message : "unknown error"}`,
|
|
428
|
-
);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/** Permanently deletes selected tenant configuration after re-authentication. */
|
|
433
|
-
async deleteAll(password: unknown, scopes: readonly DeleteAllScope[], tenantId: string): Promise<DeleteAllResult> {
|
|
434
|
-
if (typeof password !== "string" || !(await this.options.verifyPassword(password))) {
|
|
435
|
-
throw new ConsoleDomainError("unauthorized", 401, "password is incorrect");
|
|
436
|
-
}
|
|
437
|
-
if (scopes.length === 0) {
|
|
438
|
-
throw new ConsoleDomainError("invalid_request", 400, "select at least one delete scope");
|
|
439
|
-
}
|
|
440
|
-
try {
|
|
441
|
-
return await deleteAll(this.options.db, scopes, tenantId);
|
|
442
|
-
} catch (error) {
|
|
443
|
-
throw new ConsoleDomainError(
|
|
444
|
-
"delete_failed",
|
|
445
|
-
500,
|
|
446
|
-
`delete failed and was rolled back: ${error instanceof Error ? error.message : "unknown error"}`,
|
|
447
|
-
);
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/** Counts a section without building its payload. */
|
|
452
|
-
async preview(
|
|
453
|
-
sections: readonly BackupSection[] | undefined,
|
|
454
|
-
tenantId: string,
|
|
455
|
-
): Promise<Record<string, number>> {
|
|
456
|
-
const stream = exportBackupStream(
|
|
457
|
-
this.options.db,
|
|
458
|
-
sectionsOrDefault(sections).flatMap((section) => tablesForSection(section)),
|
|
459
|
-
tenantId,
|
|
460
|
-
);
|
|
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;
|
|
467
|
-
}
|
|
468
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Backup and restore operations, plus the import path for a router export.
|
|
3
|
+
*
|
|
4
|
+
* Two things every entry point here has in common:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Nothing is written before the whole payload validates.** Validation
|
|
7
|
+
* resolves every table and column against the live schema, so a stale or
|
|
8
|
+
* hostile file fails with the offending name and the database is untouched.
|
|
9
|
+
* 2. **Everything happens in one transaction** (see `store.applyRestore`), so a
|
|
10
|
+
* mid-restore SQL error rolls back rather than leaving half a
|
|
11
|
+
* configuration — which is the state that makes an operator's day worse than
|
|
12
|
+
* a plain failure would.
|
|
13
|
+
*
|
|
14
|
+
* Export is a JSON object at the operator's explicit request, and it carries
|
|
15
|
+
* provider credentials and API keys decrypted — the only form in which they can
|
|
16
|
+
* be restored into an install with a different encryption key (see
|
|
17
|
+
* {@link sealedColumns}). So the file is as sensitive as the database: the layer
|
|
18
|
+
* doc and the dashboard copy say so, and export requires password
|
|
19
|
+
* re-authentication.
|
|
20
|
+
*/
|
|
21
|
+
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
22
|
+
import type { Table } from "drizzle-orm";
|
|
23
|
+
import {
|
|
24
|
+
models,
|
|
25
|
+
providerAccounts,
|
|
26
|
+
providerOauthStates,
|
|
27
|
+
providerRoutingSettings,
|
|
28
|
+
providers,
|
|
29
|
+
tenantDisabledModels,
|
|
30
|
+
} from "../../persistence/schema";
|
|
31
|
+
import { ConsoleDomainError } from "../shared/errors";
|
|
32
|
+
import { tablesForSection, tableName } from "./contracts";
|
|
33
|
+
import type {
|
|
34
|
+
BackupSection,
|
|
35
|
+
RestoreWarning,
|
|
36
|
+
ValidatedRestore,
|
|
37
|
+
ValidatedTable,
|
|
38
|
+
} from "./contracts";
|
|
39
|
+
import {
|
|
40
|
+
applyRestore,
|
|
41
|
+
decompressBackup,
|
|
42
|
+
deleteAll,
|
|
43
|
+
exportBackupStream,
|
|
44
|
+
type BackupExportStream,
|
|
45
|
+
type DeleteAllResult,
|
|
46
|
+
type DeleteAllScope,
|
|
47
|
+
} from "./store";
|
|
48
|
+
import { detectFormat, restoreOrder, validateRestorePayload } from "./validate";
|
|
49
|
+
import { convert9RouterBackup, type ImportReport } from "./nine-router";
|
|
50
|
+
import { isRecord } from "../../protocol/codecs";
|
|
51
|
+
|
|
52
|
+
interface BackupServiceOptions {
|
|
53
|
+
readonly db: CartethyiaDatabase;
|
|
54
|
+
/** Verifies the signed-in operator's console password; supplied by the router. */
|
|
55
|
+
readonly verifyPassword: (password: string) => Promise<boolean>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface ExportOptions {
|
|
59
|
+
readonly sections?: readonly BackupSection[];
|
|
60
|
+
readonly password: string;
|
|
61
|
+
/** The tenant whose rows are exported. Never another tenant's. */
|
|
62
|
+
readonly tenantId: string;
|
|
63
|
+
/** Gzips the file. Changes nothing about what the payload carries. */
|
|
64
|
+
readonly compressed?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One table as it appears in a backup file, and whether it can be restored. */
|
|
68
|
+
interface BackupInspectionRow {
|
|
69
|
+
readonly section: string;
|
|
70
|
+
readonly table: string;
|
|
71
|
+
readonly count: number;
|
|
72
|
+
/** False when the current schema has no such table: restore will skip it. */
|
|
73
|
+
readonly supported: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A backup's cover, read without writing.
|
|
78
|
+
*
|
|
79
|
+
* `warnings` carries what a real restore would leave out, so the operator sees
|
|
80
|
+
* the loss before committing rather than after.
|
|
81
|
+
*/
|
|
82
|
+
export interface BackupInspection {
|
|
83
|
+
readonly format: "native" | "nine_router";
|
|
84
|
+
readonly version: number;
|
|
85
|
+
readonly exportedAt: string;
|
|
86
|
+
readonly rows: readonly BackupInspectionRow[];
|
|
87
|
+
readonly total: number;
|
|
88
|
+
readonly warnings: readonly RestoreWarning[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ImportResult {
|
|
92
|
+
readonly restored: Record<string, number>;
|
|
93
|
+
readonly skipped: Record<string, number>;
|
|
94
|
+
/** Parts of the file that could not be restored. Empty means it all applied. */
|
|
95
|
+
readonly warnings: readonly RestoreWarning[];
|
|
96
|
+
readonly report?: ImportReport;
|
|
97
|
+
readonly format: "native" | "nine_router";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Which parts of a backup file a restore should actually write.
|
|
102
|
+
*
|
|
103
|
+
* Absent or empty means "everything", which is the historical behavior and
|
|
104
|
+
* what the dashboard's checklist defaults to. A table named here but absent
|
|
105
|
+
* from the file is simply not restored — it is not an error, because the
|
|
106
|
+
* checklist is built from an inspection of *this* file and a selection never
|
|
107
|
+
* invents a table.
|
|
108
|
+
*/
|
|
109
|
+
interface BackupRestoreSelection {
|
|
110
|
+
/** Table names to restore; absent or empty restores every table present. */
|
|
111
|
+
readonly tables?: readonly string[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Narrows a validated backup to the tables the operator selected.
|
|
116
|
+
*
|
|
117
|
+
* The tenant row is the one thing that is not independently selectable: it is
|
|
118
|
+
* not a table in either section (`BACKUP_SECTIONS` are config and telemetry),
|
|
119
|
+
* so it never appears on the checklist, and every tenant-scoped table
|
|
120
|
+
* cascades from it. Dropping it because it was not named would make a partial
|
|
121
|
+
* restore fail on a foreign key for a table the operator *did* ask for. So it
|
|
122
|
+
* rides along with any config selection and is written only when one is made.
|
|
123
|
+
*
|
|
124
|
+
* A table named in the selection but missing from the file is a no-op rather
|
|
125
|
+
* than an error: the checklist is built from an inspection of this file, but
|
|
126
|
+
* the selection is validated as names only, and refusing to restore anything
|
|
127
|
+
* over one stale name would be worse than ignoring it.
|
|
128
|
+
*/
|
|
129
|
+
function restrictToSelection(
|
|
130
|
+
validation: ValidatedRestore,
|
|
131
|
+
selection: BackupRestoreSelection | undefined,
|
|
132
|
+
): ValidatedRestore {
|
|
133
|
+
const wanted = selection?.tables;
|
|
134
|
+
if (wanted === undefined || wanted.length === 0) return validation;
|
|
135
|
+
const selected = new Set(wanted);
|
|
136
|
+
const tables = new Map<Table, ValidatedTable>();
|
|
137
|
+
for (const [table, entry] of validation.tables) {
|
|
138
|
+
if (selected.has(tableName(table))) tables.set(table, entry);
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
...validation,
|
|
142
|
+
tables,
|
|
143
|
+
tenantRows: tables.size > 0 ? validation.tenantRows : [],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function sectionsOrDefault(sections: readonly BackupSection[] | undefined): readonly BackupSection[] {
|
|
148
|
+
if (sections === undefined || sections.length === 0) return ["config"];
|
|
149
|
+
for (const section of sections) {
|
|
150
|
+
if (section !== "config" && section !== "telemetry") {
|
|
151
|
+
throw new ConsoleDomainError("invalid_request", 400, `unknown backup section "${String(section)}"`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return sections;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The bytes of an uploaded backup file, as carried by a string. */
|
|
158
|
+
function toBackupBytes(input: string): Uint8Array {
|
|
159
|
+
// Base64 is the only way file bytes travel inside a JSON body. A string that
|
|
160
|
+
// already is JSON is taken as the file's text; anything else is the encoded
|
|
161
|
+
// file, and a malformed payload still fails at `JSON.parse` rather than here.
|
|
162
|
+
if (input.trimStart().startsWith("{")) return Buffer.from(input, "utf8");
|
|
163
|
+
return Buffer.from(input, "base64");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Accepts a backup in any form the operator can hand it over, and parses it.
|
|
168
|
+
*
|
|
169
|
+
* The export offers a compressed download and names it `.json.gz`, so the file
|
|
170
|
+
* on the operator's disk is gzip bytes. The restore path used to read its body
|
|
171
|
+
* as JSON text, which meant their own backup was the one file it could not
|
|
172
|
+
* import. The shapes handled here are the ways a file actually arrives: a JSON
|
|
173
|
+
* object (the dashboard decodes the file in the browser), the export's own
|
|
174
|
+
* `{ bytes }` envelope, the file's text, and its bytes — base64-encoded for a
|
|
175
|
+
* JSON body, or raw.
|
|
176
|
+
*
|
|
177
|
+
* Whether it is compressed is decided by the bytes' magic, never by the
|
|
178
|
+
* filename: a `.json` that is really gzip is restored, and a `.gz` that is not
|
|
179
|
+
* is too.
|
|
180
|
+
*/
|
|
181
|
+
function normalizeBackupInput(input: unknown): unknown {
|
|
182
|
+
if (input === null || input === undefined) return input;
|
|
183
|
+
if (input instanceof Uint8Array) return parseBackupText(decompressBackup(input));
|
|
184
|
+
if (input instanceof ArrayBuffer) return parseBackupText(decompressBackup(new Uint8Array(input)));
|
|
185
|
+
if (typeof input === "string") return parseBackupText(decompressBackup(toBackupBytes(input)));
|
|
186
|
+
// The export's own envelope, handed straight back.
|
|
187
|
+
if (isRecord(input) && typeof input["bytes"] === "string" && Object.keys(input).length <= 3) {
|
|
188
|
+
return parseBackupText(decompressBackup(toBackupBytes(input["bytes"])));
|
|
189
|
+
}
|
|
190
|
+
return input;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parseBackupText(text: string): unknown {
|
|
194
|
+
try {
|
|
195
|
+
return JSON.parse(text) as unknown;
|
|
196
|
+
} catch {
|
|
197
|
+
throw new ConsoleDomainError("invalid_request", 400, "that file is not a JSON backup");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function omitRowsForUnavailableProviders(
|
|
202
|
+
db: CartethyiaDatabase,
|
|
203
|
+
validation: ValidatedRestore,
|
|
204
|
+
tenantId: string,
|
|
205
|
+
): Promise<ValidatedRestore> {
|
|
206
|
+
const existingProviders = await db
|
|
207
|
+
.select({ id: providers.id, tenantId: providers.tenantId })
|
|
208
|
+
.from(providers);
|
|
209
|
+
const importedProviders = validation.tables.get(providers)?.rows ?? [];
|
|
210
|
+
const providerIds = new Set([
|
|
211
|
+
...existingProviders
|
|
212
|
+
.filter((provider) => provider.tenantId === null || provider.tenantId === tenantId)
|
|
213
|
+
.map((provider) => provider.id),
|
|
214
|
+
...importedProviders.map((provider) => String(provider["id"])),
|
|
215
|
+
]);
|
|
216
|
+
const tables = new Map(validation.tables);
|
|
217
|
+
const warnings = [...validation.warnings];
|
|
218
|
+
const counts = { ...validation.counts };
|
|
219
|
+
|
|
220
|
+
for (const table of [providerAccounts, models, tenantDisabledModels, providerRoutingSettings]) {
|
|
221
|
+
const entry = tables.get(table);
|
|
222
|
+
if (entry === undefined) continue;
|
|
223
|
+
const rows = entry.rows.filter(
|
|
224
|
+
(row) => typeof row["provider_id"] === "string" && providerIds.has(row["provider_id"]),
|
|
225
|
+
);
|
|
226
|
+
if (rows.length === entry.rows.length) continue;
|
|
227
|
+
tables.set(table, { ...entry, rows });
|
|
228
|
+
counts[tableName(table)] = rows.length;
|
|
229
|
+
warnings.push({
|
|
230
|
+
table: tableName(table),
|
|
231
|
+
column: "provider_id",
|
|
232
|
+
reason: "referenced a provider not available in this installation; those rows were skipped",
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const accountRows = tables.get(providerAccounts)?.rows ?? [];
|
|
237
|
+
const replacesTenantAccounts = accountRows.length > 0;
|
|
238
|
+
const existingAccounts = await db
|
|
239
|
+
.select({ id: providerAccounts.id, tenantId: providerAccounts.tenantId })
|
|
240
|
+
.from(providerAccounts);
|
|
241
|
+
const accountIds = new Set(
|
|
242
|
+
existingAccounts
|
|
243
|
+
.filter((account) => !replacesTenantAccounts || account.tenantId !== tenantId)
|
|
244
|
+
.map((account) => account.id),
|
|
245
|
+
);
|
|
246
|
+
for (const account of accountRows) {
|
|
247
|
+
if (typeof account["id"] === "string") accountIds.add(account["id"]);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const oauthEntry = tables.get(providerOauthStates);
|
|
251
|
+
if (oauthEntry !== undefined) {
|
|
252
|
+
const rows = oauthEntry.rows.filter(
|
|
253
|
+
(row) =>
|
|
254
|
+
typeof row["provider_account_id"] === "string" && accountIds.has(row["provider_account_id"]),
|
|
255
|
+
);
|
|
256
|
+
if (rows.length !== oauthEntry.rows.length) {
|
|
257
|
+
tables.set(providerOauthStates, { ...oauthEntry, rows });
|
|
258
|
+
counts[tableName(providerOauthStates)] = rows.length;
|
|
259
|
+
warnings.push({
|
|
260
|
+
table: tableName(providerOauthStates),
|
|
261
|
+
column: "provider_account_id",
|
|
262
|
+
reason: "referenced an account not restored or available in this installation; those rows were skipped",
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { ...validation, tables, counts, warnings };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export class BackupService {
|
|
271
|
+
constructor(private readonly options: BackupServiceOptions) {}
|
|
272
|
+
|
|
273
|
+
async export(options: ExportOptions): Promise<BackupExportStream> {
|
|
274
|
+
if (!(await this.options.verifyPassword(options.password))) {
|
|
275
|
+
throw new ConsoleDomainError("unauthorized", 401, "password is incorrect");
|
|
276
|
+
}
|
|
277
|
+
const sections = sectionsOrDefault(options.sections);
|
|
278
|
+
return exportBackupStream(
|
|
279
|
+
this.options.db,
|
|
280
|
+
sections.flatMap((section) => tablesForSection(section)),
|
|
281
|
+
options.tenantId,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Restores a payload, detecting its format first.
|
|
287
|
+
*
|
|
288
|
+
* A native payload is validated as-is. A router export is converted first —
|
|
289
|
+
* and conversion is itself validation, because it refuses provider ids it
|
|
290
|
+
* cannot map rather than guessing — then the converted payload goes through
|
|
291
|
+
* the identical validation and transaction as a native one. There is one
|
|
292
|
+
* write path, not two.
|
|
293
|
+
*
|
|
294
|
+
* `tenantId` is the restoring tenant, and it is the *only* tenant any write
|
|
295
|
+
* can reach: the payload cannot name a different one, because every table is
|
|
296
|
+
* filtered by ownership and the tenant row is forced to this id.
|
|
297
|
+
*/
|
|
298
|
+
async restore(
|
|
299
|
+
password: unknown,
|
|
300
|
+
payload: unknown,
|
|
301
|
+
tenantId: string,
|
|
302
|
+
selection?: BackupRestoreSelection,
|
|
303
|
+
): Promise<ImportResult> {
|
|
304
|
+
if (typeof password !== "string" || !(await this.options.verifyPassword(password))) {
|
|
305
|
+
throw new ConsoleDomainError("unauthorized", 401, "password is incorrect");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const detected = detectFormat(normalizeBackupInput(payload));
|
|
309
|
+
if (detected.kind === "unknown") {
|
|
310
|
+
throw new ConsoleDomainError("invalid_request", 400, detected.reason);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (detected.kind === "nine_router") {
|
|
314
|
+
const { payload: converted, report } = convert9RouterBackup(detected.payload, tenantId);
|
|
315
|
+
const result = await this.apply(converted, tenantId, selection);
|
|
316
|
+
return { ...result, report, format: "nine_router" };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const result = await this.apply(detected.payload, tenantId, selection);
|
|
320
|
+
return { ...result, format: "native" };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Restores a native backup onto a brand-new tenant during first boot.
|
|
325
|
+
*
|
|
326
|
+
* There is no operator to re-authenticate, and no tenant to scope the write
|
|
327
|
+
* to. The caller has already proven that no console user exists and holds
|
|
328
|
+
* the setup lock.
|
|
329
|
+
*
|
|
330
|
+
* The configuration is restored; the sign-in accounts are not, because a
|
|
331
|
+
* backup does not carry them (see {@link CONFIG_TABLES}). The caller creates
|
|
332
|
+
* the administrator for the install it just recovered into.
|
|
333
|
+
*/
|
|
334
|
+
async restoreFirstBoot(payload: unknown, tenantId: string): Promise<ImportResult> {
|
|
335
|
+
const detected = detectFormat(normalizeBackupInput(payload));
|
|
336
|
+
if (detected.kind !== "native") {
|
|
337
|
+
throw new ConsoleDomainError(
|
|
338
|
+
"invalid_request",
|
|
339
|
+
400,
|
|
340
|
+
detected.kind === "unknown" ? detected.reason : "first-boot restore accepts a Cartethyia backup only",
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
const result = await this.apply(detected.payload, tenantId);
|
|
344
|
+
return { ...result, format: "native" };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Describes a backup without writing any of it.
|
|
349
|
+
*
|
|
350
|
+
* The dashboard's restore wizard peeks at a file before committing to it, and
|
|
351
|
+
* "which of these tables does the current build actually have" is a question
|
|
352
|
+
* only the live schema can answer — a hand-maintained list of table names in
|
|
353
|
+
* the browser would drift the moment a migration landed. So the answer comes
|
|
354
|
+
* from the same validation a restore runs, and writes nothing.
|
|
355
|
+
*/
|
|
356
|
+
inspect(payload: unknown): BackupInspection {
|
|
357
|
+
const detected = detectFormat(normalizeBackupInput(payload));
|
|
358
|
+
if (detected.kind === "unknown") {
|
|
359
|
+
throw new ConsoleDomainError("invalid_request", 400, detected.reason);
|
|
360
|
+
}
|
|
361
|
+
const candidate = detected.kind === "native" ? detected.payload : null;
|
|
362
|
+
if (candidate === null) {
|
|
363
|
+
// A router export has no sections to list; it is converted at restore
|
|
364
|
+
// time, so all that can be said here is what it is.
|
|
365
|
+
return {
|
|
366
|
+
format: "nine_router",
|
|
367
|
+
version: 0,
|
|
368
|
+
exportedAt: "",
|
|
369
|
+
rows: [],
|
|
370
|
+
total: 0,
|
|
371
|
+
warnings: [],
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
const validation = validateRestorePayload(candidate, "");
|
|
375
|
+
const warnings = validation.ok ? validation.value.warnings : [];
|
|
376
|
+
const supported = new Set(validation.ok ? [...validation.value.tables.keys()].map(tableName) : []);
|
|
377
|
+
// `tenants` is diverted to `validation.value.tenantRows` during validate —
|
|
378
|
+
// not included in `tables` — so it would show as "unsupported" in the
|
|
379
|
+
// preview for any backup that carries it. Mark it supported explicitly.
|
|
380
|
+
if (validation.ok) supported.add("tenants");
|
|
381
|
+
|
|
382
|
+
const rows: BackupInspectionRow[] = [];
|
|
383
|
+
let total = 0;
|
|
384
|
+
const sections = isRecord(candidate.sections) ? candidate.sections : {};
|
|
385
|
+
for (const [section, value] of Object.entries(sections)) {
|
|
386
|
+
if (!isRecord(value)) continue;
|
|
387
|
+
for (const [table, list] of Object.entries(value)) {
|
|
388
|
+
if (!Array.isArray(list)) continue;
|
|
389
|
+
rows.push({ section, table, count: list.length, supported: supported.has(table) });
|
|
390
|
+
total += list.length;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
rows.sort((left, right) => right.count - left.count || left.table.localeCompare(right.table));
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
format: "native",
|
|
397
|
+
version: typeof candidate.version === "number" ? candidate.version : 0,
|
|
398
|
+
exportedAt: typeof candidate.exportedAt === "string" ? candidate.exportedAt : "",
|
|
399
|
+
rows,
|
|
400
|
+
total,
|
|
401
|
+
warnings,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Validates and applies a native payload. Shared by both formats. */
|
|
406
|
+
private async apply(
|
|
407
|
+
payload: unknown,
|
|
408
|
+
tenantId: string,
|
|
409
|
+
selection?: BackupRestoreSelection,
|
|
410
|
+
): Promise<{ restored: Record<string, number>; skipped: Record<string, number>; warnings: readonly RestoreWarning[] }> {
|
|
411
|
+
const validation = validateRestorePayload(payload, tenantId);
|
|
412
|
+
if (!validation.ok) {
|
|
413
|
+
throw new ConsoleDomainError("invalid_request", 400, validation.error);
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
const scoped = restrictToSelection(validation.value, selection);
|
|
417
|
+
const compatible = await omitRowsForUnavailableProviders(this.options.db, scoped, tenantId);
|
|
418
|
+
const applied = await applyRestore(this.options.db, compatible, restoreOrder(), tenantId);
|
|
419
|
+
return { ...applied, warnings: compatible.warnings };
|
|
420
|
+
} catch (error) {
|
|
421
|
+
// The message carries table and column names only — the store never
|
|
422
|
+
// interpolates a cell value — so a failed restore cannot leak credentials
|
|
423
|
+
// into a log or an HTTP response.
|
|
424
|
+
throw new ConsoleDomainError(
|
|
425
|
+
"restore_failed",
|
|
426
|
+
500,
|
|
427
|
+
`restore failed and was rolled back: ${error instanceof Error ? error.message : "unknown error"}`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Permanently deletes selected tenant configuration after re-authentication. */
|
|
433
|
+
async deleteAll(password: unknown, scopes: readonly DeleteAllScope[], tenantId: string): Promise<DeleteAllResult> {
|
|
434
|
+
if (typeof password !== "string" || !(await this.options.verifyPassword(password))) {
|
|
435
|
+
throw new ConsoleDomainError("unauthorized", 401, "password is incorrect");
|
|
436
|
+
}
|
|
437
|
+
if (scopes.length === 0) {
|
|
438
|
+
throw new ConsoleDomainError("invalid_request", 400, "select at least one delete scope");
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
return await deleteAll(this.options.db, scopes, tenantId);
|
|
442
|
+
} catch (error) {
|
|
443
|
+
throw new ConsoleDomainError(
|
|
444
|
+
"delete_failed",
|
|
445
|
+
500,
|
|
446
|
+
`delete failed and was rolled back: ${error instanceof Error ? error.message : "unknown error"}`,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Counts a section without building its payload. */
|
|
452
|
+
async preview(
|
|
453
|
+
sections: readonly BackupSection[] | undefined,
|
|
454
|
+
tenantId: string,
|
|
455
|
+
): Promise<Record<string, number>> {
|
|
456
|
+
const stream = exportBackupStream(
|
|
457
|
+
this.options.db,
|
|
458
|
+
sectionsOrDefault(sections).flatMap((section) => tablesForSection(section)),
|
|
459
|
+
tenantId,
|
|
460
|
+
);
|
|
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;
|
|
467
|
+
}
|
|
468
|
+
}
|