cartethyia 2.7.2 → 2.7.3
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-2TJD37Hb.js} +1 -1
- package/dist/dashboard/assets/BannedPage-2TJD37Hb.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-xefJNRmJ.js +1 -0
- package/dist/dashboard/assets/CliToolDetailPage-xefJNRmJ.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-Rj9iHQ3F.js +1 -0
- package/dist/dashboard/assets/CliToolsPage-Rj9iHQ3F.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-CXGS_Ct-.js → CombosPage-JEbYJFxw.js} +1 -1
- package/dist/dashboard/assets/CombosPage-JEbYJFxw.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-DGnGNYnM.js → ConsoleLogPage-C3xx_inM.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-C3xx_inM.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-Dw306gJU.js +1 -0
- package/dist/dashboard/assets/CustomizationPage-Dw306gJU.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-jaBo1TWV.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-jaBo1TWV.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-BdmRLnq2.js → LoginPage-C4w7vea-.js} +1 -1
- package/dist/dashboard/assets/LoginPage-C4w7vea-.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-BH8Tehdi.js → ModelPicker-BtPcAfot.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-BtPcAfot.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-CGC5gNs1.js +1 -0
- package/dist/dashboard/assets/OverviewPage-CGC5gNs1.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-DQcthNGt.js +4 -0
- package/dist/dashboard/assets/ProviderDetailPage-DQcthNGt.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-0OsXRGXe.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-0OsXRGXe.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-DdPc8slb.js +9 -0
- package/dist/dashboard/assets/ProxyPage-DdPc8slb.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-7P2n--Fv.js → QuotaPage-Ci5O4BnH.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-Ci5O4BnH.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-CxZ1fTkN.js → SettingsPage-CDZjTt87.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-CDZjTt87.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-BQS0Rx_u.js +1 -0
- package/dist/dashboard/assets/SetupPage-BQS0Rx_u.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-Dqf4zpS-.js → SortableList-BAL5npBC.js} +1 -1
- package/dist/dashboard/assets/SortableList-BAL5npBC.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-Byj2lAEm.js → StudioPage-TP7voAQ0.js} +3 -3
- package/dist/dashboard/assets/StudioPage-TP7voAQ0.js.gz +0 -0
- package/dist/dashboard/assets/{UsagePage-CTkLAuRc.js → UsagePage-Cj_fEzhg.js} +13 -13
- package/dist/dashboard/assets/UsagePage-Cj_fEzhg.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-CUi6UXsR.js → api-keys-MbI7e7rt.js} +1 -1
- package/dist/dashboard/assets/api-keys-MbI7e7rt.js.gz +0 -0
- package/dist/dashboard/assets/app-B2vyPoII.css +2 -0
- package/dist/dashboard/assets/app-B2vyPoII.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-67xXH5rf.js → assert-C0YYuZJ4.js} +1 -1
- package/dist/dashboard/assets/assert-C0YYuZJ4.js.gz +0 -0
- package/dist/dashboard/assets/{badge-DC2L43Mj.js → badge-B_DMpMJN.js} +1 -1
- package/dist/dashboard/assets/badge-B_DMpMJN.js.gz +0 -0
- package/dist/dashboard/assets/{banner-BBVcb0J3.js → banner-DMrMPmth.js} +1 -1
- package/dist/dashboard/assets/banner-DMrMPmth.js.gz +0 -0
- package/dist/dashboard/assets/{card-Cca1XC6G.js → card-Be5Uzamx.js} +1 -1
- package/dist/dashboard/assets/card-Be5Uzamx.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-KvsWa1Jl.js → cli-tools-muFRphDu.js} +1 -1
- package/dist/dashboard/assets/cli-tools-muFRphDu.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-DUzEbynq.js → clipboard-button-DOfrVmsi.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-DOfrVmsi.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-DFvWhwTa.js → dialog-C9omdjQt.js} +1 -1
- package/dist/dashboard/assets/dialog-C9omdjQt.js.gz +0 -0
- package/dist/dashboard/assets/drawer-Bm8WKaGY.js +1 -0
- package/dist/dashboard/assets/drawer-Bm8WKaGY.js.gz +0 -0
- package/dist/dashboard/assets/{entry-_xHo5x6r.js → entry-DS90gUks.js} +1 -1
- package/dist/dashboard/assets/entry-DS90gUks.js.gz +0 -0
- package/dist/dashboard/assets/{entry-ClCMCWWQ.js → entry-j40LJotO.js} +3 -3
- package/dist/dashboard/assets/entry-j40LJotO.js.gz +0 -0
- package/dist/dashboard/assets/{entry-Bqy8RFvM.js → entry-q5HwZ8nN.js} +1 -1
- package/dist/dashboard/assets/entry-q5HwZ8nN.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-BTScrQpe.js} +1 -1
- package/dist/dashboard/assets/formatters-BTScrQpe.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-C9e8Ts7e.js → github-badge-Orjuzemr.js} +1 -1
- package/dist/dashboard/assets/github-badge-Orjuzemr.js.gz +0 -0
- package/dist/dashboard/assets/{index-DFVFrnWW.js → index-cPrX5vn6.js} +2 -2
- package/dist/dashboard/assets/index-cPrX5vn6.js.gz +0 -0
- package/dist/dashboard/assets/{inline-BFUcvllj.js → inline-Dvux8w-0.js} +1 -1
- package/dist/dashboard/assets/inline-Dvux8w-0.js.gz +0 -0
- package/dist/dashboard/assets/{input-C_ILAvBb.js → input-CU1Rgf3p.js} +1 -1
- package/dist/dashboard/assets/input-CU1Rgf3p.js.gz +0 -0
- package/dist/dashboard/assets/{layout-CBIyT6Rt.js → layout-C_7A6H55.js} +1 -1
- package/dist/dashboard/assets/layout-C_7A6H55.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-ebJ_-Nsb.js → list-row-CzFvogbj.js} +1 -1
- package/dist/dashboard/assets/list-row-CzFvogbj.js.gz +0 -0
- package/dist/dashboard/assets/{live-D0mm--ME.js → live-KaG15HLQ.js} +1 -1
- package/dist/dashboard/assets/live-KaG15HLQ.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-sMD965oO.js → mutation-JJGQm8TD.js} +1 -1
- package/dist/dashboard/assets/mutation-JJGQm8TD.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-BO9dKaFr.js → numbers-BNDZnsUE.js} +1 -1
- package/dist/dashboard/assets/numbers-BNDZnsUE.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-CfL_Zdoh.js → query-keys-CLaNz8Xz.js} +1 -1
- package/dist/dashboard/assets/query-keys-CLaNz8Xz.js.gz +0 -0
- package/dist/dashboard/assets/{routing-BnIpi1IJ.js → routing-BEH32QOC.js} +1 -1
- package/dist/dashboard/assets/routing-BEH32QOC.js.gz +0 -0
- package/dist/dashboard/assets/{select-DBNVLdUu.js → select-B3iTetHN.js} +1 -1
- package/dist/dashboard/assets/select-B3iTetHN.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-BAFhDgX8.js → settings-Dt3Eqbnq.js} +1 -1
- package/dist/dashboard/assets/settings-Dt3Eqbnq.js.gz +0 -0
- package/dist/dashboard/assets/{stack-EMrOMt4I.js → stack-KrOrm7JZ.js} +1 -1
- package/dist/dashboard/assets/stack-KrOrm7JZ.js.gz +0 -0
- package/dist/dashboard/assets/{state-CWoo7ty5.js → state-DLS7IDMy.js} +1 -1
- package/dist/dashboard/assets/state-DLS7IDMy.js.gz +0 -0
- package/dist/dashboard/assets/{switch-CCjT6IkF.js → switch-42ItvbAs.js} +1 -1
- package/dist/dashboard/assets/switch-42ItvbAs.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-DjkLHUcE.js → useMutation-Dmnfh1Ue.js} +1 -1
- package/dist/dashboard/assets/useMutation-Dmnfh1Ue.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 +117 -115
- package/migrations/full-postgres/0026_add_qoder_native_identity.sql +20 -0
- package/migrations/full-postgres/0027_qoder_identity_genuine_seed.sql +22 -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/lite-sqlite3/0026_add_qoder_native_identity.sql +15 -0
- package/migrations/lite-sqlite3/0027_qoder_identity_genuine_seed.sql +22 -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/package.json +1 -1
- package/src/config/network.ts +1 -1
- package/src/console/api-keys/bucket-spend.ts +7 -7
- package/src/console/api-keys/contracts.ts +423 -410
- package/src/console/api-keys/routes.ts +118 -19
- package/src/console/backup/export-endpoint.ts +8 -11
- package/src/console/backup/routes.ts +40 -8
- package/src/console/backup/service.ts +11 -6
- package/src/console/backup/store.ts +384 -88
- package/src/console/dashboard-assets.ts +16 -1
- package/src/console/domain-registration.ts +2 -0
- package/src/console/quota/account-quota-tenant.ts +8 -1
- package/src/console/quota/account-quota.ts +4 -1
- 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/share/share-router.ts +39 -6
- package/src/console/share/share-stats.ts +64 -25
- package/src/console/share/share-usage.ts +24 -7
- package/src/console/system-prompt/contracts.ts +102 -0
- package/src/console/system-prompt/routes.ts +165 -0
- package/src/main.ts +11 -0
- package/src/observability/event-query.ts +118 -7
- package/src/persistence/db-pg.ts +42 -0
- package/src/persistence/schema.ts +102 -0
- package/src/persistence/stores/api-key-store.ts +753 -581
- package/src/persistence/stores/share-store.ts +65 -13
- package/src/persistence/stores/telemetry-store.ts +57 -1
- package/src/persistence/system-prompt-store.ts +104 -0
- package/src/protocol/reasoning.ts +65 -1
- package/src/protocol/request/chat.ts +1 -0
- package/src/providers/integrations/antigravity/antigravity.ts +20 -23
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +30 -2
- package/src/providers/integrations/buddy/buddy-media.ts +5 -1
- package/src/providers/integrations/buddy/codebuddy/provider.ts +5 -1
- package/src/providers/integrations/buddy/workbuddy/provider.ts +5 -1
- package/src/providers/integrations/cline/cline.ts +7 -0
- package/src/providers/integrations/github/github.ts +20 -1
- package/src/providers/integrations/hermes.ts +5 -1
- package/src/providers/integrations/kilo/kilo.ts +4 -0
- package/src/providers/integrations/openrouter/provider.ts +4 -0
- package/src/providers/integrations/qoder/qoder-checkin.ts +224 -51
- package/src/providers/integrations/qoder/qoder-native-identity-store.ts +156 -0
- package/src/providers/integrations/qoder/qoder-oauth.ts +12 -3
- package/src/providers/integrations/qoder/qoder-umid.ts +288 -0
- package/src/providers/integrations/qoder/qoder.ts +5 -3
- 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/provider-contracts.ts +8 -0
- package/src/security/access-control.ts +19 -0
- package/src/security/api-key-auth.ts +14 -1
- package/src/transport/dispatch/proxy-request.ts +31 -2
- package/src/transport/dispatch/streaming/stream.ts +16 -1
- package/src/transport/dispatch/upstream.ts +10 -0
- package/src/transport/request/preparer.ts +17 -1
- 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 +7 -0
- package/src/transport/routing/route-model.ts +53 -0
- package/src/transport/routing/router.ts +306 -256
- 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.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 +0 -1
- 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.gz +0 -0
- 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.gz +0 -0
- 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.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.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.gz +0 -0
- 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.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/routing-BnIpi1IJ.js.gz +0 -0
- package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
- 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-wvwCi4Eh.js} +0 -0
- /package/dist/dashboard/assets/{app-BaWd0puM.js.gz → app-wvwCi4Eh.js.gz} +0 -0
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
* idempotent. There is no unique constraint to lean on: `telemetry_events`
|
|
30
30
|
* has a random PK, so the natural key is checked explicitly.
|
|
31
31
|
*/
|
|
32
|
-
import { and, eq, sql } from "drizzle-orm";
|
|
33
|
-
import { gzipSync, gunzipSync } from "node:zlib";
|
|
32
|
+
import { and, eq, gt, isNotNull, or, sql } from "drizzle-orm";
|
|
33
|
+
import { gzipSync, gunzipSync, createGzip } from "node:zlib";
|
|
34
34
|
import { differsFrom } from "../../persistence/dialect";
|
|
35
|
-
import { TELEMETRY_EVENT_DIR } from "../../observability/event-log";
|
|
35
|
+
import { TELEMETRY_EVENT_DIR, type TelemetryEventRecord } from "../../observability/event-log";
|
|
36
36
|
import { queryTelemetryEvents as querySqlEvents } from "../../observability/event-query";
|
|
37
37
|
import { appendFile, mkdir } from "node:fs/promises";
|
|
38
38
|
import { getTableColumns, type Column, type SQL, type Table } from "drizzle-orm";
|
|
@@ -241,90 +241,343 @@ function ownershipOwnerColumn(parent: Table): Column {
|
|
|
241
241
|
);
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
-
/**
|
|
245
|
-
|
|
244
|
+
/**
|
|
245
|
+
* Reads every row of one table that `tenantId` owns, encoded for JSON.
|
|
246
|
+
*
|
|
247
|
+
* Exports stream: the backup of a tenant can carry hundreds of thousands of
|
|
248
|
+
* rows (the dev deployment's `api_keys` held 331k minted share children), and
|
|
249
|
+
* materializing the whole table to JSON it in one gulp was what OOM-killed the
|
|
250
|
+
* process during a backup. Every reader is therefore paged.
|
|
251
|
+
*/
|
|
252
|
+
const EXPORT_PAGE_ROWS = 500;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The composite keys of tables whose primary key is not declared column-wise.
|
|
256
|
+
* Row tables stream through a keyset walk, so the sort key must uniquely
|
|
257
|
+
* order rows — a truncated key would swallow tied rows at every page
|
|
258
|
+
* boundary.
|
|
259
|
+
*/
|
|
260
|
+
const COMPOSITE_KEYSETS: Partial<Record<string, readonly string[]>> = {
|
|
261
|
+
telemetry_usage_totals: ["tenantId", "identityType", "entityId"],
|
|
262
|
+
cli_tool_settings: ["tenantId", "apiKeyId", "toolId"],
|
|
263
|
+
provider_routing_settings: ["providerId", "tenantId"],
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The key a keyset walk of `table` pages by, or `null` for a table whose rows
|
|
268
|
+
* are contract-bounded to a handful (per-tenant settings singletons) and are
|
|
269
|
+
* read in one statement.
|
|
270
|
+
*/
|
|
271
|
+
function keysetKey(table: Table): readonly Column[] | null {
|
|
272
|
+
const name = tableName(table);
|
|
273
|
+
const columns = getTableColumns(table) as Record<string, Column>;
|
|
274
|
+
const composite = COMPOSITE_KEYSETS[name];
|
|
275
|
+
if (composite !== undefined) {
|
|
276
|
+
return composite.map((key) => {
|
|
277
|
+
const column = columns[key];
|
|
278
|
+
if (column === undefined) throw new Error(`${name}: backup keyset names missing column ${key}`);
|
|
279
|
+
return column;
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
// Single keys: a uuid `id` first, then the per-table natural keys
|
|
283
|
+
// (`provider_oauth_states` → its account, `share_popups` → its template).
|
|
284
|
+
// `tenantId` is last: it is the primary key of nothing but the per-tenant
|
|
285
|
+
// settings singletons, so single-column `id` always wins where one exists.
|
|
286
|
+
for (const single of ["id", "providerAccountId", "apiKeyId", "tenantId"] as const) {
|
|
287
|
+
const column = columns[single];
|
|
288
|
+
if (column !== undefined) return [column];
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Rows past the key of the previous page, in the key's own order. */
|
|
294
|
+
function keysetAfter(key: readonly Column[], last: readonly unknown[]): SQL {
|
|
295
|
+
return or(
|
|
296
|
+
...key.map((column, index) =>
|
|
297
|
+
and(...key.slice(0, index).map((prior, at) => eq(prior, last[at])), gt(column, last[index])),
|
|
298
|
+
),
|
|
299
|
+
) as SQL;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Yields the owned rows of `table` in key order, one statement per page. */
|
|
303
|
+
async function* pagedRows(
|
|
246
304
|
db: CartethyiaDatabase,
|
|
247
305
|
table: Table,
|
|
248
306
|
tenantId: string,
|
|
249
|
-
):
|
|
307
|
+
): AsyncGenerator<Record<string, unknown>, void, void> {
|
|
308
|
+
const filter = ownedByFilter(table, tenantId);
|
|
309
|
+
const key = keysetKey(table);
|
|
310
|
+
if (key === null) {
|
|
311
|
+
const rows = (await db
|
|
312
|
+
.select()
|
|
313
|
+
.from(table)
|
|
314
|
+
.where(filter)
|
|
315
|
+
.limit(MAX_ROWS_PER_TABLE)) as Array<Record<string, unknown>>;
|
|
316
|
+
yield* rows;
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const entries = Object.entries(getTableColumns(table));
|
|
320
|
+
const keyNames = key.map((column) => {
|
|
321
|
+
const entry = entries.find(([, candidate]) => candidate === column);
|
|
322
|
+
if (entry === undefined) throw new Error(`${tableName(table)}: key column is not part of the table`);
|
|
323
|
+
return entry[0];
|
|
324
|
+
});
|
|
325
|
+
let last: unknown[] = [];
|
|
326
|
+
while (true) {
|
|
327
|
+
const where = last.length === 0 ? filter : and(filter, keysetAfter(key, last));
|
|
328
|
+
const rows = (await db
|
|
329
|
+
.select()
|
|
330
|
+
.from(table)
|
|
331
|
+
.where(where)
|
|
332
|
+
.orderBy(...key.map((column) => sql`${column} asc`))
|
|
333
|
+
.limit(EXPORT_PAGE_ROWS)) as Array<Record<string, unknown>>;
|
|
334
|
+
if (rows.length === 0) return;
|
|
335
|
+
yield* rows;
|
|
336
|
+
if (rows.length < EXPORT_PAGE_ROWS) return;
|
|
337
|
+
const tail = rows[rows.length - 1] as Record<string, unknown>;
|
|
338
|
+
last = keyNames.map((name) => tail[name]);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Encodes one read row for the payload. */
|
|
343
|
+
function encodeRow(table: Table, row: Record<string, unknown>): BackupRow {
|
|
250
344
|
const index = columnIndex(table);
|
|
251
345
|
const sealed = sealedColumns(table);
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
346
|
+
const out: BackupRow = {};
|
|
347
|
+
for (const [name, { key, dataType }] of index) {
|
|
348
|
+
// A sealed column is written decrypted, under the name of the secret it
|
|
349
|
+
// holds — see `SEALED_COLUMNS` for why the column's own name would be a
|
|
350
|
+
// lie in a file that no longer carries its ciphertext.
|
|
351
|
+
out[payloadNameOf(table, name)] = sealed.has(name)
|
|
352
|
+
? decodeSealedForPayload(row[key])
|
|
353
|
+
: encodeCell(row[key], dataType);
|
|
354
|
+
}
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Encodes one telemetry event row the way `exportEventFile` used to. */
|
|
359
|
+
function telemetryRowOf(event: TelemetryEventRecord): BackupRow {
|
|
360
|
+
const row: BackupRow = {};
|
|
361
|
+
for (const [key, value] of Object.entries(event)) {
|
|
362
|
+
if (value === null || value === undefined) continue;
|
|
363
|
+
row[camelToSnake(key)] = key === "createdAt" ? { __date: String(value) } : value;
|
|
364
|
+
}
|
|
365
|
+
return row;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function* pagedTelemetryEvents(
|
|
369
|
+
db: CartethyiaDatabase,
|
|
370
|
+
tenantId: string,
|
|
371
|
+
): AsyncGenerator<TelemetryEventRecord, void, void> {
|
|
372
|
+
// The SQL table is the durable record now, and it is tenant-scoped and
|
|
373
|
+
// retained by the same rule as the raw files — so the export walks it
|
|
374
|
+
// newest-first through its own keyset cursor instead of scanning every raw
|
|
375
|
+
// file and filtering by tenant afterwards.
|
|
376
|
+
let cursor: string | undefined;
|
|
377
|
+
while (true) {
|
|
378
|
+
const page = await querySqlEvents(db, {
|
|
379
|
+
tenantId,
|
|
380
|
+
limit: EXPORT_PAGE_ROWS,
|
|
381
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
382
|
+
});
|
|
383
|
+
yield* page.events;
|
|
384
|
+
if (page.nextCursor === undefined) return;
|
|
385
|
+
cursor = page.nextCursor;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Streams the payload document: the requested tables, grouped into their
|
|
391
|
+
* sections, written membrane-incrementally so the process never holds a table
|
|
392
|
+
* — let alone every table and a second JSON copy — in memory. Consumed
|
|
393
|
+
* chunks concatenated are exactly the JSON the buffered variant used to
|
|
394
|
+
* stringify in one piece.
|
|
395
|
+
*/
|
|
396
|
+
/**
|
|
397
|
+
* Streams the payload document: the requested tables, grouped into their
|
|
398
|
+
* sections, written incrementally so the process never holds a table — let
|
|
399
|
+
* alone every table and a second JSON copy — in memory. Consumed chunks
|
|
400
|
+
* concatenated are exactly the JSON the buffered variant used to stringify in
|
|
401
|
+
* one piece.
|
|
402
|
+
*/
|
|
403
|
+
async function* backupJsonChunks(
|
|
404
|
+
db: CartethyiaDatabase,
|
|
405
|
+
tables: readonly Table[],
|
|
406
|
+
tenantId: string,
|
|
407
|
+
finishCounts: (counts: Record<string, number>) => void,
|
|
408
|
+
): AsyncGenerator<string, void, void> {
|
|
409
|
+
const counts: Record<string, number> = {};
|
|
410
|
+
|
|
411
|
+
const configSet = new Set<Table>([...CONFIG_TABLES]);
|
|
412
|
+
const telemetrySet = new Set<Table>(TELEMETRY_TABLES);
|
|
413
|
+
const configTables = tables.filter((table) => configSet.has(table));
|
|
414
|
+
const telemetryTables = tables.filter((table) => telemetrySet.has(table));
|
|
415
|
+
const outside = tables.filter((table) => !configSet.has(table) && !telemetrySet.has(table));
|
|
416
|
+
|
|
417
|
+
// The OAuth projection is derived from the accounts block itself, so those
|
|
418
|
+
// two tables materialize (both are operator-sized, dozens of rows) instead
|
|
419
|
+
// of being read twice.
|
|
420
|
+
const materialized = new Map<Table, readonly BackupRow[]>();
|
|
421
|
+
const materializedRows = async (table: Table): Promise<readonly BackupRow[]> => {
|
|
422
|
+
let rows: readonly BackupRow[] | undefined = materialized.get(table);
|
|
423
|
+
if (rows === undefined) {
|
|
424
|
+
const built: BackupRow[] = [];
|
|
425
|
+
for await (const source of pagedRows(db, table, tenantId)) {
|
|
426
|
+
built.push(encodeRow(table, source));
|
|
427
|
+
}
|
|
428
|
+
rows = built;
|
|
429
|
+
materialized.set(table, rows);
|
|
266
430
|
}
|
|
267
|
-
return
|
|
268
|
-
}
|
|
431
|
+
return rows;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
/** Writes one table's array into the document, tracking the exported count. */
|
|
435
|
+
async function* writeTable(table: Table, sources?: readonly BackupRow[]): AsyncGenerator<string, void, void> {
|
|
436
|
+
const name = tableName(table);
|
|
437
|
+
let first = true;
|
|
438
|
+
let written = 0;
|
|
439
|
+
yield `"${name}":[`;
|
|
440
|
+
if (sources === undefined) {
|
|
441
|
+
for await (const source of pagedRows(db, table, tenantId)) {
|
|
442
|
+
if (written >= MAX_ROWS_PER_TABLE) break;
|
|
443
|
+
yield `${first ? "" : ","}${JSON.stringify(encodeRow(table, source))}`;
|
|
444
|
+
first = false;
|
|
445
|
+
written += 1;
|
|
446
|
+
}
|
|
447
|
+
} else {
|
|
448
|
+
for (const row of sources) {
|
|
449
|
+
yield `${first ? "" : ","}${JSON.stringify(row)}`;
|
|
450
|
+
first = false;
|
|
451
|
+
written += 1;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
yield "]";
|
|
455
|
+
counts[name] = written;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
yield `{"app":${JSON.stringify(BACKUP_APP)},"version":${BACKUP_VERSION},"exportedAt":${JSON.stringify(new Date().toISOString())},"sections":{`;
|
|
460
|
+
if (configTables.length > 0) {
|
|
461
|
+
yield `"config":{`;
|
|
462
|
+
let configIndex = 0;
|
|
463
|
+
for (const table of configTables) {
|
|
464
|
+
const sources =
|
|
465
|
+
table === providerAccounts || table === providerOauthStates ? await materializedRows(table) : undefined;
|
|
466
|
+
yield configIndex > 0 ? "," : "";
|
|
467
|
+
yield* writeTable(table, sources);
|
|
468
|
+
configIndex += 1;
|
|
469
|
+
}
|
|
470
|
+
yield `}`;
|
|
471
|
+
}
|
|
472
|
+
if (telemetryTables.length > 0) {
|
|
473
|
+
let telemetryIndex = 0;
|
|
474
|
+
for (const table of telemetryTables) {
|
|
475
|
+
if (telemetryIndex === 0) {
|
|
476
|
+
yield `${configTables.length > 0 ? "," : ""}"telemetry":{`;
|
|
477
|
+
} else {
|
|
478
|
+
yield ",";
|
|
479
|
+
}
|
|
480
|
+
telemetryIndex += 1;
|
|
481
|
+
if (table === telemetryEvents) {
|
|
482
|
+
yield `"${tableName(table)}":[`;
|
|
483
|
+
let first = true;
|
|
484
|
+
let written = 0;
|
|
485
|
+
for await (const event of pagedTelemetryEvents(db, tenantId)) {
|
|
486
|
+
if (written >= MAX_ROWS_PER_TABLE) break;
|
|
487
|
+
yield `${first ? "" : ","}${JSON.stringify(telemetryRowOf(event))}`;
|
|
488
|
+
first = false;
|
|
489
|
+
written += 1;
|
|
490
|
+
}
|
|
491
|
+
yield "]";
|
|
492
|
+
counts[tableName(table)] = written;
|
|
493
|
+
} else {
|
|
494
|
+
yield* writeTable(table);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
yield `}`;
|
|
498
|
+
}
|
|
499
|
+
yield `}`;
|
|
500
|
+
// Tables that belong to neither section (the tenant row) are counted but
|
|
501
|
+
// do not enter the payload — the same rule the buffered assembly kept.
|
|
502
|
+
for (const table of outside) {
|
|
503
|
+
let written = 0;
|
|
504
|
+
for await (const _source of pagedRows(db, table, tenantId)) {
|
|
505
|
+
written += 1;
|
|
506
|
+
}
|
|
507
|
+
counts[tableName(table)] = written;
|
|
508
|
+
}
|
|
509
|
+
// Always built: the row tables above now always carry the credentials
|
|
510
|
+
// decrypted, so the projection is always complete. It used to be
|
|
511
|
+
// conditional on the export being "raw", which made a plain JSON export
|
|
512
|
+
// carry a block of null tokens — a router migration from it produced
|
|
513
|
+
// accounts with no credential at all.
|
|
514
|
+
if (materialized.size > 0) {
|
|
515
|
+
const inputs: Record<string, readonly BackupRow[]> = {};
|
|
516
|
+
const accounts = materialized.get(providerAccounts);
|
|
517
|
+
const states = materialized.get(providerOauthStates);
|
|
518
|
+
if (accounts !== undefined) inputs["provider_accounts"] = accounts;
|
|
519
|
+
if (states !== undefined) inputs["provider_oauth_states"] = states;
|
|
520
|
+
const providerConnections = attachOauthSecrets(inputs);
|
|
521
|
+
if (providerConnections !== undefined) {
|
|
522
|
+
yield `,"providerConnections":${JSON.stringify(providerConnections)}`;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
yield `}`;
|
|
526
|
+
} finally {
|
|
527
|
+
finishCounts(counts);
|
|
528
|
+
}
|
|
269
529
|
}
|
|
270
530
|
|
|
271
531
|
/**
|
|
272
|
-
* Snapshots the requested tables, grouped into their sections
|
|
532
|
+
* Snapshots the requested tables, grouped into their sections, as an
|
|
533
|
+
* incrementally-written JSON document.
|
|
273
534
|
*
|
|
274
535
|
* Only `tenantId`'s own rows are read. The shared rows a backup needs in order
|
|
275
536
|
* to be restorable — the built-in providers and their catalog — are not carried
|
|
276
537
|
* because the build already supplies them (see `seedBundledProviders` /
|
|
277
538
|
* `seedBundledModels`), so a payload never contains another tenant's data.
|
|
278
539
|
*/
|
|
540
|
+
export interface BackupExportStream {
|
|
541
|
+
/** Concatenated chunks are the payload document, one string at a row-page granularity. */
|
|
542
|
+
readonly jsonChunks: AsyncGenerator<string, void, void>;
|
|
543
|
+
/**
|
|
544
|
+
* Row counts per table, resolved once the document has been consumed to
|
|
545
|
+
* completion — a streamed export counts what it actually wrote.
|
|
546
|
+
*/
|
|
547
|
+
readonly counts: Promise<Record<string, number>>;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export function exportBackupStream(
|
|
551
|
+
db: CartethyiaDatabase,
|
|
552
|
+
tables: readonly Table[],
|
|
553
|
+
tenantId: string,
|
|
554
|
+
): BackupExportStream {
|
|
555
|
+
let resolveCounts!: (counts: Record<string, number>) => void;
|
|
556
|
+
const counts = new Promise<Record<string, number>>((resolve) => {
|
|
557
|
+
resolveCounts = resolve;
|
|
558
|
+
});
|
|
559
|
+
return {
|
|
560
|
+
jsonChunks: backupJsonChunks(db, tables, tenantId, resolveCounts),
|
|
561
|
+
counts,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* The buffered form of {@link exportBackupStream}: builds the whole payload
|
|
567
|
+
* object in memory. Export callers stream; this remains for callers that need
|
|
568
|
+
* the payload as data — the backup round-trip tests use it to feed restores.
|
|
569
|
+
*/
|
|
279
570
|
export async function exportBackup(
|
|
280
571
|
db: CartethyiaDatabase,
|
|
281
572
|
tables: readonly Table[],
|
|
282
573
|
tenantId: string,
|
|
283
574
|
): Promise<BackupExportResult> {
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
const telemetrySet = new Set<Table>(TELEMETRY_TABLES);
|
|
289
|
-
|
|
290
|
-
// Table reads are independent tenant-scoped `SELECT`s plus one disk read for
|
|
291
|
-
// the event file; firing them concurrently cuts a many-table export from
|
|
292
|
-
// one round-trip-at-a-time to the single slowest query. The DB driver pools
|
|
293
|
-
// these; the table list is bounded by `CONFIG_TABLES + TELEMETRY_TABLES`
|
|
294
|
-
// (small and known), so fan-out is bounded without an extra limiter.
|
|
295
|
-
const reads = tables.map(async (table) =>
|
|
296
|
-
[
|
|
297
|
-
table,
|
|
298
|
-
table === telemetryEvents ? await exportEventFile(db, tenantId) : await exportTable(db, table, tenantId),
|
|
299
|
-
] as const,
|
|
300
|
-
);
|
|
301
|
-
// Preserve the caller's `tables` order when writing into the output maps so
|
|
302
|
-
// the payload's key order is deterministic across runs.
|
|
303
|
-
const settled = await Promise.all(reads);
|
|
304
|
-
for (const [table, rows] of settled) {
|
|
305
|
-
const name = tableName(table);
|
|
306
|
-
counts[name] = rows.length;
|
|
307
|
-
if (configSet.has(table)) config[name] = rows;
|
|
308
|
-
else if (telemetrySet.has(table)) telemetry[name] = rows;
|
|
309
|
-
}
|
|
310
|
-
// Always built: the row tables above now always carry the credentials
|
|
311
|
-
// decrypted, so the projection is always complete. It used to be conditional
|
|
312
|
-
// on the export being "raw", which made a plain JSON export carry a block of
|
|
313
|
-
// null tokens — a router migration from it produced accounts with no
|
|
314
|
-
// credential at all.
|
|
315
|
-
const providerConnections = attachOauthSecrets(config);
|
|
316
|
-
const payload: BackupPayload = {
|
|
317
|
-
app: BACKUP_APP,
|
|
318
|
-
version: BACKUP_VERSION,
|
|
319
|
-
exportedAt: new Date().toISOString(),
|
|
320
|
-
sections: {
|
|
321
|
-
...(Object.keys(config).length > 0 ? { config } : {}),
|
|
322
|
-
...(Object.keys(telemetry).length > 0 ? { telemetry } : {}),
|
|
323
|
-
},
|
|
324
|
-
...(providerConnections === undefined ? {} : { providerConnections }),
|
|
325
|
-
};
|
|
326
|
-
return { counts, payload };
|
|
575
|
+
const stream = exportBackupStream(db, tables, tenantId);
|
|
576
|
+
let document = "";
|
|
577
|
+
for await (const chunk of stream.jsonChunks) document += chunk;
|
|
578
|
+
return { counts: await stream.counts, payload: JSON.parse(document) as BackupPayload };
|
|
327
579
|
}
|
|
580
|
+
|
|
328
581
|
/**
|
|
329
582
|
* Gzips a backup payload.
|
|
330
583
|
*
|
|
@@ -337,6 +590,30 @@ export function compressBackup(payload: unknown): Buffer {
|
|
|
337
590
|
return gzipSync(Buffer.from(JSON.stringify(payload), "utf8"));
|
|
338
591
|
}
|
|
339
592
|
|
|
593
|
+
/**
|
|
594
|
+
* Gzips a streamed payload assembly: one pass, no whole-document buffer of
|
|
595
|
+
* the raw JSON (the compressed document itself still fits one buffer, since
|
|
596
|
+
* the response contract wraps it as base64).
|
|
597
|
+
*/
|
|
598
|
+
export async function compressBackupStream(chunks: AsyncGenerator<string, void, void>): Promise<Buffer> {
|
|
599
|
+
const gzip = createGzip();
|
|
600
|
+
const parts: Buffer[] = [];
|
|
601
|
+
const finished = new Promise<"done" | Error>((resolve) => {
|
|
602
|
+
gzip.on("data", (chunk: Buffer) => parts.push(chunk));
|
|
603
|
+
gzip.on("error", (error: Error) => resolve(error));
|
|
604
|
+
gzip.on("end", () => resolve("done"));
|
|
605
|
+
});
|
|
606
|
+
for await (const chunk of chunks) {
|
|
607
|
+
await new Promise<void>((resolve, reject) => {
|
|
608
|
+
gzip.write(Buffer.from(chunk, "utf8"), (error) => (error === null ? resolve() : reject(error)));
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
gzip.end();
|
|
612
|
+
const outcome = await finished;
|
|
613
|
+
if (outcome !== "done") throw outcome;
|
|
614
|
+
return Buffer.concat(parts);
|
|
615
|
+
}
|
|
616
|
+
|
|
340
617
|
/** The gzip magic, so a file can be recognised without trusting its name. */
|
|
341
618
|
const GZIP_MAGIC = [0x1f, 0x8b] as const;
|
|
342
619
|
|
|
@@ -369,28 +646,6 @@ export function decompressBackup(bytes: Uint8Array): string {
|
|
|
369
646
|
}
|
|
370
647
|
}
|
|
371
648
|
|
|
372
|
-
/**
|
|
373
|
-
* Exports per-request events from the file log, shaped like the table the
|
|
374
|
-
* backup contract still names. The table is no longer written, so reading it
|
|
375
|
-
* would export nothing.
|
|
376
|
-
*/
|
|
377
|
-
async function exportEventFile(db: CartethyiaDatabase, tenantId: string): Promise<readonly BackupRow[]> {
|
|
378
|
-
// The SQL table is the durable record now, and it is tenant-scoped and
|
|
379
|
-
// retained by the same rule as the raw files — so the export reads it
|
|
380
|
-
// instead of scanning every raw file and filtering by tenant afterwards.
|
|
381
|
-
const page = await querySqlEvents(db, { tenantId, limit: MAX_ROWS_PER_TABLE });
|
|
382
|
-
return page.events
|
|
383
|
-
.slice(0, MAX_ROWS_PER_TABLE)
|
|
384
|
-
.map((event) => {
|
|
385
|
-
const row: BackupRow = {};
|
|
386
|
-
for (const [key, value] of Object.entries(event)) {
|
|
387
|
-
if (value === null || value === undefined) continue;
|
|
388
|
-
row[camelToSnake(key)] = key === "createdAt" ? { __date: String(value) } : value;
|
|
389
|
-
}
|
|
390
|
-
return row;
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
|
-
|
|
394
649
|
function camelToSnake(name: string): string {
|
|
395
650
|
return name.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`);
|
|
396
651
|
}
|
|
@@ -732,6 +987,40 @@ async function insertRows(
|
|
|
732
987
|
return inserted;
|
|
733
988
|
}
|
|
734
989
|
|
|
990
|
+
/**
|
|
991
|
+
* Nulls the 11 nullable policy columns on share children owned by `tenantId`.
|
|
992
|
+
*
|
|
993
|
+
* Auth resolves policy from the parent (`policy = parent ?? row`), so child
|
|
994
|
+
* copies of notes/limits/model_list/denylist are pure bloat. Migration 0029
|
|
995
|
+
* clears them once at boot; restore does not re-run numbered migrations, so
|
|
996
|
+
* an old backup that still carries populated children would reintroduce the
|
|
997
|
+
* bloat unless this runs inside the same restore transaction. Keep the SET
|
|
998
|
+
* list identical to migration 0029
|
|
999
|
+
* (`0029_null_share_child_policy_columns.sql` in both backends) and the write
|
|
1000
|
+
* path in `issueSharedApiKey` (which no longer copies them).
|
|
1001
|
+
*/
|
|
1002
|
+
export async function normalizeShareChildPolicy(
|
|
1003
|
+
db: CartethyiaDatabase,
|
|
1004
|
+
tenantId: string,
|
|
1005
|
+
): Promise<void> {
|
|
1006
|
+
await db
|
|
1007
|
+
.update(apiKeys)
|
|
1008
|
+
.set({
|
|
1009
|
+
notesTitle: null,
|
|
1010
|
+
notesSubtitle: null,
|
|
1011
|
+
notesBody: null,
|
|
1012
|
+
requestsPerMinute: null,
|
|
1013
|
+
dailyTokenLimit: null,
|
|
1014
|
+
monthlyTokenLimit: null,
|
|
1015
|
+
lifetimeTokenBudget: null,
|
|
1016
|
+
maxConcurrentRequests: null,
|
|
1017
|
+
modelPrefix: null,
|
|
1018
|
+
modelList: null,
|
|
1019
|
+
clientRouterDenylist: null,
|
|
1020
|
+
})
|
|
1021
|
+
.where(and(eq(apiKeys.tenantId, tenantId), isNotNull(apiKeys.parentKeyId)));
|
|
1022
|
+
}
|
|
1023
|
+
|
|
735
1024
|
/**
|
|
736
1025
|
* Applies a pre-validated restore inside one transaction.
|
|
737
1026
|
*
|
|
@@ -865,6 +1154,13 @@ export async function applyRestore(
|
|
|
865
1154
|
restored[tableName(table)] = await insertRows(scoped, table, entry.rows, { tenantId });
|
|
866
1155
|
}
|
|
867
1156
|
}
|
|
1157
|
+
|
|
1158
|
+
// Restore writes api_keys rows verbatim and does not re-run migration
|
|
1159
|
+
// 0029. An old backup can therefore reintroduce populated child policy
|
|
1160
|
+
// columns; re-apply the null-out for this tenant before committing.
|
|
1161
|
+
if (replaceTables.includes(apiKeys)) {
|
|
1162
|
+
await normalizeShareChildPolicy(scoped, tenantId);
|
|
1163
|
+
}
|
|
868
1164
|
});
|
|
869
1165
|
|
|
870
1166
|
return { restored, skipped };
|
|
@@ -36,6 +36,11 @@ export const DEFAULT_ENTRY_DOCUMENTS: Readonly<Record<string, string>> = {
|
|
|
36
36
|
"/console": "index.html",
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
function dashboardBuildHeader(html: string): Record<string, string> {
|
|
40
|
+
const buildId = /<meta\s+name="cartethyia-dashboard-build"\s+content="([a-f0-9]{16})"\s*\/?>/i.exec(html)?.[1];
|
|
41
|
+
return buildId === undefined ? {} : { "x-cartethyia-dashboard-build": buildId };
|
|
42
|
+
}
|
|
43
|
+
|
|
39
44
|
/**
|
|
40
45
|
* Open Graph / Twitter card values injected into a served document. The shared
|
|
41
46
|
* index document ships the landing card, so the public share routes swap in
|
|
@@ -200,6 +205,7 @@ export function createStaticHandler(
|
|
|
200
205
|
"cache-control": "no-store, no-cache, must-revalidate",
|
|
201
206
|
pragma: "no-cache",
|
|
202
207
|
expires: "0",
|
|
208
|
+
...dashboardBuildHeader(html),
|
|
203
209
|
},
|
|
204
210
|
};
|
|
205
211
|
};
|
|
@@ -274,6 +280,10 @@ export function createStaticHandler(
|
|
|
274
280
|
// Regular assets without hash
|
|
275
281
|
cacheControl = "public, max-age=3600";
|
|
276
282
|
}
|
|
283
|
+
const buildHeaders =
|
|
284
|
+
mimeType.startsWith("text/html")
|
|
285
|
+
? dashboardBuildHeader(new TextDecoder().decode(data))
|
|
286
|
+
: {};
|
|
277
287
|
|
|
278
288
|
// A precompressed sibling is served when the client asked for gzip
|
|
279
289
|
// and the build emitted one. The bytes were compressed at build time
|
|
@@ -293,6 +303,7 @@ export function createStaticHandler(
|
|
|
293
303
|
// client that never offered gzip: the URL is identical either
|
|
294
304
|
// way, so `accept-encoding` is the only thing that differs.
|
|
295
305
|
vary: "accept-encoding",
|
|
306
|
+
...buildHeaders,
|
|
296
307
|
},
|
|
297
308
|
};
|
|
298
309
|
}
|
|
@@ -300,7 +311,11 @@ export function createStaticHandler(
|
|
|
300
311
|
return {
|
|
301
312
|
status: 200,
|
|
302
313
|
body: data,
|
|
303
|
-
headers: {
|
|
314
|
+
headers: {
|
|
315
|
+
"content-type": mimeType,
|
|
316
|
+
"cache-control": cacheControl,
|
|
317
|
+
...buildHeaders,
|
|
318
|
+
},
|
|
304
319
|
};
|
|
305
320
|
}
|
|
306
321
|
} catch {
|
|
@@ -25,6 +25,7 @@ import { createNetworkPoolRoutes } from "./routing/pools/routes";
|
|
|
25
25
|
import { DrizzleModelRoutingStore } from "./routing/model/store";
|
|
26
26
|
import { DrizzleNetworkPoolStore } from "./routing/pools/store";
|
|
27
27
|
import { createRuntimeSettingsRoutes } from "./settings/routes";
|
|
28
|
+
import { createSystemPromptRoutes } from "./system-prompt/routes";
|
|
28
29
|
import { createDataManagementRoutes } from "./data-management/routes";
|
|
29
30
|
import { healthEventBuffer } from "../persistence/health-event-flush";
|
|
30
31
|
import { countAllHealthEvents, deleteAllHealthEvents } from "../providers/operations/account-health-service";
|
|
@@ -306,6 +307,7 @@ export function registerConsoleDomains(
|
|
|
306
307
|
relayFetch: createValidatedFetch({ policy: resolveSsrfPolicy() }),
|
|
307
308
|
}));
|
|
308
309
|
console.use(createRuntimeSettingsRoutes({ store: runtimeSettingsStore, accessResolver: ctx.accessResolver, auditSink: ctx.auditRecorder, snapshotInvalidator: ctx.routeSnapshotService }));
|
|
310
|
+
console.use(createSystemPromptRoutes({ db: ctx.db, accessResolver: ctx.accessResolver, auditSink: ctx.auditRecorder }));
|
|
309
311
|
console.use(
|
|
310
312
|
createDataManagementRoutes({
|
|
311
313
|
telemetryDir: TELEMETRY_EVENT_DIR,
|
|
@@ -43,6 +43,10 @@ import {
|
|
|
43
43
|
fetchQoderDailyCheckin,
|
|
44
44
|
isQoderCheckinProvider,
|
|
45
45
|
} from "../../providers/integrations/qoder/qoder-checkin";
|
|
46
|
+
import {
|
|
47
|
+
DrizzleQoderNativeIdentityStore,
|
|
48
|
+
type QoderNativeIdentityStore,
|
|
49
|
+
} from "../../providers/integrations/qoder/qoder-native-identity-store";
|
|
46
50
|
import { buddyAccountUid } from "../../providers/integrations/buddy/buddy-oauth-shared";
|
|
47
51
|
import {
|
|
48
52
|
supportsAccountReset,
|
|
@@ -108,6 +112,7 @@ async function runAccountCheckin(
|
|
|
108
112
|
account: { id: string; providerId: string },
|
|
109
113
|
credential: string,
|
|
110
114
|
forceClaim: boolean,
|
|
115
|
+
nativeIdentityStore?: QoderNativeIdentityStore,
|
|
111
116
|
): Promise<DailyCheckinResult> {
|
|
112
117
|
if (isQoderCheckinProvider(account.providerId)) {
|
|
113
118
|
return fetchQoderDailyCheckin({
|
|
@@ -115,6 +120,7 @@ async function runAccountCheckin(
|
|
|
115
120
|
accountId: account.id,
|
|
116
121
|
credential,
|
|
117
122
|
fetcher: globalThis.fetch,
|
|
123
|
+
...(nativeIdentityStore ? { nativeIdentityStore } : {}),
|
|
118
124
|
});
|
|
119
125
|
}
|
|
120
126
|
return fetchDailyCheckin({
|
|
@@ -202,6 +208,7 @@ async function runDailyGrowthForAccount(
|
|
|
202
208
|
accountId: account.id,
|
|
203
209
|
credential,
|
|
204
210
|
fetcher,
|
|
211
|
+
nativeIdentityStore: new DrizzleQoderNativeIdentityStore(refreshDeps.db),
|
|
205
212
|
})
|
|
206
213
|
: await fetchDailyCheckin({
|
|
207
214
|
providerId,
|
|
@@ -884,7 +891,7 @@ export function registerAccountQuotaTenantRoutes(
|
|
|
884
891
|
account.providerId as ProviderId,
|
|
885
892
|
account.id,
|
|
886
893
|
);
|
|
887
|
-
result = await runAccountCheckin(account, credential, true);
|
|
894
|
+
result = await runAccountCheckin(account, credential, true, new DrizzleQoderNativeIdentityStore(db));
|
|
888
895
|
} catch (error) {
|
|
889
896
|
result = {
|
|
890
897
|
providerId: account.providerId,
|
|
@@ -2,13 +2,15 @@ import { Elysia } from "elysia";
|
|
|
2
2
|
import { consoleErrorHandler } from "../shared/errors";
|
|
3
3
|
import { registerAccountQuotaGlobalRoutes } from "./account-quota-global";
|
|
4
4
|
import { registerAccountQuotaTenantRoutes } from "./account-quota-tenant";
|
|
5
|
+
import { registerNativeIdentityRoutes } from "./native-identity";
|
|
5
6
|
import { createQuotaRefreshDeps, type AccountQuotaRoutesDeps } from "./account-quota-shared";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Mounts the account/quota console routes: the tenant-scoped group (the
|
|
9
10
|
* caller's own accounts) and the platform-admin group (shared accounts). Each
|
|
10
11
|
* group lives in its own module and authorises every route with its own
|
|
11
|
-
* access check.
|
|
12
|
+
* access check. The native-identity routes are mounted once, here, because
|
|
13
|
+
* they serve both owned and shared accounts with per-account authorization.
|
|
12
14
|
*/
|
|
13
15
|
export function createAccountQuotaRoutes(deps: AccountQuotaRoutesDeps): Elysia {
|
|
14
16
|
const refreshDeps = createQuotaRefreshDeps(deps);
|
|
@@ -18,5 +20,6 @@ export function createAccountQuotaRoutes(deps: AccountQuotaRoutesDeps): Elysia {
|
|
|
18
20
|
app.error(consoleErrorHandler("Quota operation failed"));
|
|
19
21
|
registerAccountQuotaTenantRoutes(app, deps, refreshDeps);
|
|
20
22
|
registerAccountQuotaGlobalRoutes(app, deps, refreshDeps);
|
|
23
|
+
registerNativeIdentityRoutes(app, { db: deps.db, accessResolver: deps.accessResolver });
|
|
21
24
|
return app;
|
|
22
25
|
}
|