cartethyia 2.7.1 → 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-D-J75hBM.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-QoJ6foxo.js → CombosPage-JEbYJFxw.js} +1 -1
- package/dist/dashboard/assets/CombosPage-JEbYJFxw.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-DpNRay4C.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-DubdQcvX.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-ATpaxrbl.js → QuotaPage-Ci5O4BnH.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-Ci5O4BnH.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-CETophpx.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-CAK-EN9z.js → SortableList-BAL5npBC.js} +1 -1
- package/dist/dashboard/assets/SortableList-BAL5npBC.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-B7W2_RhT.js → StudioPage-TP7voAQ0.js} +3 -3
- package/dist/dashboard/assets/StudioPage-TP7voAQ0.js.gz +0 -0
- package/dist/dashboard/assets/{UsagePage-6T4EFDjW.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-BLBLxX0h.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-mpBPqAQ8.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-D6QzEC_n.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-Cj5Nw43g.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/cli-tools/contracts.ts +10 -4
- 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/network/outbound-fetch.ts +281 -226
- package/src/network/pool/agent.ts +7 -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/default-registry.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/ollamacloud-models.ts +50 -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 +24 -4
- package/src/providers/integrations/qoder/qoder-realm.ts +20 -0
- package/src/providers/integrations/qoder/qoder-text-tools.ts +277 -0
- package/src/providers/integrations/qoder/qoder-umid.ts +288 -0
- package/src/providers/integrations/qoder/qoder.ts +1375 -1641
- package/src/providers/operations/account-error-classification.ts +1682 -1660
- package/src/providers/operations/account-health-service.ts +619 -621
- package/src/providers/operations/client-versions.ts +37 -22
- package/src/providers/operations/provider-catalog-service.ts +11 -1
- package/src/providers/provider-contracts.ts +15 -2
- package/src/security/access-control.ts +19 -0
- package/src/security/api-key-auth.ts +14 -1
- package/src/security/cli-client-fingerprint.ts +9 -2
- package/src/transport/dispatch/empty-stream.ts +224 -224
- 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/failure-policy.ts +33 -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 +41 -12
- package/src/transport/routing/route-model.ts +53 -0
- package/src/transport/routing/router.ts +306 -228
- package/src/transport/surface/responses/encode.ts +2 -3
- package/src/transport/surface/responses/parse.ts +49 -7
- package/src/types/undici.d.ts +33 -0
- package/src/workers/daily-checkin.ts +2 -0
- package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js +0 -1
- package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js +0 -1
- package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js +0 -1
- package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DubdQcvX.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js +0 -1
- package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +0 -4
- package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js +0 -9
- package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-BdgECjqq.js +0 -1
- package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-6T4EFDjW.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-BLBLxX0h.js.gz +0 -0
- package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
- package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
- package/dist/dashboard/assets/formatters-mpBPqAQ8.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
- package/dist/dashboard/assets/index-D6QzEC_n.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-Cj5Nw43g.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
|
@@ -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
|
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Qoder native-identity console operations, shared by the tenant-scoped and
|
|
3
|
+
* global-admin account route groups.
|
|
4
|
+
*
|
|
5
|
+
* The identity is the account's device identity (minted seed). The routes
|
|
6
|
+
* surface it for export/import/regenerate from the provider-detail screen;
|
|
7
|
+
* the device-uniqueness invariant lives in the store (and the partial unique
|
|
8
|
+
* index behind it).
|
|
9
|
+
*/
|
|
10
|
+
import { and, eq, isNull } from "drizzle-orm";
|
|
11
|
+
import { Elysia, t } from "elysia";
|
|
12
|
+
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
13
|
+
import { providerAccounts, type NativeIdentityRecord } from "../../persistence/schema";
|
|
14
|
+
import {
|
|
15
|
+
allocateQoderNativeIdentity,
|
|
16
|
+
DrizzleQoderNativeIdentityStore,
|
|
17
|
+
type QoderNativeIdentityStore,
|
|
18
|
+
} from "../../providers/integrations/qoder/qoder-native-identity-store";
|
|
19
|
+
import { ConsoleDomainError, requireGlobalAdmin, requireTenantScope } from "../shared/errors";
|
|
20
|
+
import type { ConsoleAccessResolver } from "../auth/access";
|
|
21
|
+
|
|
22
|
+
/** True when `token` looks like a plausible machine token. */
|
|
23
|
+
export function isValidNativeIdentityToken(token: string): boolean {
|
|
24
|
+
return typeof token === "string" && token.length >= 8 && token.length <= 512;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Allocates a fresh device identity for regenerate. Injected so tests can stub
|
|
29
|
+
* the native bridge; the default runs the real `runtime-info` bridge through
|
|
30
|
+
* the store.
|
|
31
|
+
*/
|
|
32
|
+
export type NativeIdentityAllocator = (args: {
|
|
33
|
+
providerId: string;
|
|
34
|
+
accountId: string;
|
|
35
|
+
avoidToken?: string;
|
|
36
|
+
}) => Promise<NativeIdentityRecord | null>;
|
|
37
|
+
|
|
38
|
+
function defaultAllocator(store: QoderNativeIdentityStore): NativeIdentityAllocator {
|
|
39
|
+
return ({ providerId, accountId, avoidToken }) =>
|
|
40
|
+
allocateQoderNativeIdentity(store, providerId, accountId, {
|
|
41
|
+
...(avoidToken !== undefined ? { avoidToken } : {}),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Loads one account row, scoped by tenant (null = global/shared). */
|
|
46
|
+
async function loadAccount(
|
|
47
|
+
db: CartethyiaDatabase,
|
|
48
|
+
accountId: string,
|
|
49
|
+
tenantId: string | null,
|
|
50
|
+
): Promise<{ providerId: string; nativeIdentity: NativeIdentityRecord | null } | undefined> {
|
|
51
|
+
const rows = await db
|
|
52
|
+
.select({
|
|
53
|
+
providerId: providerAccounts.providerId,
|
|
54
|
+
nativeIdentity: providerAccounts.nativeIdentity,
|
|
55
|
+
})
|
|
56
|
+
.from(providerAccounts)
|
|
57
|
+
.where(
|
|
58
|
+
tenantId === null
|
|
59
|
+
? and(eq(providerAccounts.id, accountId), isNull(providerAccounts.tenantId))
|
|
60
|
+
: and(eq(providerAccounts.id, accountId), eq(providerAccounts.tenantId, tenantId)),
|
|
61
|
+
)
|
|
62
|
+
.limit(1);
|
|
63
|
+
return rows[0];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface NativeIdentityOps {
|
|
67
|
+
get(accountId: string, tenantId: string | null): Promise<NativeIdentityRecord | null>;
|
|
68
|
+
put(accountId: string, tenantId: string | null, record: NativeIdentityRecord): Promise<NativeIdentityRecord>;
|
|
69
|
+
regenerate(accountId: string, tenantId: string | null): Promise<NativeIdentityRecord | null>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createNativeIdentityOps(
|
|
73
|
+
db: CartethyiaDatabase,
|
|
74
|
+
allocator: NativeIdentityAllocator = defaultAllocator(new DrizzleQoderNativeIdentityStore(db)),
|
|
75
|
+
): NativeIdentityOps {
|
|
76
|
+
const store = new DrizzleQoderNativeIdentityStore(db);
|
|
77
|
+
return {
|
|
78
|
+
async get(accountId, tenantId) {
|
|
79
|
+
const account = await loadAccount(db, accountId, tenantId);
|
|
80
|
+
// A missing row under the narrowed tenant scope means the account is not
|
|
81
|
+
// visible to this caller (cross-tenant read) — report not-found, the
|
|
82
|
+
// same convention the account routes use, rather than echoing null.
|
|
83
|
+
if (!account) throw new ConsoleDomainError("not_found", 404, "Account not found");
|
|
84
|
+
return account.nativeIdentity;
|
|
85
|
+
},
|
|
86
|
+
async put(accountId, tenantId, record) {
|
|
87
|
+
const account = await loadAccount(db, accountId, tenantId);
|
|
88
|
+
if (!account) throw new ConsoleDomainError("not_found", 404, "Account not found");
|
|
89
|
+
if (!isValidNativeIdentityToken(record.machineToken)) {
|
|
90
|
+
throw new ConsoleDomainError("invalid_identity", 400, "machineToken must be a non-empty string (8–512 chars)");
|
|
91
|
+
}
|
|
92
|
+
if (!record.machineType || !record.machineCode) {
|
|
93
|
+
throw new ConsoleDomainError("invalid_identity", 400, "machineToken, machineType, and machineCode are required");
|
|
94
|
+
}
|
|
95
|
+
// Device uniqueness: another account already owns this token? The pool is
|
|
96
|
+
// global across Qoder accounts (both realms), matching the unique index.
|
|
97
|
+
const occupied = await store.occupied(accountId);
|
|
98
|
+
if (occupied.has(record.machineToken)) {
|
|
99
|
+
throw new ConsoleDomainError("slot_taken", 409, `Native identity token is already bound to another account`);
|
|
100
|
+
}
|
|
101
|
+
await store.bind(account.providerId, accountId, record);
|
|
102
|
+
return record;
|
|
103
|
+
},
|
|
104
|
+
async regenerate(accountId, tenantId) {
|
|
105
|
+
const account = await loadAccount(db, accountId, tenantId);
|
|
106
|
+
if (!account) throw new ConsoleDomainError("not_found", 404, "Account not found");
|
|
107
|
+
return allocator({
|
|
108
|
+
providerId: account.providerId,
|
|
109
|
+
accountId,
|
|
110
|
+
...(account.nativeIdentity?.machineToken !== undefined ? { avoidToken: account.nativeIdentity.machineToken } : {}),
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The authorization decision for one account's native-identity route.
|
|
118
|
+
*
|
|
119
|
+
* Account ownership decides which rule applies: shared accounts
|
|
120
|
+
* (`tenant_id IS NULL`) need platform admin; tenant-owned accounts need the
|
|
121
|
+
* tenant scope for the owning tenant.
|
|
122
|
+
*/
|
|
123
|
+
export interface NativeIdentityScope {
|
|
124
|
+
readonly tenantId: string | null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Registers the native-identity routes on `app` (path
|
|
129
|
+
* `/provider-accounts/:id/native-identity`).
|
|
130
|
+
*
|
|
131
|
+
* `authorize` performs the access check based on the account's owner and
|
|
132
|
+
* returns the tenant scope to load the row under, so a shared account is
|
|
133
|
+
* reachable only by a platform admin and a tenant-owned account only by its
|
|
134
|
+
* tenant.
|
|
135
|
+
*/
|
|
136
|
+
export function registerNativeIdentityRoutes(
|
|
137
|
+
app: Elysia,
|
|
138
|
+
deps: { readonly db: CartethyiaDatabase; readonly accessResolver: ConsoleAccessResolver },
|
|
139
|
+
allocator?: NativeIdentityAllocator,
|
|
140
|
+
): void {
|
|
141
|
+
const ops = createNativeIdentityOps(deps.db, allocator);
|
|
142
|
+
const authorize = async (
|
|
143
|
+
request: Request,
|
|
144
|
+
accountId: string,
|
|
145
|
+
scope: "dashboard:read" | "dashboard:write",
|
|
146
|
+
): Promise<NativeIdentityScope> => {
|
|
147
|
+
const rows = await deps.db
|
|
148
|
+
.select({ tenantId: providerAccounts.tenantId })
|
|
149
|
+
.from(providerAccounts)
|
|
150
|
+
.where(eq(providerAccounts.id, accountId))
|
|
151
|
+
.limit(1);
|
|
152
|
+
if (rows.length === 0) {
|
|
153
|
+
throw new ConsoleDomainError("not_found", 404, "Account not found");
|
|
154
|
+
}
|
|
155
|
+
const ownerTenantId = rows[0]!.tenantId;
|
|
156
|
+
if (ownerTenantId === null) {
|
|
157
|
+
requireGlobalAdmin(deps.accessResolver(request));
|
|
158
|
+
return { tenantId: null };
|
|
159
|
+
}
|
|
160
|
+
const access = requireTenantScope(deps.accessResolver(request), scope);
|
|
161
|
+
return { tenantId: access.tenantId };
|
|
162
|
+
};
|
|
163
|
+
app
|
|
164
|
+
.get("/provider-accounts/:id/native-identity", async ({ request, params }) => {
|
|
165
|
+
const { tenantId } = await authorize(request, params.id, "dashboard:read");
|
|
166
|
+
return { identity: await ops.get(params.id, tenantId) };
|
|
167
|
+
})
|
|
168
|
+
.put(
|
|
169
|
+
"/provider-accounts/:id/native-identity",
|
|
170
|
+
{
|
|
171
|
+
body: t.Object({
|
|
172
|
+
machineToken: t.String(),
|
|
173
|
+
machineType: t.String(),
|
|
174
|
+
machineCode: t.String(),
|
|
175
|
+
seed: t.Optional(t.String()),
|
|
176
|
+
}),
|
|
177
|
+
},
|
|
178
|
+
async ({ request, params, body }) => {
|
|
179
|
+
const { tenantId } = await authorize(request, params.id, "dashboard:write");
|
|
180
|
+
const identity = await ops.put(params.id, tenantId, body as NativeIdentityRecord);
|
|
181
|
+
return { identity };
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
.post("/provider-accounts/:id/native-identity/regenerate", async ({ request, params }) => {
|
|
185
|
+
const { tenantId } = await authorize(request, params.id, "dashboard:write");
|
|
186
|
+
const identity = await ops.regenerate(params.id, tenantId);
|
|
187
|
+
return { identity };
|
|
188
|
+
});
|
|
189
|
+
}
|
|
@@ -51,12 +51,18 @@ export interface RemoteSupportStartResponse extends RemoteSupportStatus {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/** Human-readable install guidance, keyed by tool. Never an installer. */
|
|
54
|
-
export
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
export function remoteSupportInstallHint(tool: RemoteSupportTool): string {
|
|
55
|
+
// Cross-OS install hint: pick the host's package manager. A Windows-only
|
|
56
|
+
// hint on Linux/macOS would be nonsense (and vice versa).
|
|
57
|
+
const win = process.platform === "win32";
|
|
58
|
+
const mac = process.platform === "darwin";
|
|
59
|
+
const installCommand = (pkg: string): string =>
|
|
60
|
+
win ? `winget install ${pkg}` : mac ? `brew install ${pkg}` : `sudo apt-get install -y ${pkg}`;
|
|
61
|
+
if (tool === "cloudflared") {
|
|
62
|
+
return `cloudflared is not installed. Install it yourself (${installCommand("cloudflared")}) and try again.`;
|
|
63
|
+
}
|
|
64
|
+
return `tailscale is not installed. Install it yourself (${installCommand("tailscale")}), run \`tailscale up\` once, then try again.`;
|
|
65
|
+
}
|
|
60
66
|
|
|
61
67
|
export function isRemoteSupportTool(value: unknown): value is RemoteSupportTool {
|
|
62
68
|
return typeof value === "string" && (REMOTE_SUPPORT_TOOLS as readonly string[]).includes(value);
|
|
@@ -18,7 +18,7 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, write
|
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import { resolveUserConfigDir } from "../../config/package-paths";
|
|
20
20
|
import {
|
|
21
|
-
|
|
21
|
+
remoteSupportInstallHint,
|
|
22
22
|
REMOTE_SUPPORT_TOOLS,
|
|
23
23
|
type RemoteSupportBlocker,
|
|
24
24
|
type RemoteSupportStatus,
|
|
@@ -430,7 +430,7 @@ export function createRemoteSupportService(config: RemoteSupportServiceConfig):
|
|
|
430
430
|
active: null,
|
|
431
431
|
url: null,
|
|
432
432
|
blocker: "not_installed",
|
|
433
|
-
message:
|
|
433
|
+
message: remoteSupportInstallHint(tool),
|
|
434
434
|
port,
|
|
435
435
|
installed: await detectInstalled(),
|
|
436
436
|
};
|
|
@@ -5,7 +5,7 @@ import { globalOrOwnedBy } from "../../persistence/tenant-scope";
|
|
|
5
5
|
// client IP. The child bearer is returned only by the successful issue call.
|
|
6
6
|
|
|
7
7
|
import { Elysia } from "elysia";
|
|
8
|
-
import { and, eq } from "drizzle-orm";
|
|
8
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
9
9
|
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
10
10
|
import { apiKeys, models, providers } from "../../persistence/schema";
|
|
11
11
|
import { PublicModelCatalogStore } from "../providers/catalog/public-model-store";
|
|
@@ -227,12 +227,20 @@ async function resolveFamilyStats(
|
|
|
227
227
|
if (resolved === null) return null;
|
|
228
228
|
const templateId = resolved.key.id;
|
|
229
229
|
const tenantId = resolved.key.tenantId;
|
|
230
|
-
|
|
231
|
-
|
|
230
|
+
// Recipient counts come back as one aggregate row: a family minted by the
|
|
231
|
+
// tenant can carry tens of thousands of children, and materializing their
|
|
232
|
+
// ids only to count them made every share-page view pay for the family's
|
|
233
|
+
// size. The fingerprint/quote keys the rollup needs are addressed by the
|
|
234
|
+
// template id (`familyApiKeyId`), which scopes in SQL without any id list.
|
|
235
|
+
const [counts] = await db
|
|
236
|
+
.select({
|
|
237
|
+
total: sql<number>`count(*)`,
|
|
238
|
+
active: sql<number>`sum(case when ${apiKeys.revokedAt} is null then 1 else 0 end)`,
|
|
239
|
+
})
|
|
232
240
|
.from(apiKeys)
|
|
233
241
|
.where(eq(apiKeys.parentKeyId, templateId));
|
|
234
|
-
const
|
|
235
|
-
const
|
|
242
|
+
const total = Number(counts?.total ?? 0);
|
|
243
|
+
const active = Number(counts?.active ?? 0);
|
|
236
244
|
// Rank only the models this link grants. A refused request still writes a
|
|
237
245
|
// telemetry row carrying the name the client asked for, so an unfiltered
|
|
238
246
|
// top-models table would list the very names the grant excludes. An
|
|
@@ -244,7 +252,12 @@ async function resolveFamilyStats(
|
|
|
244
252
|
configured !== null && configured.length > 0
|
|
245
253
|
? await modelsForShare(db, resolved.key)
|
|
246
254
|
: undefined;
|
|
247
|
-
return stats.getFamilyStats(
|
|
255
|
+
return stats.getFamilyStats(
|
|
256
|
+
tenantId,
|
|
257
|
+
{ templateKeyId: templateId, memberCount: total + 1 },
|
|
258
|
+
{ total, active },
|
|
259
|
+
allowedModels,
|
|
260
|
+
);
|
|
248
261
|
}
|
|
249
262
|
|
|
250
263
|
/** Creates the public enrollment page and one-time shared-key issuance route. */
|
|
@@ -379,6 +392,26 @@ export function createShareRouter(options: ShareRouterOptions): Elysia {
|
|
|
379
392
|
nameHint,
|
|
380
393
|
});
|
|
381
394
|
if (issued.kind === "link_unavailable") return notFound();
|
|
395
|
+
if (issued.kind === "slot_full") {
|
|
396
|
+
return json({
|
|
397
|
+
error: {
|
|
398
|
+
code: "template_slot_full",
|
|
399
|
+
message: "This template has reached its maximum number of recipients.",
|
|
400
|
+
},
|
|
401
|
+
}, 429);
|
|
402
|
+
}
|
|
403
|
+
if (issued.kind === "already_issued") {
|
|
404
|
+
// The recipient's IP already enrolled from this link. The original
|
|
405
|
+
// bearer secret is not stored server-side, so it cannot be returned;
|
|
406
|
+
// refuse the duplicate mint instead. The share page surfaces this as
|
|
407
|
+
// a clear message rather than minting an unbounded key set.
|
|
408
|
+
return json({
|
|
409
|
+
error: {
|
|
410
|
+
code: "already_issued",
|
|
411
|
+
message: "This address already has a key from this share link.",
|
|
412
|
+
},
|
|
413
|
+
}, 409);
|
|
414
|
+
}
|
|
382
415
|
set.status = 201;
|
|
383
416
|
return json({
|
|
384
417
|
key: generated.secret,
|
|
@@ -11,10 +11,15 @@
|
|
|
11
11
|
// masked addresses. No request or response bodies cross this boundary.
|
|
12
12
|
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
|
+
import { eq } from "drizzle-orm";
|
|
14
15
|
import { maskClientIp } from "../../observability/redaction";
|
|
15
16
|
import { type TelemetryEventRecord } from "../../observability/event-log";
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
queryTelemetryEvents as querySqlEvents,
|
|
19
|
+
sumTelemetryTokens,
|
|
20
|
+
} from "../../observability/event-query";
|
|
17
21
|
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
22
|
+
import { apiKeys } from "../../persistence/schema";
|
|
18
23
|
import { isGatewayError } from "../../observability/telemetry-status";
|
|
19
24
|
import type { RedisClient } from "../../persistence/redis";
|
|
20
25
|
import { cachedRead } from "../shared/read-through-cache";
|
|
@@ -81,6 +86,14 @@ export interface ShareFamilyStats {
|
|
|
81
86
|
/** Read-only family rollup for one share link. */
|
|
82
87
|
export interface ShareStatsPort {
|
|
83
88
|
/**
|
|
89
|
+
* The family is addressed by its template key (`templateKeyId`) plus the
|
|
90
|
+
* number of keys it covers at call time (`memberCount`), not by an id list:
|
|
91
|
+
* a family grows with every key the link mints, and enumerating its ids to
|
|
92
|
+
* build a query — or a cache key — scaled both with the family instead of
|
|
93
|
+
* with the request. `memberCount` keeps the cache key sensitive to minting:
|
|
94
|
+
* a newly issued child starts a fresh entry rather than being folded into
|
|
95
|
+
* the previous one for a full TTL.
|
|
96
|
+
*
|
|
84
97
|
* `allowedModels`, when supplied, restricts the top-models table to the names
|
|
85
98
|
* the link actually grants. A rejected request still writes a telemetry row
|
|
86
99
|
* (with the requested name), so without this the table would rank models the
|
|
@@ -90,7 +103,7 @@ export interface ShareStatsPort {
|
|
|
90
103
|
*/
|
|
91
104
|
getFamilyStats(
|
|
92
105
|
tenantId: string,
|
|
93
|
-
|
|
106
|
+
family: { readonly templateKeyId: string; readonly memberCount: number },
|
|
94
107
|
recipients: { readonly total: number; readonly active: number },
|
|
95
108
|
allowedModels?: readonly string[],
|
|
96
109
|
): Promise<ShareFamilyStats>;
|
|
@@ -244,19 +257,21 @@ const HOURS_WINDOW = 24;
|
|
|
244
257
|
export const SHARE_STATS_CACHE_TTL_SECONDS = 10;
|
|
245
258
|
|
|
246
259
|
/**
|
|
247
|
-
* Cache key for one family's rollup.
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
260
|
+
* Cache key for one family's rollup. The family is named by its template key
|
|
261
|
+
* and the number of keys it covers — both change the payload — and
|
|
262
|
+
* `allowedModels` filters the model table, so all three are hashed in.
|
|
263
|
+
* (The key used to hash the sorted list of every family id; on a family with
|
|
264
|
+
* tens of thousands of minted children that built and sorted a multi-megabyte
|
|
265
|
+
* string on every page view, before the cache could even answer.)
|
|
251
266
|
*/
|
|
252
267
|
function shareFamilyStatsKey(
|
|
253
268
|
tenantId: string,
|
|
254
|
-
|
|
269
|
+
family: { readonly templateKeyId: string; readonly memberCount: number },
|
|
255
270
|
allowedModels: readonly string[] | undefined,
|
|
256
271
|
): string {
|
|
257
272
|
const models = allowedModels === undefined ? "*" : [...allowedModels].sort().join(",");
|
|
258
273
|
const hash = createHash("sha256")
|
|
259
|
-
.update(
|
|
274
|
+
.update(`${family.templateKeyId}#${family.memberCount}|${models}`)
|
|
260
275
|
.digest("hex");
|
|
261
276
|
return `share:v1:stats:${tenantId}:${hash}`;
|
|
262
277
|
}
|
|
@@ -271,12 +286,12 @@ interface ShareStatsPortDeps {
|
|
|
271
286
|
|
|
272
287
|
export function createShareStatsPort(deps: ShareStatsPortDeps): ShareStatsPort {
|
|
273
288
|
return {
|
|
274
|
-
async getFamilyStats(tenantId,
|
|
275
|
-
if (
|
|
289
|
+
async getFamilyStats(tenantId, family, recipients, allowedModels) {
|
|
290
|
+
if (family.templateKeyId.length === 0) return emptyStats(recipients);
|
|
276
291
|
const cached = await cachedRead<Omit<ShareFamilyStats, "recipients">>(
|
|
277
292
|
deps.redis,
|
|
278
|
-
shareFamilyStatsKey(tenantId,
|
|
279
|
-
() => computeFamilyStatsBody(deps.db, tenantId,
|
|
293
|
+
shareFamilyStatsKey(tenantId, family, allowedModels),
|
|
294
|
+
() => computeFamilyStatsBody(deps.db, tenantId, family.templateKeyId, allowedModels),
|
|
280
295
|
SHARE_STATS_CACHE_TTL_SECONDS,
|
|
281
296
|
);
|
|
282
297
|
return { ...cached, recipients };
|
|
@@ -288,7 +303,7 @@ export function createShareStatsPort(deps: ShareStatsPortDeps): ShareStatsPort {
|
|
|
288
303
|
async function computeFamilyStatsBody(
|
|
289
304
|
db: CartethyiaDatabase,
|
|
290
305
|
tenantId: string,
|
|
291
|
-
|
|
306
|
+
templateKeyId: string,
|
|
292
307
|
allowedModels: readonly string[] | undefined,
|
|
293
308
|
): Promise<Omit<ShareFamilyStats, "recipients">> {
|
|
294
309
|
// A share that grants a fixed set ranks only that set. Telemetry keeps the
|
|
@@ -304,20 +319,35 @@ async function computeFamilyStatsBody(
|
|
|
304
319
|
const lastHourStart = new Date(now.getTime() - HOUR_MS);
|
|
305
320
|
const windowStart = new Date(hourStart.getTime() - (HOURS_WINDOW - 1) * HOUR_MS);
|
|
306
321
|
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
322
|
+
// All-time and today's/month's spend come from SQL aggregates, not from the
|
|
323
|
+
// bounded page below. The page is capped so a very large family cannot pull
|
|
324
|
+
// the whole table, which means it cannot carry the all-time total. Lifetime,
|
|
325
|
+
// daily, and monthly are therefore separate bounded sums over the retained
|
|
326
|
+
// SQL window (each is a single `SUM` aggregate, not a row materialization).
|
|
327
|
+
// The page's events feed only the hourly chart and the two ranked tables.
|
|
328
|
+
const [totalTokens, todayTokens, monthTokens, template] = await Promise.all([
|
|
329
|
+
sumTelemetryTokens(db, tenantId, { familyKeyId: templateKeyId, since: new Date(0) }),
|
|
330
|
+
sumTelemetryTokens(db, tenantId, { familyKeyId: templateKeyId, since: dayStart }),
|
|
331
|
+
sumTelemetryTokens(db, tenantId, { familyKeyId: templateKeyId, since: monthStart }),
|
|
332
|
+
db
|
|
333
|
+
.select({ lifetime: apiKeys.lifetimeTokensConsumed })
|
|
334
|
+
.from(apiKeys)
|
|
335
|
+
.where(eq(apiKeys.id, templateKeyId))
|
|
336
|
+
.limit(1),
|
|
337
|
+
]);
|
|
338
|
+
// Cumulative family spend: the template's own counter plus the telemetry
|
|
339
|
+
// total. The counter is a manually-configured seed (an operator limit), not
|
|
340
|
+
// traffic, and it is never reset; azmetry rows for revoked/deleted children
|
|
341
|
+
// likewise stay counted, so the running total is monotonic and consistent
|
|
342
|
+
// across the overview, the share dialog, and this page.
|
|
343
|
+
const templateLifetime = Number(template[0]?.lifetime ?? 0);
|
|
344
|
+
|
|
345
|
+
// The hourly chart covers the last 24 hours, so the page only needs events
|
|
346
|
+
// in that window — not the whole retained history. Bounded and small.
|
|
318
347
|
const page = await querySqlEvents(db, {
|
|
319
348
|
tenantId,
|
|
320
|
-
|
|
349
|
+
familyApiKeyId: templateKeyId,
|
|
350
|
+
since: windowStart,
|
|
321
351
|
limit: MAX_FAMILY_EVENTS,
|
|
322
352
|
});
|
|
323
353
|
const events = page.events;
|
|
@@ -328,6 +358,15 @@ async function computeFamilyStatsBody(
|
|
|
328
358
|
windowStart: windowStart.getTime(),
|
|
329
359
|
allowed: allowedSet,
|
|
330
360
|
});
|
|
361
|
+
rollup.totals = {
|
|
362
|
+
...rollup.totals,
|
|
363
|
+
// The template counter is the seed on the running total, not a distinct
|
|
364
|
+
// bucket: daily/monthly windows are telemetry-only (the counter has no
|
|
365
|
+
// time axis), so those keep their own sums.
|
|
366
|
+
totalTokens: templateLifetime + totalTokens,
|
|
367
|
+
todayTokens,
|
|
368
|
+
monthTokens,
|
|
369
|
+
};
|
|
331
370
|
|
|
332
371
|
// Fill the 24 buckets in JS: only hours that saw traffic are present, and
|
|
333
372
|
// a chart drawn from sparse rows would silently compress the axis.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { and, eq,
|
|
1
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
2
2
|
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
3
3
|
import type { ApiKeyRecord } from "../../persistence/stores/api-key-store";
|
|
4
4
|
import { CachedPreferencesReader, DrizzlePreferencesReader } from "../../persistence/tenant-preferences";
|
|
@@ -9,7 +9,7 @@ import { isGatewayError } from "../../observability/telemetry-status";
|
|
|
9
9
|
import type { RedisClient } from "../../persistence/redis";
|
|
10
10
|
import { cachedRead } from "../shared/read-through-cache";
|
|
11
11
|
import { SHARE_STATS_CACHE_TTL_SECONDS } from "./share-stats";
|
|
12
|
-
import { telemetryUsageTotals } from "../../persistence/schema";
|
|
12
|
+
import { apiKeys, telemetryUsageTotals } from "../../persistence/schema";
|
|
13
13
|
import { shouldMaskClientIp } from "../shared/ip-privacy";
|
|
14
14
|
import { utcDayStart } from "../shared/day";
|
|
15
15
|
|
|
@@ -66,9 +66,16 @@ export interface SharedKeyActivityDetail {
|
|
|
66
66
|
|
|
67
67
|
/** Read-only share-recipient telemetry; payloads and credential material never cross this boundary. */
|
|
68
68
|
export interface ShareActivityPort {
|
|
69
|
+
/**
|
|
70
|
+
* `familyTemplateKeyId` is the share template the children were issued from:
|
|
71
|
+
* the telemetry reads scope to the whole family through it (an `EXISTS`
|
|
72
|
+
* against `api_keys`, constant-wire) rather than through an id list, so the
|
|
73
|
+
* read is the same no matter how many keys the template has minted.
|
|
74
|
+
*/
|
|
69
75
|
getSharedKeySummaries(
|
|
70
76
|
tenantId: string,
|
|
71
77
|
children: readonly ApiKeyRecord[],
|
|
78
|
+
familyTemplateKeyId: string,
|
|
72
79
|
): Promise<readonly SharedKeySummary[]>;
|
|
73
80
|
getSharedKeyDetail(tenantId: string, childKeyId: string): Promise<SharedKeyActivityDetail>;
|
|
74
81
|
}
|
|
@@ -133,15 +140,25 @@ export function createShareUsagePort(deps: ShareUsagePortDeps): ShareActivityPor
|
|
|
133
140
|
const { db, redis } = deps;
|
|
134
141
|
const preferences = new CachedPreferencesReader(new DrizzlePreferencesReader(db));
|
|
135
142
|
return {
|
|
136
|
-
async getSharedKeySummaries(tenantId, children) {
|
|
143
|
+
async getSharedKeySummaries(tenantId, children, familyTemplateKeyId) {
|
|
137
144
|
if (children.length === 0) return [];
|
|
138
|
-
const childIds = children.map((child) => child.id);
|
|
139
145
|
const todayStart = utcDayStart(new Date());
|
|
146
|
+
/**
|
|
147
|
+
* Family membership of the entity the totals/events row is attributed to.
|
|
148
|
+
* Primary predicate is the denormalized template column (so history
|
|
149
|
+
* survives key deletion); the `EXISTS` fallback covers rows written
|
|
150
|
+
* before the column existed.
|
|
151
|
+
*/
|
|
152
|
+
const familyMembership = (entityId: typeof apiKeys.id | typeof telemetryUsageTotals.entityId) =>
|
|
153
|
+
sql`(${telemetryUsageTotals.familyTemplateKeyId} = ${familyTemplateKeyId} or (${telemetryUsageTotals.familyTemplateKeyId} is null and exists (select 1 from ${apiKeys} where ${apiKeys.id} = ${entityId} and (${apiKeys.id} = ${familyTemplateKeyId} or ${apiKeys.parentKeyId} = ${familyTemplateKeyId}))))`;
|
|
140
154
|
const [todayEvents, lifetimeRows, hideClientIp] = await Promise.all([
|
|
141
155
|
// Today's per-key totals as one GROUP BY instead of reading every
|
|
142
156
|
// event of the family. `addEventUsage` needs per-event fields, so the
|
|
143
|
-
// groups are projected into the same shape the loop consumes.
|
|
144
|
-
|
|
157
|
+
// groups are projected into the same shape the loop consumes. The
|
|
158
|
+
// family filter is the template key, not an id list: a family with
|
|
159
|
+
// tens of thousands of minted children must not expand them into bind
|
|
160
|
+
// parameters on every dashboard poll.
|
|
161
|
+
querySqlEvents(db, { tenantId, familyApiKeyId: familyTemplateKeyId, since: todayStart, limit: MAX_SUMMARY_EVENTS }).then(
|
|
145
162
|
(page) => page.events,
|
|
146
163
|
),
|
|
147
164
|
db
|
|
@@ -158,7 +175,7 @@ export function createShareUsagePort(deps: ShareUsagePortDeps): ShareActivityPor
|
|
|
158
175
|
and(
|
|
159
176
|
eq(telemetryUsageTotals.tenantId, tenantId),
|
|
160
177
|
eq(telemetryUsageTotals.identityType, "api_key"),
|
|
161
|
-
|
|
178
|
+
familyMembership(telemetryUsageTotals.entityId),
|
|
162
179
|
),
|
|
163
180
|
),
|
|
164
181
|
shouldMaskClientIp(preferences, tenantId),
|