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
|
@@ -1,581 +1,753 @@
|
|
|
1
|
-
// Persisted API-key store: the console API-key domain and gateway authentication both consume this boundary.
|
|
2
|
-
|
|
3
|
-
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
|
|
4
|
-
import { nextSortIndex, reorderSortIndex, type SortOrderDb } from "../sort-order";
|
|
5
|
-
import { getDbHandle } from "../boot";
|
|
6
|
-
import type { CartethyiaDatabase } from "../db-handle";
|
|
7
|
-
import { lockRows } from "../dialect";
|
|
8
|
-
import { apiKeys, shareLinks, sharePopups, type ApiKeyMode, type ApiKeyModelAccessMode } from "../schema";
|
|
9
|
-
import type { AccessScope } from "../../security/access-control";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
readonly
|
|
22
|
-
readonly
|
|
23
|
-
|
|
24
|
-
readonly
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
readonly
|
|
30
|
-
readonly
|
|
31
|
-
readonly
|
|
32
|
-
readonly
|
|
33
|
-
/**
|
|
34
|
-
readonly
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
readonly
|
|
40
|
-
readonly
|
|
41
|
-
readonly
|
|
42
|
-
readonly
|
|
43
|
-
readonly
|
|
44
|
-
readonly
|
|
45
|
-
readonly
|
|
46
|
-
|
|
47
|
-
readonly
|
|
48
|
-
readonly
|
|
49
|
-
|
|
50
|
-
readonly
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
.
|
|
267
|
-
.
|
|
268
|
-
.
|
|
269
|
-
|
|
270
|
-
return
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
.
|
|
294
|
-
.
|
|
295
|
-
|
|
296
|
-
.
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
.
|
|
302
|
-
.
|
|
303
|
-
.
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
),
|
|
308
|
-
)
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
)
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
.
|
|
457
|
-
.
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
.
|
|
488
|
-
.
|
|
489
|
-
|
|
490
|
-
.
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
};
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
}
|
|
1
|
+
// Persisted API-key store: the console API-key domain and gateway authentication both consume this boundary.
|
|
2
|
+
|
|
3
|
+
import { and, asc, count, desc, eq, inArray, isNotNull, isNull, sql } from "drizzle-orm";
|
|
4
|
+
import { nextSortIndex, reorderSortIndex, type SortOrderDb } from "../sort-order";
|
|
5
|
+
import { getDbHandle } from "../boot";
|
|
6
|
+
import type { CartethyiaDatabase } from "../db-handle";
|
|
7
|
+
import { lockRows } from "../dialect";
|
|
8
|
+
import { apiKeys, shareLinks, sharePopups, telemetryUsageTotals, type ApiKeyMode, type ApiKeyModelAccessMode } from "../schema";
|
|
9
|
+
import type { AccessScope } from "../../security/access-control";
|
|
10
|
+
|
|
11
|
+
/** Sortable columns for the paged share-child listing (api_keys columns only). */
|
|
12
|
+
export type SharedChildSortColumn =
|
|
13
|
+
| "createdAt"
|
|
14
|
+
| "label"
|
|
15
|
+
| "keyPrefix"
|
|
16
|
+
| "issuedClientIp"
|
|
17
|
+
| "revokedAt"
|
|
18
|
+
| "enabled";
|
|
19
|
+
|
|
20
|
+
export interface SharedChildrenPageOptions {
|
|
21
|
+
readonly limit: number;
|
|
22
|
+
readonly offset: number;
|
|
23
|
+
readonly sortBy: SharedChildSortColumn;
|
|
24
|
+
readonly order: "asc" | "desc";
|
|
25
|
+
readonly status: "all" | "active" | "revoked";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SharedChildrenPage {
|
|
29
|
+
readonly rows: readonly ApiKeyRecord[];
|
|
30
|
+
readonly total: number;
|
|
31
|
+
readonly active: number;
|
|
32
|
+
readonly revoked: number;
|
|
33
|
+
/** Family-wide `lifetime_tokens_consumed` sum, independent of the page. */
|
|
34
|
+
readonly consumed: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Persisted API key. Only its one-way hash authenticates; plaintext is never stored. */
|
|
38
|
+
export interface ApiKeyRecord {
|
|
39
|
+
readonly id: string;
|
|
40
|
+
readonly tenantId: string;
|
|
41
|
+
readonly keyHash: string | null;
|
|
42
|
+
readonly keyMode: ApiKeyMode;
|
|
43
|
+
readonly parentKeyId?: string;
|
|
44
|
+
readonly issuedClientIp?: string;
|
|
45
|
+
readonly issuedClientIpKey?: string;
|
|
46
|
+
readonly label: string;
|
|
47
|
+
readonly enabled: boolean;
|
|
48
|
+
readonly scopes: readonly AccessScope[];
|
|
49
|
+
/** The tenant's mandatory gateway key: rotatable, never revocable. */
|
|
50
|
+
readonly isDefault: boolean;
|
|
51
|
+
/** Non-secret prefix configured on a key or used to identify a share child. */
|
|
52
|
+
readonly keyPrefix?: string;
|
|
53
|
+
/** Encrypted copy exists only for personal keys used by CLI handoff. */
|
|
54
|
+
readonly keyEncrypted?: Buffer;
|
|
55
|
+
readonly notesTitle?: string;
|
|
56
|
+
readonly notesSubtitle?: string;
|
|
57
|
+
readonly notesBody?: string;
|
|
58
|
+
readonly sharePopupEnabled?: boolean;
|
|
59
|
+
/** Uploaded popup art; served from the share page rather than a remote URL. */
|
|
60
|
+
readonly sharePopupImage?: Buffer;
|
|
61
|
+
readonly sharePopupImageMime?: string;
|
|
62
|
+
readonly sharePopupTitle?: string;
|
|
63
|
+
readonly sharePopupBody?: string;
|
|
64
|
+
readonly requestsPerMinute?: number;
|
|
65
|
+
readonly dailyTokenLimit?: number;
|
|
66
|
+
readonly monthlyTokenLimit?: number;
|
|
67
|
+
readonly lifetimeTokenBudget?: number;
|
|
68
|
+
readonly maxConcurrentRequests?: number;
|
|
69
|
+
/** Share-template recipient slot cap; undefined = unlimited. */
|
|
70
|
+
readonly maxChildren?: number;
|
|
71
|
+
readonly modelPrefix?: string;
|
|
72
|
+
readonly modelAccessMode?: ApiKeyModelAccessMode;
|
|
73
|
+
readonly modelList?: readonly string[];
|
|
74
|
+
/** Client-router ids this key refuses; see the schema column for the contract. */
|
|
75
|
+
readonly clientRouterDenylist?: readonly string[];
|
|
76
|
+
readonly createdAt: Date;
|
|
77
|
+
readonly revokedAt?: Date;
|
|
78
|
+
readonly tokensConsumed: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type SharedChildSortColumnRef =
|
|
82
|
+
| typeof apiKeys.createdAt
|
|
83
|
+
| typeof apiKeys.label
|
|
84
|
+
| typeof apiKeys.keyPrefix
|
|
85
|
+
| typeof apiKeys.issuedClientIp
|
|
86
|
+
| typeof apiKeys.revokedAt
|
|
87
|
+
| typeof apiKeys.enabled;
|
|
88
|
+
|
|
89
|
+
const SHARED_CHILD_SORT_COLUMNS: Readonly<Record<SharedChildSortColumn, SharedChildSortColumnRef>> = {
|
|
90
|
+
createdAt: apiKeys.createdAt,
|
|
91
|
+
label: apiKeys.label,
|
|
92
|
+
keyPrefix: apiKeys.keyPrefix,
|
|
93
|
+
issuedClientIp: apiKeys.issuedClientIp,
|
|
94
|
+
revokedAt: apiKeys.revokedAt,
|
|
95
|
+
enabled: apiKeys.enabled,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Partial key update. Nullable policy fields clear their values; `undefined`
|
|
100
|
+
* leaves them unchanged. Credential metadata is writable only for a mode
|
|
101
|
+
* transition performed by the API-key domain.
|
|
102
|
+
*/
|
|
103
|
+
export interface ApiKeyPatch
|
|
104
|
+
extends Omit<
|
|
105
|
+
Partial<ApiKeyRecord>,
|
|
106
|
+
| "id"
|
|
107
|
+
| "tenantId"
|
|
108
|
+
| "keyHash"
|
|
109
|
+
| "parentKeyId"
|
|
110
|
+
| "issuedClientIp"
|
|
111
|
+
| "issuedClientIpKey"
|
|
112
|
+
| "createdAt"
|
|
113
|
+
| "revokedAt"
|
|
114
|
+
| "tokensConsumed"
|
|
115
|
+
| "keyEncrypted"
|
|
116
|
+
| "keyPrefix"
|
|
117
|
+
| "notesTitle"
|
|
118
|
+
| "notesSubtitle"
|
|
119
|
+
| "notesBody"
|
|
120
|
+
| "sharePopupEnabled"
|
|
121
|
+
| "sharePopupImage"
|
|
122
|
+
| "sharePopupImageMime"
|
|
123
|
+
| "sharePopupTitle"
|
|
124
|
+
| "sharePopupBody"
|
|
125
|
+
| "requestsPerMinute"
|
|
126
|
+
| "dailyTokenLimit"
|
|
127
|
+
| "monthlyTokenLimit"
|
|
128
|
+
| "lifetimeTokenBudget"
|
|
129
|
+
| "maxConcurrentRequests"
|
|
130
|
+
| "maxChildren"
|
|
131
|
+
> {
|
|
132
|
+
readonly keyHash?: string | null;
|
|
133
|
+
readonly keyEncrypted?: Buffer | null;
|
|
134
|
+
readonly keyPrefix?: string | null;
|
|
135
|
+
readonly notesTitle?: string | null;
|
|
136
|
+
readonly notesSubtitle?: string | null;
|
|
137
|
+
readonly notesBody?: string | null;
|
|
138
|
+
readonly sharePopupEnabled?: boolean | null;
|
|
139
|
+
/** Null clears both bytes and mime; setting one without the other is rejected upstream. */
|
|
140
|
+
readonly sharePopupImage?: Buffer | null;
|
|
141
|
+
readonly sharePopupImageMime?: string | null;
|
|
142
|
+
readonly sharePopupTitle?: string | null;
|
|
143
|
+
readonly sharePopupBody?: string | null;
|
|
144
|
+
readonly requestsPerMinute?: number | null;
|
|
145
|
+
readonly dailyTokenLimit?: number | null;
|
|
146
|
+
readonly monthlyTokenLimit?: number | null;
|
|
147
|
+
readonly lifetimeTokenBudget?: number | null;
|
|
148
|
+
readonly maxConcurrentRequests?: number | null;
|
|
149
|
+
readonly maxChildren?: number | null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
interface PopupFields {
|
|
153
|
+
readonly sharePopupEnabled?: boolean | null;
|
|
154
|
+
readonly sharePopupImage?: Buffer | null;
|
|
155
|
+
readonly sharePopupImageMime?: string | null;
|
|
156
|
+
readonly sharePopupTitle?: string | null;
|
|
157
|
+
readonly sharePopupBody?: string | null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function popupPatchTouched(patch: PopupFields): boolean {
|
|
161
|
+
return (
|
|
162
|
+
patch.sharePopupEnabled !== undefined ||
|
|
163
|
+
patch.sharePopupImage !== undefined ||
|
|
164
|
+
patch.sharePopupImageMime !== undefined ||
|
|
165
|
+
patch.sharePopupTitle !== undefined ||
|
|
166
|
+
patch.sharePopupBody !== undefined
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Merges a partial popup patch onto the stored row, so an omitted field is kept. */
|
|
171
|
+
async function mergeSharePopup(
|
|
172
|
+
db: CartethyiaDatabase,
|
|
173
|
+
apiKeyId: string,
|
|
174
|
+
patch: PopupFields,
|
|
175
|
+
): Promise<void> {
|
|
176
|
+
const existing = await db
|
|
177
|
+
.select()
|
|
178
|
+
.from(sharePopups)
|
|
179
|
+
.where(eq(sharePopups.apiKeyId, apiKeyId))
|
|
180
|
+
.limit(1);
|
|
181
|
+
const row = existing[0];
|
|
182
|
+
await writeSharePopup(db, apiKeyId, {
|
|
183
|
+
sharePopupEnabled: patch.sharePopupEnabled !== undefined ? patch.sharePopupEnabled : (row?.enabled ?? false),
|
|
184
|
+
sharePopupImage: patch.sharePopupImage !== undefined ? patch.sharePopupImage : (row?.image ?? null),
|
|
185
|
+
sharePopupImageMime: patch.sharePopupImageMime !== undefined ? patch.sharePopupImageMime : (row?.imageMime ?? null),
|
|
186
|
+
sharePopupTitle: patch.sharePopupTitle !== undefined ? patch.sharePopupTitle : (row?.title ?? null),
|
|
187
|
+
sharePopupBody: patch.sharePopupBody !== undefined ? patch.sharePopupBody : (row?.body ?? null),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Writes the popup row, or removes it when the owner cleared every field. */
|
|
192
|
+
async function writeSharePopup(
|
|
193
|
+
db: CartethyiaDatabase,
|
|
194
|
+
apiKeyId: string,
|
|
195
|
+
fields: PopupFields,
|
|
196
|
+
): Promise<void> {
|
|
197
|
+
const enabled = fields.sharePopupEnabled ?? false;
|
|
198
|
+
const image = fields.sharePopupImage ?? null;
|
|
199
|
+
const title = fields.sharePopupTitle ?? null;
|
|
200
|
+
const body = fields.sharePopupBody ?? null;
|
|
201
|
+
if (!enabled && image === null && title === null && body === null) {
|
|
202
|
+
await db.delete(sharePopups).where(eq(sharePopups.apiKeyId, apiKeyId));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
await db
|
|
206
|
+
.insert(sharePopups)
|
|
207
|
+
.values({
|
|
208
|
+
apiKeyId,
|
|
209
|
+
enabled,
|
|
210
|
+
image,
|
|
211
|
+
imageMime: fields.sharePopupImageMime ?? null,
|
|
212
|
+
title,
|
|
213
|
+
body,
|
|
214
|
+
})
|
|
215
|
+
.onConflictDoUpdate({
|
|
216
|
+
target: sharePopups.apiKeyId,
|
|
217
|
+
set: {
|
|
218
|
+
enabled,
|
|
219
|
+
image,
|
|
220
|
+
imageMime: fields.sharePopupImageMime ?? null,
|
|
221
|
+
title,
|
|
222
|
+
body,
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Explicit persistence boundary. Production must provide this; there is no fallback store. */
|
|
228
|
+
export interface ApiKeyStore {
|
|
229
|
+
list(tenantId: string): Promise<readonly ApiKeyRecord[]>;
|
|
230
|
+
get(tenantId: string, keyId: string): Promise<ApiKeyRecord | undefined>;
|
|
231
|
+
listChildren(tenantId: string, parentKeyId: string): Promise<readonly ApiKeyRecord[]>;
|
|
232
|
+
revokeChildrenByScope(
|
|
233
|
+
tenantId: string,
|
|
234
|
+
parentKeyId: string,
|
|
235
|
+
scope: "active" | "unused",
|
|
236
|
+
): Promise<number>;
|
|
237
|
+
/** Cumulative family spend: template counter + every telemetry total row ever
|
|
238
|
+
* recorded for the family (revoked/deleted children stay counted). */
|
|
239
|
+
sumFamilyTelemetryConsumed(tenantId: string, parentKeyId: string): Promise<number>;
|
|
240
|
+
listChildrenPage(
|
|
241
|
+
tenantId: string,
|
|
242
|
+
parentKeyId: string,
|
|
243
|
+
options: SharedChildrenPageOptions,
|
|
244
|
+
): Promise<SharedChildrenPage>;
|
|
245
|
+
create(record: ApiKeyRecord): Promise<void>;
|
|
246
|
+
/** Rewrites list order for one tenant; `keyIds` is the full desired order. */
|
|
247
|
+
reorder(tenantId: string, keyIds: readonly string[]): Promise<void>;
|
|
248
|
+
update(tenantId: string, keyId: string, patch: ApiKeyPatch): Promise<ApiKeyRecord | undefined>;
|
|
249
|
+
revoke(tenantId: string, keyId: string, revokedAt: Date): Promise<boolean>;
|
|
250
|
+
remove(tenantId: string, keyId: string): Promise<boolean>;
|
|
251
|
+
findActiveByHash?(hash: string): Promise<typeof apiKeys.$inferSelect | undefined>;
|
|
252
|
+
findActiveById?(keyId: string): Promise<typeof apiKeys.$inferSelect | undefined>;
|
|
253
|
+
sumChildrenConsumed?(parentKeyId: string): Promise<number>;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export class DrizzleApiKeyStore implements ApiKeyStore {
|
|
257
|
+
constructor(private readonly db: CartethyiaDatabase) {}
|
|
258
|
+
|
|
259
|
+
/** Attaches the popup, which lives in its own table so the auth read never loads it. */
|
|
260
|
+
private async withPopups(
|
|
261
|
+
db: SortOrderDb | CartethyiaDatabase,
|
|
262
|
+
rows: readonly (typeof apiKeys.$inferSelect)[],
|
|
263
|
+
): Promise<ApiKeyRecord[]> {
|
|
264
|
+
if (rows.length === 0) return [];
|
|
265
|
+
const popups = await db
|
|
266
|
+
.select()
|
|
267
|
+
.from(sharePopups)
|
|
268
|
+
.where(inArray(sharePopups.apiKeyId, rows.map((row) => row.id)));
|
|
269
|
+
const byKey = new Map(popups.map((popup) => [popup.apiKeyId, popup]));
|
|
270
|
+
return rows.map((row) => this.map(row, byKey.get(row.id)));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private map(
|
|
274
|
+
row: typeof apiKeys.$inferSelect,
|
|
275
|
+
popup?: typeof sharePopups.$inferSelect,
|
|
276
|
+
): ApiKeyRecord {
|
|
277
|
+
return {
|
|
278
|
+
id: row.id,
|
|
279
|
+
tenantId: row.tenantId,
|
|
280
|
+
keyHash: row.keyHash,
|
|
281
|
+
keyMode: row.keyMode,
|
|
282
|
+
isDefault: row.isDefault,
|
|
283
|
+
...(row.parentKeyId === null ? {} : { parentKeyId: row.parentKeyId }),
|
|
284
|
+
...(row.issuedClientIp === null ? {} : { issuedClientIp: row.issuedClientIp }),
|
|
285
|
+
...(row.issuedClientIpKey === null
|
|
286
|
+
? {}
|
|
287
|
+
: { issuedClientIpKey: row.issuedClientIpKey }),
|
|
288
|
+
label: row.label,
|
|
289
|
+
enabled: row.enabled,
|
|
290
|
+
scopes: row.scopes as ApiKeyRecord["scopes"],
|
|
291
|
+
...(row.keyPrefix === null ? {} : { keyPrefix: row.keyPrefix }),
|
|
292
|
+
...(row.keyEncrypted === null ? {} : { keyEncrypted: row.keyEncrypted }),
|
|
293
|
+
...(row.notesTitle === null ? {} : { notesTitle: row.notesTitle }),
|
|
294
|
+
...(row.notesSubtitle === null ? {} : { notesSubtitle: row.notesSubtitle }),
|
|
295
|
+
...(row.notesBody === null ? {} : { notesBody: row.notesBody }),
|
|
296
|
+
...(popup?.enabled ? { sharePopupEnabled: popup.enabled } : {}),
|
|
297
|
+
...(popup?.image == null ? {} : { sharePopupImage: popup.image }),
|
|
298
|
+
...(popup?.imageMime == null ? {} : { sharePopupImageMime: popup.imageMime }),
|
|
299
|
+
...(popup?.title == null ? {} : { sharePopupTitle: popup.title }),
|
|
300
|
+
...(popup?.body == null ? {} : { sharePopupBody: popup.body }),
|
|
301
|
+
...(row.requestsPerMinute === null ? {} : { requestsPerMinute: row.requestsPerMinute }),
|
|
302
|
+
...(row.dailyTokenLimit === null ? {} : { dailyTokenLimit: row.dailyTokenLimit }),
|
|
303
|
+
...(row.monthlyTokenLimit === null ? {} : { monthlyTokenLimit: row.monthlyTokenLimit }),
|
|
304
|
+
...(row.lifetimeTokenBudget === null ? {} : { lifetimeTokenBudget: row.lifetimeTokenBudget }),
|
|
305
|
+
...(row.maxConcurrentRequests === null
|
|
306
|
+
? {}
|
|
307
|
+
: { maxConcurrentRequests: row.maxConcurrentRequests }),
|
|
308
|
+
...(row.maxChildren === null ? {} : { maxChildren: row.maxChildren }),
|
|
309
|
+
...(row.modelPrefix === null ? {} : { modelPrefix: row.modelPrefix }),
|
|
310
|
+
modelAccessMode: row.modelAccessMode,
|
|
311
|
+
...(row.modelList === null ? {} : { modelList: row.modelList as string[] }),
|
|
312
|
+
...(row.clientRouterDenylist === null
|
|
313
|
+
? {}
|
|
314
|
+
: { clientRouterDenylist: row.clientRouterDenylist as string[] }),
|
|
315
|
+
createdAt: row.createdAt,
|
|
316
|
+
...(row.revokedAt ? { revokedAt: row.revokedAt } : {}),
|
|
317
|
+
tokensConsumed: row.lifetimeTokensConsumed,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
async list(tenantId: string): Promise<readonly ApiKeyRecord[]> {
|
|
321
|
+
// Bounded: a large tenant key list must not load fully into memory.
|
|
322
|
+
// Ordered by the explicit `sort_index`, not `created_at`: a stable key means
|
|
323
|
+
// rows keep their position across reloads (and can be reordered) instead of
|
|
324
|
+
// shifting when two rows share a timestamp or one is updated.
|
|
325
|
+
const rows = await this.db
|
|
326
|
+
.select()
|
|
327
|
+
.from(apiKeys)
|
|
328
|
+
.where(eq(apiKeys.tenantId, tenantId))
|
|
329
|
+
// Parents first: share children all carry `sort_index = 0` (the column
|
|
330
|
+
// default), so sorting by `sort_index` alone lets a huge family of
|
|
331
|
+
// children crowd every top-level key except the one with the oldest
|
|
332
|
+
// `created_at` out of the 1000-row page. The console only renders
|
|
333
|
+
// top-level keys, so they must never be truncated away by their own
|
|
334
|
+
// children.
|
|
335
|
+
.orderBy(sql`(${apiKeys.parentKeyId} IS NOT NULL)`, apiKeys.sortIndex, apiKeys.createdAt, apiKeys.id)
|
|
336
|
+
.limit(1000);
|
|
337
|
+
return this.withPopups(this.db, rows);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Rewrites the list order for one tenant; `keyIds` is the full order. */
|
|
341
|
+
async reorder(tenantId: string, keyIds: readonly string[]): Promise<void> {
|
|
342
|
+
await reorderSortIndex(
|
|
343
|
+
this.db,
|
|
344
|
+
{ table: apiKeys, idColumn: apiKeys.id, sortIndexColumn: apiKeys.sortIndex },
|
|
345
|
+
eq(apiKeys.tenantId, tenantId),
|
|
346
|
+
keyIds,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Next free list position for a new key in this tenant. */
|
|
351
|
+
private async nextSortIndex(tenantId: string): Promise<number> {
|
|
352
|
+
return nextSortIndex(
|
|
353
|
+
this.db,
|
|
354
|
+
{ table: apiKeys, idColumn: apiKeys.id, sortIndexColumn: apiKeys.sortIndex },
|
|
355
|
+
eq(apiKeys.tenantId, tenantId),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
async get(tenantId: string, keyId: string): Promise<ApiKeyRecord | undefined> {
|
|
359
|
+
const rows = await this.db
|
|
360
|
+
.select()
|
|
361
|
+
.from(apiKeys)
|
|
362
|
+
.where(and(eq(apiKeys.tenantId, tenantId), eq(apiKeys.id, keyId)))
|
|
363
|
+
.limit(1);
|
|
364
|
+
return rows[0] ? (await this.withPopups(this.db, rows))[0] : undefined;
|
|
365
|
+
}
|
|
366
|
+
async listChildren(tenantId: string, parentKeyId: string): Promise<readonly ApiKeyRecord[]> {
|
|
367
|
+
const rows = await this.db
|
|
368
|
+
.select()
|
|
369
|
+
.from(apiKeys)
|
|
370
|
+
.where(
|
|
371
|
+
and(
|
|
372
|
+
eq(apiKeys.tenantId, tenantId),
|
|
373
|
+
eq(apiKeys.parentKeyId, parentKeyId),
|
|
374
|
+
),
|
|
375
|
+
)
|
|
376
|
+
.orderBy(apiKeys.createdAt, apiKeys.id);
|
|
377
|
+
return this.withPopups(this.db, rows);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async listChildrenPage(
|
|
381
|
+
tenantId: string,
|
|
382
|
+
parentKeyId: string,
|
|
383
|
+
options: SharedChildrenPageOptions,
|
|
384
|
+
): Promise<SharedChildrenPage> {
|
|
385
|
+
const column = SHARED_CHILD_SORT_COLUMNS[options.sortBy];
|
|
386
|
+
const direction = options.order === "desc" ? desc(column) : asc(column);
|
|
387
|
+
const statusFilter =
|
|
388
|
+
options.status === "active"
|
|
389
|
+
? isNull(apiKeys.revokedAt)
|
|
390
|
+
: options.status === "revoked"
|
|
391
|
+
? isNotNull(apiKeys.revokedAt)
|
|
392
|
+
: undefined;
|
|
393
|
+
const where = and(
|
|
394
|
+
eq(apiKeys.tenantId, tenantId),
|
|
395
|
+
eq(apiKeys.parentKeyId, parentKeyId),
|
|
396
|
+
statusFilter,
|
|
397
|
+
);
|
|
398
|
+
// The page query and the count query run together; the counts feed the
|
|
399
|
+
// modal's header ("active users") without a second round trip per page.
|
|
400
|
+
const [rows, counts, template] = await Promise.all([
|
|
401
|
+
this.db.select().from(apiKeys).where(where).orderBy(direction, apiKeys.createdAt, apiKeys.id)
|
|
402
|
+
.limit(options.limit)
|
|
403
|
+
.offset(options.offset),
|
|
404
|
+
this.db
|
|
405
|
+
.select({
|
|
406
|
+
total: count(),
|
|
407
|
+
active: sql<number>`count(*) filter (where ${apiKeys.revokedAt} is null)`,
|
|
408
|
+
revoked: sql<number>`count(*) filter (where ${apiKeys.revokedAt} is not null)`,
|
|
409
|
+
})
|
|
410
|
+
.from(apiKeys)
|
|
411
|
+
.where(where),
|
|
412
|
+
this.db
|
|
413
|
+
.select({ lifetime: apiKeys.lifetimeTokensConsumed })
|
|
414
|
+
.from(apiKeys)
|
|
415
|
+
.where(eq(apiKeys.id, parentKeyId))
|
|
416
|
+
.limit(1),
|
|
417
|
+
]);
|
|
418
|
+
const summary = counts[0]!;
|
|
419
|
+
// Cumulative family spend: the template's own counter plus every telemetry
|
|
420
|
+
// total row the family ever recorded (revoked/deleted children stay
|
|
421
|
+
// counted). Deleted children leave telemetry rows behind, so this is the
|
|
422
|
+
// number that survives cleanup — matching the overview and share page.
|
|
423
|
+
const familyTelemetry = await this.sumFamilyTelemetryConsumed(tenantId, parentKeyId);
|
|
424
|
+
return {
|
|
425
|
+
rows: await this.withPopups(this.db, rows),
|
|
426
|
+
total: Number(summary.total),
|
|
427
|
+
active: Number(summary.active),
|
|
428
|
+
revoked: Number(summary.revoked),
|
|
429
|
+
consumed: Number(template[0]?.lifetime ?? 0) + familyTelemetry,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Revokes every active child (or only the active children that never spent
|
|
434
|
+
* a token) of a share template, in bounded batches so one statement never
|
|
435
|
+
* exceeds the pool's statement timeout. A family can hold hundreds of
|
|
436
|
+
* thousands of recipients; the console's bulk actions target all of them,
|
|
437
|
+
* not just the currently rendered page.
|
|
438
|
+
*/
|
|
439
|
+
async revokeChildrenByScope(
|
|
440
|
+
tenantId: string,
|
|
441
|
+
parentKeyId: string,
|
|
442
|
+
scope: "active" | "unused",
|
|
443
|
+
): Promise<number> {
|
|
444
|
+
const condition = and(
|
|
445
|
+
eq(apiKeys.tenantId, tenantId),
|
|
446
|
+
eq(apiKeys.parentKeyId, parentKeyId),
|
|
447
|
+
isNull(apiKeys.revokedAt),
|
|
448
|
+
...(scope === "unused" ? [eq(apiKeys.lifetimeTokensConsumed, 0)] : []),
|
|
449
|
+
);
|
|
450
|
+
let deleted = 0;
|
|
451
|
+
for (;;) {
|
|
452
|
+
const batch = await this.db
|
|
453
|
+
.select({ id: apiKeys.id })
|
|
454
|
+
.from(apiKeys)
|
|
455
|
+
.where(condition)
|
|
456
|
+
.orderBy(apiKeys.id)
|
|
457
|
+
.limit(5000);
|
|
458
|
+
if (batch.length === 0) return deleted;
|
|
459
|
+
const result = await this.db
|
|
460
|
+
.delete(apiKeys)
|
|
461
|
+
.where(inArray(apiKeys.id, batch.map((row) => row.id)));
|
|
462
|
+
deleted += result.rowCount ?? batch.length;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async sumFamilyTelemetryConsumed(tenantId: string, parentKeyId: string): Promise<number> {
|
|
467
|
+
const rows = await this.db
|
|
468
|
+
.select({
|
|
469
|
+
total: sql<number>`coalesce(sum(${telemetryUsageTotals.inputTokens} + ${telemetryUsageTotals.outputTokens}), 0)`,
|
|
470
|
+
})
|
|
471
|
+
.from(telemetryUsageTotals)
|
|
472
|
+
.where(
|
|
473
|
+
and(
|
|
474
|
+
eq(telemetryUsageTotals.tenantId, tenantId),
|
|
475
|
+
eq(telemetryUsageTotals.identityType, "api_key"),
|
|
476
|
+
sql`(${telemetryUsageTotals.familyTemplateKeyId} = ${parentKeyId} or (${telemetryUsageTotals.familyTemplateKeyId} is null and exists (select 1 from ${apiKeys} where ${apiKeys.id} = ${telemetryUsageTotals.entityId} and ${apiKeys.parentKeyId} = ${parentKeyId})))`,
|
|
477
|
+
),
|
|
478
|
+
);
|
|
479
|
+
return Number(rows[0]?.total ?? 0);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async create(record: ApiKeyRecord): Promise<void> {
|
|
483
|
+
// Append to the end of the tenant's list rather than relying on creation
|
|
484
|
+
// time, so a new key never displaces the existing rows' positions.
|
|
485
|
+
const sortIndex = await this.nextSortIndex(record.tenantId);
|
|
486
|
+
await this.db.insert(apiKeys).values({
|
|
487
|
+
id: record.id,
|
|
488
|
+
tenantId: record.tenantId,
|
|
489
|
+
sortIndex,
|
|
490
|
+
keyHash: record.keyHash,
|
|
491
|
+
keyMode: record.keyMode,
|
|
492
|
+
parentKeyId: record.parentKeyId ?? null,
|
|
493
|
+
issuedClientIp: record.issuedClientIp ?? null,
|
|
494
|
+
issuedClientIpKey: record.issuedClientIpKey ?? null,
|
|
495
|
+
label: record.label,
|
|
496
|
+
scopes: record.scopes,
|
|
497
|
+
enabled: record.enabled,
|
|
498
|
+
keyPrefix: record.keyPrefix ?? null,
|
|
499
|
+
keyEncrypted: record.keyEncrypted ?? null,
|
|
500
|
+
notesTitle: record.notesTitle ?? null,
|
|
501
|
+
notesSubtitle: record.notesSubtitle ?? null,
|
|
502
|
+
notesBody: record.notesBody ?? null,
|
|
503
|
+
requestsPerMinute: record.requestsPerMinute ?? null,
|
|
504
|
+
dailyTokenLimit: record.dailyTokenLimit ?? null,
|
|
505
|
+
monthlyTokenLimit: record.monthlyTokenLimit ?? null,
|
|
506
|
+
lifetimeTokenBudget: record.lifetimeTokenBudget ?? null,
|
|
507
|
+
maxConcurrentRequests: record.maxConcurrentRequests ?? null,
|
|
508
|
+
maxChildren: record.maxChildren ?? null,
|
|
509
|
+
modelPrefix: record.modelPrefix ?? null,
|
|
510
|
+
modelAccessMode: record.modelAccessMode ?? "whitelist",
|
|
511
|
+
modelList: record.modelList ?? null,
|
|
512
|
+
clientRouterDenylist: record.clientRouterDenylist ?? null,
|
|
513
|
+
lifetimeTokensConsumed: record.tokensConsumed,
|
|
514
|
+
});
|
|
515
|
+
await writeSharePopup(this.db, record.id, record);
|
|
516
|
+
}
|
|
517
|
+
async update(
|
|
518
|
+
tenantId: string,
|
|
519
|
+
keyId: string,
|
|
520
|
+
patch: ApiKeyPatch,
|
|
521
|
+
): Promise<ApiKeyRecord | undefined> {
|
|
522
|
+
return this.db.transaction(async (tx) => {
|
|
523
|
+
const existingRows = await lockRows(
|
|
524
|
+
tx
|
|
525
|
+
.select({
|
|
526
|
+
id: apiKeys.id,
|
|
527
|
+
keyMode: apiKeys.keyMode,
|
|
528
|
+
parentKeyId: apiKeys.parentKeyId,
|
|
529
|
+
})
|
|
530
|
+
.from(apiKeys)
|
|
531
|
+
.where(and(eq(apiKeys.tenantId, tenantId), eq(apiKeys.id, keyId)))
|
|
532
|
+
.limit(1),
|
|
533
|
+
);
|
|
534
|
+
const existing = existingRows[0];
|
|
535
|
+
if (!existing) return undefined;
|
|
536
|
+
if (existing.parentKeyId !== null && patch.keyMode !== undefined && patch.keyMode !== existing.keyMode)
|
|
537
|
+
return undefined;
|
|
538
|
+
|
|
539
|
+
if (existing.keyMode === "share" && patch.keyMode === "personal") {
|
|
540
|
+
await tx
|
|
541
|
+
.update(apiKeys)
|
|
542
|
+
.set({ revokedAt: new Date() })
|
|
543
|
+
.where(and(eq(apiKeys.parentKeyId, keyId), isNull(apiKeys.revokedAt)));
|
|
544
|
+
await tx
|
|
545
|
+
.update(shareLinks)
|
|
546
|
+
.set({ active: false })
|
|
547
|
+
.where(and(eq(shareLinks.apiKeyId, keyId), eq(shareLinks.active, true)));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const rows = await tx
|
|
551
|
+
.update(apiKeys)
|
|
552
|
+
.set({
|
|
553
|
+
...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}),
|
|
554
|
+
...(patch.label !== undefined ? { label: patch.label } : {}),
|
|
555
|
+
...(patch.isDefault !== undefined ? { isDefault: patch.isDefault } : {}),
|
|
556
|
+
...(patch.keyHash !== undefined ? { keyHash: patch.keyHash } : {}),
|
|
557
|
+
...(patch.keyMode !== undefined ? { keyMode: patch.keyMode } : {}),
|
|
558
|
+
...(patch.keyEncrypted !== undefined ? { keyEncrypted: patch.keyEncrypted } : {}),
|
|
559
|
+
...(patch.keyPrefix !== undefined ? { keyPrefix: patch.keyPrefix } : {}),
|
|
560
|
+
...(patch.notesTitle !== undefined ? { notesTitle: patch.notesTitle } : {}),
|
|
561
|
+
...(patch.notesSubtitle !== undefined ? { notesSubtitle: patch.notesSubtitle } : {}),
|
|
562
|
+
...(patch.notesBody !== undefined ? { notesBody: patch.notesBody } : {}),
|
|
563
|
+
...(patch.requestsPerMinute !== undefined
|
|
564
|
+
? { requestsPerMinute: patch.requestsPerMinute }
|
|
565
|
+
: {}),
|
|
566
|
+
...(patch.dailyTokenLimit !== undefined ? { dailyTokenLimit: patch.dailyTokenLimit } : {}),
|
|
567
|
+
...(patch.monthlyTokenLimit !== undefined
|
|
568
|
+
? { monthlyTokenLimit: patch.monthlyTokenLimit }
|
|
569
|
+
: {}),
|
|
570
|
+
...(patch.lifetimeTokenBudget !== undefined
|
|
571
|
+
? { lifetimeTokenBudget: patch.lifetimeTokenBudget }
|
|
572
|
+
: {}),
|
|
573
|
+
...(patch.maxConcurrentRequests !== undefined
|
|
574
|
+
? { maxConcurrentRequests: patch.maxConcurrentRequests }
|
|
575
|
+
: {}),
|
|
576
|
+
...(patch.maxChildren !== undefined ? { maxChildren: patch.maxChildren } : {}),
|
|
577
|
+
...(patch.modelPrefix !== undefined ? { modelPrefix: patch.modelPrefix } : {}),
|
|
578
|
+
...(patch.modelAccessMode !== undefined ? { modelAccessMode: patch.modelAccessMode } : {}),
|
|
579
|
+
...(patch.modelList !== undefined ? { modelList: patch.modelList } : {}),
|
|
580
|
+
...(patch.clientRouterDenylist !== undefined
|
|
581
|
+
? { clientRouterDenylist: patch.clientRouterDenylist }
|
|
582
|
+
: {}),
|
|
583
|
+
// Scopes are validated once, in `validateApiKeyRequest`, against the
|
|
584
|
+
// tenant scope set; the store writes what it is handed. Omitting the
|
|
585
|
+
// field here means an update is type-checked, accepted, and then
|
|
586
|
+
// silently dropped — a grant like `routing:cli_mapping` can be
|
|
587
|
+
// turned on and never stick.
|
|
588
|
+
...(patch.scopes === undefined ? {} : { scopes: patch.scopes }),
|
|
589
|
+
})
|
|
590
|
+
.where(and(eq(apiKeys.tenantId, tenantId), eq(apiKeys.id, keyId)))
|
|
591
|
+
.returning();
|
|
592
|
+
if (!rows[0]) return undefined;
|
|
593
|
+
if (popupPatchTouched(patch)) await mergeSharePopup(tx, keyId, patch);
|
|
594
|
+
return (await this.withPopups(tx, rows))[0];
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
async revoke(tenantId: string, keyId: string, revokedAt: Date): Promise<boolean> {
|
|
598
|
+
return this.db.transaction(async (tx) => {
|
|
599
|
+
const rows = await tx
|
|
600
|
+
.update(apiKeys)
|
|
601
|
+
.set({ revokedAt })
|
|
602
|
+
.where(
|
|
603
|
+
and(
|
|
604
|
+
eq(apiKeys.tenantId, tenantId),
|
|
605
|
+
eq(apiKeys.id, keyId),
|
|
606
|
+
isNull(apiKeys.revokedAt),
|
|
607
|
+
),
|
|
608
|
+
)
|
|
609
|
+
.returning({ id: apiKeys.id, keyMode: apiKeys.keyMode, parentKeyId: apiKeys.parentKeyId });
|
|
610
|
+
const row = rows[0];
|
|
611
|
+
if (!row) return false;
|
|
612
|
+
if (row.keyMode === "share" && row.parentKeyId === null) {
|
|
613
|
+
await tx
|
|
614
|
+
.update(apiKeys)
|
|
615
|
+
.set({ revokedAt })
|
|
616
|
+
.where(and(eq(apiKeys.parentKeyId, keyId), isNull(apiKeys.revokedAt)));
|
|
617
|
+
await tx
|
|
618
|
+
.update(shareLinks)
|
|
619
|
+
.set({ active: false })
|
|
620
|
+
.where(and(eq(shareLinks.apiKeyId, keyId), eq(shareLinks.active, true)));
|
|
621
|
+
}
|
|
622
|
+
return true;
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
async remove(tenantId: string, keyId: string): Promise<boolean> {
|
|
626
|
+
return this.db.transaction(async (tx) => {
|
|
627
|
+
const rows = await tx
|
|
628
|
+
.delete(apiKeys)
|
|
629
|
+
.where(
|
|
630
|
+
and(
|
|
631
|
+
eq(apiKeys.tenantId, tenantId),
|
|
632
|
+
eq(apiKeys.id, keyId),
|
|
633
|
+
eq(apiKeys.isDefault, false),
|
|
634
|
+
),
|
|
635
|
+
)
|
|
636
|
+
.returning({ id: apiKeys.id });
|
|
637
|
+
return rows.length > 0;
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
async findActiveByHash(hash: string): Promise<typeof apiKeys.$inferSelect | undefined> {
|
|
641
|
+
// The SQLite backend holds this lookup prepared. The builder path rebuilds
|
|
642
|
+
// the query per call, which measured 60x the lookup itself, so the hot
|
|
643
|
+
// path skips it. PostgreSQL has no prepared statement here and uses the
|
|
644
|
+
// builder, whose plan is cached server-side.
|
|
645
|
+
const prepared = getDbHandle().auth;
|
|
646
|
+
if (prepared) return this.authRow(prepared.byHash.get(hash));
|
|
647
|
+
const rows = await this.db
|
|
648
|
+
.select()
|
|
649
|
+
.from(apiKeys)
|
|
650
|
+
.where(and(eq(apiKeys.keyHash, hash), eq(apiKeys.enabled, true), isNull(apiKeys.revokedAt)))
|
|
651
|
+
.limit(1);
|
|
652
|
+
return rows[0];
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async findActiveById(keyId: string): Promise<typeof apiKeys.$inferSelect | undefined> {
|
|
656
|
+
const prepared = getDbHandle().auth;
|
|
657
|
+
if (prepared) return this.authRow(prepared.byId.get(keyId));
|
|
658
|
+
const rows = await this.db
|
|
659
|
+
.select()
|
|
660
|
+
.from(apiKeys)
|
|
661
|
+
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.enabled, true), isNull(apiKeys.revokedAt)))
|
|
662
|
+
.limit(1);
|
|
663
|
+
return rows[0];
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Maps a raw SQLite auth row to the shape drizzle returns.
|
|
668
|
+
*
|
|
669
|
+
* The prepared statement selects the table's own column names, which are
|
|
670
|
+
* snake_case, while every reader of this row — `resolveApiKeyAuthorization`
|
|
671
|
+
* included — reads the camelCase `$inferSelect` fields. Copying the raw
|
|
672
|
+
* keys and casting would typecheck and then hand auth `undefined` for
|
|
673
|
+
* `parentKeyId`, `tenantId`, and every limit. SQLite also stores JSON as
|
|
674
|
+
* text and booleans as integers, so those are converted here too. The SQL
|
|
675
|
+
* stays a plain select.
|
|
676
|
+
*/
|
|
677
|
+
private authRow(raw: Record<string, unknown> | null): typeof apiKeys.$inferSelect | undefined {
|
|
678
|
+
if (raw === null) return undefined;
|
|
679
|
+
const text = (key: string): string | null => {
|
|
680
|
+
const value = raw[key];
|
|
681
|
+
return typeof value === "string" ? value : null;
|
|
682
|
+
};
|
|
683
|
+
const date = (key: string): Date | null => {
|
|
684
|
+
const value = text(key);
|
|
685
|
+
return value === null ? null : new Date(value);
|
|
686
|
+
};
|
|
687
|
+
const integer = (key: string): number | null => {
|
|
688
|
+
const value = raw[key];
|
|
689
|
+
return typeof value === "number" ? value : null;
|
|
690
|
+
};
|
|
691
|
+
const json = (key: string): unknown => {
|
|
692
|
+
const value = raw[key];
|
|
693
|
+
return typeof value === "string" ? JSON.parse(value) : (value ?? null);
|
|
694
|
+
};
|
|
695
|
+
const flag = (key: string): boolean => raw[key] === 1 || raw[key] === true;
|
|
696
|
+
return {
|
|
697
|
+
id: text("id") ?? "",
|
|
698
|
+
tenantId: text("tenant_id") ?? "",
|
|
699
|
+
keyHash: text("key_hash"),
|
|
700
|
+
keyMode: (text("key_mode") ?? "personal") as ApiKeyMode,
|
|
701
|
+
parentKeyId: text("parent_key_id"),
|
|
702
|
+
issuedClientIp: text("issued_client_ip"),
|
|
703
|
+
issuedClientIpKey: text("issued_client_ip_key"),
|
|
704
|
+
label: text("label") ?? "",
|
|
705
|
+
scopes: json("scopes"),
|
|
706
|
+
requestsPerMinute: integer("requests_per_minute"),
|
|
707
|
+
dailyTokenLimit: integer("daily_token_limit"),
|
|
708
|
+
monthlyTokenLimit: integer("monthly_token_limit"),
|
|
709
|
+
lifetimeTokenBudget: integer("lifetime_token_budget"),
|
|
710
|
+
lifetimeTokensConsumed: integer("lifetime_tokens_consumed") ?? 0,
|
|
711
|
+
maxConcurrentRequests: integer("max_concurrent_requests"),
|
|
712
|
+
modelAccessMode: (text("model_access_mode") ?? "whitelist") as ApiKeyModelAccessMode,
|
|
713
|
+
modelList: json("model_list"),
|
|
714
|
+
clientRouterDenylist: json("client_router_denylist"),
|
|
715
|
+
modelPrefix: text("model_prefix"),
|
|
716
|
+
createdAt: date("created_at") ?? new Date(0),
|
|
717
|
+
enabled: flag("enabled"),
|
|
718
|
+
revokedAt: date("revoked_at"),
|
|
719
|
+
keyPrefix: text("key_prefix"),
|
|
720
|
+
keyEncrypted: raw["key_encrypted"] ?? null,
|
|
721
|
+
notesTitle: text("notes_title"),
|
|
722
|
+
notesSubtitle: text("notes_subtitle"),
|
|
723
|
+
notesBody: text("notes_body"),
|
|
724
|
+
isDefault: flag("is_default"),
|
|
725
|
+
sortIndex: integer("sort_index") ?? 0,
|
|
726
|
+
} as unknown as typeof apiKeys.$inferSelect;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Total lifetime consumption across one key's children, revoked included.
|
|
731
|
+
*
|
|
732
|
+
* The parent's own counter never moves for child traffic — attribution
|
|
733
|
+
* lands on the child row — so quota enforcement reads the parent plus this
|
|
734
|
+
* sum rather than the parent alone.
|
|
735
|
+
*
|
|
736
|
+
* Revoked children stay in the sum on purpose. Tokens a recipient already
|
|
737
|
+
* spent are spent; dropping them on revoke would refund the parent's budget
|
|
738
|
+
* the moment the Redis lifetime counter is re-seeded from this total (which
|
|
739
|
+
* happens on every purge, and whenever that counter's TTL lapses), letting a
|
|
740
|
+
* recipient burn the whole allowance, be revoked, and hand the next
|
|
741
|
+
* recipient a fresh one. It also keeps this figure equal to the "Total
|
|
742
|
+
* quota" the share dialog reports, which counts every recipient.
|
|
743
|
+
*/
|
|
744
|
+
async sumChildrenConsumed(parentKeyId: string): Promise<number> {
|
|
745
|
+
const prepared = getDbHandle().auth;
|
|
746
|
+
if (prepared) return Number(prepared.childrenConsumed.get(parentKeyId)?.total ?? 0);
|
|
747
|
+
const rows = await this.db
|
|
748
|
+
.select({ total: sql<number>`coalesce(sum(${apiKeys.lifetimeTokensConsumed}), 0)` })
|
|
749
|
+
.from(apiKeys)
|
|
750
|
+
.where(eq(apiKeys.parentKeyId, parentKeyId));
|
|
751
|
+
return Number(rows[0]?.total ?? 0);
|
|
752
|
+
}
|
|
753
|
+
}
|