cartethyia 2.5.2 → 2.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dashboard/assets/{BannedPage-MsQaeQTs.js → BannedPage-C2orBsI2.js} +1 -1
- package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +1 -0
- package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DfWHTJ7v.js → CliToolsPage-BV6pxVQo.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-BrCBQDAc.js → CombosPage-Bsan4679.js} +1 -1
- package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-C6Uh33er.js → ConsoleLogPage-CW-wPmJ6.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-8aS9hyJu.js → CustomizationPage-0p8uX7vB.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-C8hKyXBe.js → LoginPage-BvzCx8Kr.js} +1 -1
- package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-CWkTUtpX.js → ModelPicker-BY1aKEkP.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +1 -0
- package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +7 -0
- package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +1 -0
- package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +1 -0
- package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-CXjUlfqr.js → SetupPage-DDbMQsoo.js} +1 -1
- package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-C-_oD3F2.js → SortableList-CB3Z7VTf.js} +1 -1
- package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-u9IxmYOI.js → StudioPage-D95UCPHI.js} +3 -3
- package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
- package/dist/dashboard/assets/{UsagePage-BEVLVrwP.js → UsagePage-Cnae9SWA.js} +13 -13
- package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-uNywYlfv.js → api-keys-VY68Mhd1.js} +1 -1
- package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
- package/dist/dashboard/assets/app-COlK95oi.css +2 -0
- package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
- package/dist/dashboard/assets/{assert-NHwYq95t.js → assert-Chi7pCnt.js} +1 -1
- package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
- package/dist/dashboard/assets/{badge-6uzDDvqs.js → badge-D4pQapKj.js} +1 -1
- package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
- package/dist/dashboard/assets/{banner-4_WNg2WZ.js → banner-DH6cg-_B.js} +1 -1
- package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
- package/dist/dashboard/assets/{card--AtFedAq.js → card-BKqlUOe4.js} +1 -1
- package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-B3e7dx3N.js → cli-tools-fTgljTGA.js} +1 -1
- package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-NreUx6c8.js → clipboard-button-D5Ys_sM0.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
- package/dist/dashboard/assets/database-Dz9N3zGd.js +1 -0
- package/dist/dashboard/assets/database-Dz9N3zGd.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-BtpsJU98.js → dialog-BYj-8WuK.js} +1 -1
- package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-WwwtzihF.js → drawer-CmXIZ6H1.js} +1 -1
- package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
- package/dist/dashboard/assets/entry-CBg5zeu8.js +1 -0
- package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
- package/dist/dashboard/assets/entry-CQBr6U9c.js +1 -0
- package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
- package/dist/dashboard/assets/entry-CgdSAzEV.js +4 -0
- package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-D_eM3IgG.js → github-badge-BlptL8Uf.js} +1 -1
- package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
- package/dist/dashboard/assets/{index-DCBtROoK.js → index-js6TJvdr.js} +2 -2
- package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
- package/dist/dashboard/assets/{inline-BcNGF8Gr.js → inline-DUWDSmDV.js} +1 -1
- package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
- package/dist/dashboard/assets/{input-BG41et9G.js → input-LVjgZq4f.js} +1 -1
- package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
- package/dist/dashboard/assets/{layout-C8mer_Ak.js → layout-CPfxpsfy.js} +1 -1
- package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-Gh_W4OR_.js → list-row-DupvLfDx.js} +1 -1
- package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
- package/dist/dashboard/assets/{live-BJGYRBnR.js → live-C4pEJUqI.js} +1 -1
- package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-BRiuufkB.js +1 -0
- package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-BkoLM5Ba.js → mutation-B4ROwk8L.js} +1 -1
- package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
- package/dist/dashboard/assets/numbers-YNcHvRY7.js +2 -0
- package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-D7wX2vM5.js → query-keys-gmjzC3RS.js} +1 -1
- package/dist/dashboard/assets/{query-keys-D7wX2vM5.js.gz → query-keys-gmjzC3RS.js.gz} +0 -0
- package/dist/dashboard/assets/{routing-CcusqXA7.js → routing-C7tBT5o-.js} +1 -1
- package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
- package/dist/dashboard/assets/{select-DsX5QT8K.js → select-WUCXorXw.js} +2 -2
- package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
- package/dist/dashboard/assets/{settings-mpmXTgUa.js → settings-C_zKl1GS.js} +1 -1
- package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
- package/dist/dashboard/assets/{stack-DwLmjUFK.js → stack-_3O0F8Xn.js} +1 -1
- package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
- package/dist/dashboard/assets/{state-DYdQmdAl.js → state-BgTxHcJS.js} +1 -1
- package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
- package/dist/dashboard/assets/switch-DEjTtOWf.js +1 -0
- package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
- package/dist/dashboard/assets/terminal-0_IjPWTk.js +1 -0
- package/dist/dashboard/assets/terminal-0_IjPWTk.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-w4u4CVue.js → useMutation-BWSC0UAS.js} +1 -1
- package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
- package/dist/dashboard/assets/zap-C4EeVW4q.js +1 -0
- package/dist/dashboard/assets/zap-C4EeVW4q.js.gz +0 -0
- package/dist/dashboard/donation/qris-risun-marke.png +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +54 -54
- package/package.json +1 -1
- package/scripts/cli/autostart.ts +237 -0
- package/scripts/cli/command/console-menu.ts +244 -246
- package/scripts/cli/command/tray.ts +208 -204
- package/scripts/cli/command/update.ts +27 -10
- package/scripts/cli/main.ts +28 -1
- package/src/boot/app.ts +693 -693
- package/src/boot/dependencies.ts +646 -616
- package/src/config/spec.ts +81 -2
- package/src/config/telemetry.ts +46 -0
- package/src/console/api-keys/bucket-spend.ts +15 -4
- package/src/console/backup/contracts.ts +15 -2
- package/src/console/backup/routes.ts +52 -1
- package/src/console/backup/service.ts +60 -4
- package/src/console/console-router.ts +8 -0
- package/src/console/data-management/contracts.ts +93 -0
- package/src/console/data-management/operations.ts +229 -0
- package/src/console/data-management/routes.ts +104 -0
- package/src/console/domain-registration.ts +39 -0
- package/src/console/observability/contracts.ts +524 -485
- package/src/console/observability/store.ts +1115 -1018
- package/src/console/providers/catalog/contracts.ts +6 -0
- package/src/console/providers/catalog/provider-operations.ts +20 -0
- package/src/console/providers/catalog/routes.ts +7 -0
- package/src/console/providers/catalog/store.ts +22 -1
- package/src/console/quota/account-quota-shared.ts +52 -39
- package/src/console/quota/refresh.ts +441 -429
- package/src/console/routing/pools/import-parse.ts +206 -0
- package/src/console/routing/pools/operations.ts +543 -501
- package/src/console/routing/pools/routes.ts +181 -164
- package/src/console/settings/contracts.ts +37 -0
- package/src/console/settings/store.ts +51 -0
- package/src/console/share/share-router.ts +6 -9
- package/src/console/updates/routes.ts +16 -0
- package/src/main.ts +10 -0
- package/src/observability/event-compaction.ts +315 -0
- package/src/observability/event-day-index.ts +442 -0
- package/src/observability/event-log.ts +152 -3
- package/src/observability/payload-capture.ts +373 -344
- package/src/observability/payload-store.ts +440 -366
- package/src/observability/telemetry-status.ts +28 -16
- package/src/persistence/health-event-flush.ts +36 -0
- package/src/persistence/schema.ts +1402 -1360
- package/src/persistence/stores/api-key-store.ts +6 -0
- package/src/persistence/stores/share-store.ts +583 -556
- package/src/persistence/stores/telemetry-store.ts +237 -214
- package/src/protocol/codecs.ts +1 -2
- package/src/protocol/image-clamp.ts +19 -1
- package/src/protocol/registry.ts +8 -0
- package/src/protocol/request/gemini.ts +9 -2
- package/src/protocol/request/messages.ts +14 -2
- package/src/protocol/response/gemini.ts +18 -4
- package/src/providers/adapters/compatible-adapter.ts +3 -0
- package/src/providers/bundled-discovery-providers.ts +122 -119
- package/src/providers/configured-openai-providers.ts +136 -134
- package/src/providers/default-registry.ts +5 -4
- package/src/providers/discovery/openai-model-discovery.ts +5 -0
- package/src/providers/discovery/openrouter-free-roster.ts +15 -17
- package/src/providers/integrations/antigravity/antigravity-quota.ts +11 -3
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +4 -4
- package/src/providers/integrations/buddy/workbuddy/shared.ts +201 -200
- package/src/providers/integrations/cline/cline-quota.ts +132 -128
- package/src/providers/integrations/codex/codex-headers.ts +25 -39
- package/src/providers/integrations/codex/codex.ts +2 -29
- package/src/providers/integrations/cursor/cursor-quota.ts +310 -0
- package/src/providers/integrations/cursor/cursor-request.ts +616 -471
- package/src/providers/integrations/cursor/cursor-session-manager.ts +319 -281
- package/src/providers/integrations/cursor/cursor.ts +1485 -960
- package/src/providers/integrations/cursor/protocol/messages.ts +25 -0
- package/src/providers/integrations/factory/factory.ts +8 -1
- package/src/providers/integrations/gemini.ts +6 -1
- package/src/providers/integrations/grok/grok-quota.ts +279 -177
- package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -0
- package/src/providers/integrations/grok/grok-ratelimit.ts +218 -0
- package/src/providers/integrations/opencode/provider.ts +531 -532
- package/src/providers/integrations/qoder/qoder-quota.ts +192 -0
- package/src/providers/integrations/tokenharbor.ts +2 -2
- package/src/providers/integrations/trae/trae-catalog.ts +5 -3
- package/src/providers/integrations/trae/trae-quota.ts +384 -0
- package/src/providers/operations/account-health-service.ts +1523 -1457
- package/src/providers/operations/client-versions.ts +9 -2
- package/src/providers/provider-contracts.ts +7 -0
- package/src/providers/provider-metadata.ts +13 -1
- package/src/providers/quota/quota-support.ts +8 -0
- package/src/security/api-key-auth.ts +504 -494
- package/src/transport/dispatch/attempt-finalize.ts +46 -5
- package/src/transport/dispatch/generation.ts +6 -0
- package/src/transport/dispatch/proxy-request.ts +33 -1
- package/src/transport/dispatch/ratelimit-capture.ts +37 -0
- package/src/transport/dispatch/streaming/stream.ts +30 -2
- package/src/transport/dispatch/upstream.ts +9 -0
- package/src/transport/request/preparer.ts +7 -6
- package/src/transport/request/state.ts +9 -0
- package/src/transport/request/tenant-preferences.ts +60 -0
- package/src/transport/routing/route-catalog.ts +854 -799
- package/dist/dashboard/assets/BannedPage-MsQaeQTs.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-BO9nKg9w.js +0 -1
- package/dist/dashboard/assets/CliToolDetailPage-BO9nKg9w.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DfWHTJ7v.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-BrCBQDAc.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-C6Uh33er.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-8aS9hyJu.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-qomXThxX.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-qomXThxX.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-C8hKyXBe.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-C-ExCy1V.js +0 -1
- package/dist/dashboard/assets/OverviewPage-C-ExCy1V.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-BS9IEpvv.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-BS9IEpvv.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-Rn_qXScb.js +0 -7
- package/dist/dashboard/assets/ProxyPage-Rn_qXScb.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BihkgQSq.js +0 -1
- package/dist/dashboard/assets/QuotaPage-BihkgQSq.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js +0 -1
- package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-CXjUlfqr.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-C-_oD3F2.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-u9IxmYOI.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-BEVLVrwP.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-uNywYlfv.js.gz +0 -0
- package/dist/dashboard/assets/app-DxivpfYG.css +0 -2
- package/dist/dashboard/assets/app-DxivpfYG.css.gz +0 -0
- package/dist/dashboard/assets/assert-NHwYq95t.js.gz +0 -0
- package/dist/dashboard/assets/badge-6uzDDvqs.js.gz +0 -0
- package/dist/dashboard/assets/banner-4_WNg2WZ.js.gz +0 -0
- package/dist/dashboard/assets/card--AtFedAq.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-B3e7dx3N.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-NreUx6c8.js.gz +0 -0
- package/dist/dashboard/assets/dialog-BtpsJU98.js.gz +0 -0
- package/dist/dashboard/assets/download-A0SW9OCW.js +0 -1
- package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
- package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
- package/dist/dashboard/assets/entry-Dj9AR0gY.js +0 -4
- package/dist/dashboard/assets/entry-Dj9AR0gY.js.gz +0 -0
- package/dist/dashboard/assets/entry-TJIye_jT.js +0 -1
- package/dist/dashboard/assets/entry-TJIye_jT.js.gz +0 -0
- package/dist/dashboard/assets/entry-jy9_4-Ke.js +0 -1
- package/dist/dashboard/assets/entry-jy9_4-Ke.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
- package/dist/dashboard/assets/index-DCBtROoK.js.gz +0 -0
- package/dist/dashboard/assets/inline-BcNGF8Gr.js.gz +0 -0
- package/dist/dashboard/assets/input-BG41et9G.js.gz +0 -0
- package/dist/dashboard/assets/layout-C8mer_Ak.js.gz +0 -0
- package/dist/dashboard/assets/list-row-Gh_W4OR_.js.gz +0 -0
- package/dist/dashboard/assets/live-BJGYRBnR.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-DeVLDA7I.js +0 -1
- package/dist/dashboard/assets/model-bans-DeVLDA7I.js.gz +0 -0
- package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
- package/dist/dashboard/assets/numbers-Bs4LOOuf.js +0 -2
- package/dist/dashboard/assets/numbers-Bs4LOOuf.js.gz +0 -0
- package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
- package/dist/dashboard/assets/select-DsX5QT8K.js.gz +0 -0
- package/dist/dashboard/assets/settings-mpmXTgUa.js.gz +0 -0
- package/dist/dashboard/assets/sparkles-BRaRUxAU.js +0 -1
- package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
- package/dist/dashboard/assets/stack-DwLmjUFK.js.gz +0 -0
- package/dist/dashboard/assets/state-DYdQmdAl.js.gz +0 -0
- package/dist/dashboard/assets/switch-CidSfbG7.js +0 -1
- package/dist/dashboard/assets/switch-CidSfbG7.js.gz +0 -0
- package/dist/dashboard/assets/terminal-Bd4ee8TA.js +0 -1
- package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-w4u4CVue.js.gz +0 -0
- /package/dist/dashboard/assets/{app-CafWtVXA.js → app-DCQZRmHY.js} +0 -0
- /package/dist/dashboard/assets/{app-CafWtVXA.js.gz → app-DCQZRmHY.js.gz} +0 -0
|
@@ -1,494 +1,504 @@
|
|
|
1
|
-
// API-key authentication and authorization snapshots: token extraction, hashing, lookup, and admission identity.
|
|
2
|
-
|
|
3
|
-
import type { CartethyiaDatabase } from "../persistence/db-handle";
|
|
4
|
-
import { deleteShared, publishInvalidation, readShared, writeShared } from "../persistence/shared-cache";
|
|
5
|
-
import { DrizzleApiKeyStore } from "../persistence/stores/api-key-store";
|
|
6
|
-
import { GatewayError } from "../protocol/gateway-error";
|
|
7
|
-
import { hashSecret } from "./crypto";
|
|
8
|
-
import type { AccessScope } from "./access-control";
|
|
9
|
-
import type { ApiKeyModelAccessMode } from "../persistence/schema";
|
|
10
|
-
|
|
11
|
-
// ── Authorization cache ──────────────────────────────────────────────────────
|
|
12
|
-
/**
|
|
13
|
-
* TTL-based in-memory cache for `resolveApiKeyAuthorization`, keyed by the
|
|
14
|
-
* token hash. The Postgres round-trips this avoids (1–3 SELECTs per request)
|
|
15
|
-
* are the single largest latency contributor on the `/v1/*` hot path.
|
|
16
|
-
*
|
|
17
|
-
* Trade-off: `lifetime_tokens_consumed` in the cached snapshot may be up to
|
|
18
|
-
* `AUTH_CACHE_TTL_MS` stale. The admission service's real-time in-flight and
|
|
19
|
-
* estimated-token accounting is the actual enforcement mechanism; the
|
|
20
|
-
* snapshot's consumed value is a reference point, so a brief stale window
|
|
21
|
-
* cannot meaningfully over-consume a budget.
|
|
22
|
-
*
|
|
23
|
-
* Negative results (unknown/revoked tokens) are deliberately NOT cached, so
|
|
24
|
-
* a newly minted key is usable immediately.
|
|
25
|
-
*/
|
|
26
|
-
const AUTH_CACHE_TTL_MS = 3_000;
|
|
27
|
-
const AUTH_CACHE_MAX_ENTRIES = 10_000;
|
|
28
|
-
/** Negative-result TTL: unknown/revoked tokens miss the DB at most this often. */
|
|
29
|
-
const AUTH_NEGATIVE_CACHE_TTL_MS = 1_500;
|
|
30
|
-
|
|
31
|
-
interface AuthCacheEntry {
|
|
32
|
-
readonly at: number;
|
|
33
|
-
readonly value: ResolvedApiKey;
|
|
34
|
-
/** Owner of `value`; the reverse-index key this entry is registered under. */
|
|
35
|
-
readonly keyId: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const authCache = new Map<string, AuthCacheEntry>();
|
|
39
|
-
/** Reverse index: keyId → set of token hashes that resolved to it. */
|
|
40
|
-
const authCacheByKeyId = new Map<string, Set<string>>();
|
|
41
|
-
/** Unknown/revoked token HMACs with their miss time; cleared on any key mutation. */
|
|
42
|
-
const authNegativeCache = new Map<string, number>();
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Short circuit after a credential-store failure.
|
|
46
|
-
*
|
|
47
|
-
* An overloaded or down database fails every lookup, and each failure costs a
|
|
48
|
-
* connection attempt. Without this, a saturated backend received one query per
|
|
49
|
-
* inbound request — load the operator sees as traffic, on top of the load that
|
|
50
|
-
* caused the outage. For the window, lookups answer "unavailable" instead.
|
|
51
|
-
*
|
|
52
|
-
* Deliberately short: this is an auth path, and a brief outage must not lock
|
|
53
|
-
* out valid keys for long.
|
|
54
|
-
*/
|
|
55
|
-
const AUTH_FAILURE_COOLDOWN_MS = 1_000;
|
|
56
|
-
let authFailureUntil = 0;
|
|
57
|
-
|
|
58
|
-
/** Thrown when the credential store could not be reached at all. */
|
|
59
|
-
export class ApiKeyStoreUnavailableError extends Error {
|
|
60
|
-
constructor() {
|
|
61
|
-
super("The credential store is unavailable.");
|
|
62
|
-
this.name = "ApiKeyStoreUnavailableError";
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Source for lifetime-usage deltas recorded but not yet flushed.
|
|
68
|
-
*
|
|
69
|
-
* Injected rather than imported: this module is on the auth path and must not
|
|
70
|
-
* depend on the persistence/write layer it would otherwise pull in. Set once at
|
|
71
|
-
* boot (`boot/dependencies.ts`); until then it reads as zero, which is the
|
|
72
|
-
* value the code has always used.
|
|
73
|
-
*/
|
|
74
|
-
let pendingUsage: (apiKeyId: string) => number = () => 0;
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Supplies the in-flight usage source. See {@link pendingUsage}.
|
|
78
|
-
*
|
|
79
|
-
* The persisted `lifetime_tokens_consumed` is behind by up to one flush
|
|
80
|
-
* interval, and the flush coalesces by design — so it is behind by the *most*
|
|
81
|
-
* exactly when a key is busiest. Adding the pending delta costs nothing and
|
|
82
|
-
* removes the gap.
|
|
83
|
-
*/
|
|
84
|
-
export function setPendingUsageSource(source: (apiKeyId: string) => number): void {
|
|
85
|
-
pendingUsage = source;
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* Negative-result fast path. Unknown/revoked tokens previously hit the DB on
|
|
89
|
-
* every request; now a repeat miss within 1.5 s costs one HMAC + one map
|
|
90
|
-
* lookup and no query. Keyed by the full HMAC: a cheap pre-hash would let a
|
|
91
|
-
* crafted collision reject someone else's valid key, while the HMAC key can
|
|
92
|
-
* only match its own token. Bound below beside the positive cache.
|
|
93
|
-
*/
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Drops the oldest entries until `authCache` is within budget. Every removal
|
|
97
|
-
* must also unregister the hash from `authCacheByKeyId`, otherwise the reverse
|
|
98
|
-
* index keeps referencing hashes that no longer exist and grows without bound
|
|
99
|
-
* (one dead hash per eviction, forever).
|
|
100
|
-
*/
|
|
101
|
-
function evictAuthCacheOldest(): void {
|
|
102
|
-
while (authCache.size > AUTH_CACHE_MAX_ENTRIES) {
|
|
103
|
-
const oldest = authCache.keys().next().value;
|
|
104
|
-
if (oldest === undefined) return;
|
|
105
|
-
const entry = authCache.get(oldest);
|
|
106
|
-
authCache.delete(oldest);
|
|
107
|
-
if (entry === undefined) continue;
|
|
108
|
-
const hashes = authCacheByKeyId.get(entry.keyId);
|
|
109
|
-
if (hashes === undefined) continue;
|
|
110
|
-
hashes.delete(oldest);
|
|
111
|
-
if (hashes.size === 0) authCacheByKeyId.delete(entry.keyId);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Registers a resolved hash in both the cache and the keyId reverse index. */
|
|
116
|
-
function indexAuthCache(hash: string, entry: AuthCacheEntry): void {
|
|
117
|
-
authCache.set(hash, entry);
|
|
118
|
-
authNegativeCache.delete(hash);
|
|
119
|
-
const hashes = authCacheByKeyId.get(entry.keyId) ?? new Set<string>();
|
|
120
|
-
hashes.add(hash);
|
|
121
|
-
authCacheByKeyId.set(entry.keyId, hashes);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Bounds the negative cache; same oldest-first rule as the positive cache. */
|
|
125
|
-
function evictAuthNegativeOldest(): void {
|
|
126
|
-
while (authNegativeCache.size > AUTH_CACHE_MAX_ENTRIES) {
|
|
127
|
-
const oldest = authNegativeCache.keys().next().value;
|
|
128
|
-
if (oldest === undefined) return;
|
|
129
|
-
authNegativeCache.delete(oldest);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Invalidates every cached authorization entry for one API key (or the entire
|
|
135
|
-
* cache when `keyId` is omitted). Called by the API-key domain after any
|
|
136
|
-
* mutation that changes a key's scopes, limits, or revoked state.
|
|
137
|
-
*/
|
|
138
|
-
export function invalidateApiKeyCache(keyId?: string): void {
|
|
139
|
-
// A mutation may mint the previously-unknown token, so negative entries
|
|
140
|
-
// must go with the positive ones.
|
|
141
|
-
authNegativeCache.clear();
|
|
142
|
-
if (keyId === undefined) {
|
|
143
|
-
authCache.clear();
|
|
144
|
-
authCacheByKeyId.clear();
|
|
145
|
-
void deleteShared("auth");
|
|
146
|
-
publishInvalidation({ namespace: "auth" });
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
const hashes = authCacheByKeyId.get(keyId);
|
|
150
|
-
if (hashes) {
|
|
151
|
-
for (const hash of hashes) authCache.delete(hash);
|
|
152
|
-
authCacheByKeyId.delete(keyId);
|
|
153
|
-
}
|
|
154
|
-
// Peer processes hold their own copy, and `reusePort` means the console
|
|
155
|
-
// write may have landed on a different one entirely. Dropping only locally
|
|
156
|
-
// leaves a revoked key valid for up to a full TTL everywhere else.
|
|
157
|
-
//
|
|
158
|
-
// Delivered by broadcast rather than by deleting a shared entry: a mutation
|
|
159
|
-
// names a key id, and the shared layer is keyed by token hash, which the
|
|
160
|
-
// mutating request does not have. The notice is at-most-once, so the TTL
|
|
161
|
-
// stays the backstop for a peer that misses it.
|
|
162
|
-
publishInvalidation({ namespace: "auth", key: keyId });
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Resolves an inbound `/v1/*` bearer token to its persisted API key row and
|
|
167
|
-
* builds the immutable `ApiKeyAuthorizationSnapshot` consumed by
|
|
168
|
-
* `ApiKeyAdmissionService`. This is the only place a raw client bearer token
|
|
169
|
-
* is hashed and looked up against Postgres.
|
|
170
|
-
*/
|
|
171
|
-
/** Immutable tenant-scoped authorization snapshot. */
|
|
172
|
-
|
|
173
|
-
export interface ApiKeyAuthorizationSnapshot {
|
|
174
|
-
readonly api_key_id: string;
|
|
175
|
-
readonly tenant_id: string;
|
|
176
|
-
/** Admission identity used as key for rolling counters; defaults to api_key_id when absent. */
|
|
177
|
-
readonly admission_identity?: string;
|
|
178
|
-
/** Empty or absent means unrestricted; values are normalized to frozen arrays. */
|
|
179
|
-
readonly model_access_mode?: ApiKeyModelAccessMode | null | undefined;
|
|
180
|
-
/** The one list `model_access_mode` interprets; see the schema for semantics. */
|
|
181
|
-
readonly model_list?: readonly string[] | ReadonlySet<string> | null | undefined;
|
|
182
|
-
/** Client-router ids this key refuses; see `client-router-fingerprint.ts`. */
|
|
183
|
-
readonly client_router_denylist?: readonly unknown[] | ReadonlySet<unknown> | null | undefined;
|
|
184
|
-
readonly rpm?: number | null | undefined;
|
|
185
|
-
readonly rpm_limit?: number | null | undefined;
|
|
186
|
-
readonly daily_tokens?: number | null | undefined;
|
|
187
|
-
readonly monthly_tokens?: number | null | undefined;
|
|
188
|
-
readonly lifetime_token_budget?: number | null | undefined;
|
|
189
|
-
readonly lifetime_tokens_consumed?: number | null | undefined;
|
|
190
|
-
readonly max_concurrent?: number | null | undefined;
|
|
191
|
-
readonly scopes?: readonly string[] | undefined;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function freezeList(
|
|
195
|
-
list: readonly unknown[] | ReadonlySet<unknown> | null | undefined,
|
|
196
|
-
): readonly string[] | null | undefined {
|
|
197
|
-
if (list == null) return list as null | undefined;
|
|
198
|
-
const values = list instanceof Set ? [...list] : Array.isArray(list) ? list : [];
|
|
199
|
-
const arr = Object.freeze(values.filter((value): value is string => typeof value === "string"));
|
|
200
|
-
return arr;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
export function freezeSnapshot(s: ApiKeyAuthorizationSnapshot): ApiKeyAuthorizationSnapshot {
|
|
204
|
-
const model_list = freezeList(s.model_list);
|
|
205
|
-
const model_access_mode: ApiKeyModelAccessMode =
|
|
206
|
-
s.model_access_mode === "blacklist" ? "blacklist" : "whitelist";
|
|
207
|
-
const client_router_denylist = freezeList(s.client_router_denylist);
|
|
208
|
-
|
|
209
|
-
const api_key_id = s.api_key_id ?? "";
|
|
210
|
-
const tenant_id = s.tenant_id ?? "";
|
|
211
|
-
const admission_identity = s.admission_identity ?? api_key_id;
|
|
212
|
-
|
|
213
|
-
const frozen: ApiKeyAuthorizationSnapshot = {
|
|
214
|
-
api_key_id,
|
|
215
|
-
tenant_id,
|
|
216
|
-
admission_identity,
|
|
217
|
-
model_access_mode,
|
|
218
|
-
model_list,
|
|
219
|
-
client_router_denylist,
|
|
220
|
-
rpm: s.rpm ?? s.rpm_limit ?? null,
|
|
221
|
-
daily_tokens: s.daily_tokens ?? null,
|
|
222
|
-
monthly_tokens: s.monthly_tokens ?? null,
|
|
223
|
-
lifetime_token_budget: s.lifetime_token_budget ?? null,
|
|
224
|
-
lifetime_tokens_consumed: s.lifetime_tokens_consumed ?? null,
|
|
225
|
-
max_concurrent: s.max_concurrent ?? null,
|
|
226
|
-
scopes: s.scopes ? Object.freeze([...s.scopes]) : undefined,
|
|
227
|
-
};
|
|
228
|
-
return Object.freeze(frozen) as ApiKeyAuthorizationSnapshot;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export function createAuthorizationSnapshot(input: {
|
|
232
|
-
readonly api_key_id: string;
|
|
233
|
-
readonly tenant_id: string;
|
|
234
|
-
readonly admission_identity?: string;
|
|
235
|
-
readonly model_access_mode?: ApiKeyModelAccessMode | null;
|
|
236
|
-
readonly model_list?: readonly string[] | ReadonlySet<string> | null;
|
|
237
|
-
readonly client_router_denylist?: readonly unknown[] | ReadonlySet<unknown> | null;
|
|
238
|
-
readonly rpm?: number | null;
|
|
239
|
-
readonly daily_tokens?: number | null;
|
|
240
|
-
readonly monthly_tokens?: number | null;
|
|
241
|
-
readonly lifetime_token_budget?: number | null;
|
|
242
|
-
readonly lifetime_tokens_consumed?: number | null;
|
|
243
|
-
readonly max_concurrent?: number | null;
|
|
244
|
-
readonly rpm_limit?: number | null;
|
|
245
|
-
readonly scopes?: readonly string[];
|
|
246
|
-
}): ApiKeyAuthorizationSnapshot {
|
|
247
|
-
return freezeSnapshot({
|
|
248
|
-
api_key_id: input.api_key_id,
|
|
249
|
-
tenant_id: input.tenant_id,
|
|
250
|
-
...(input.admission_identity !== undefined
|
|
251
|
-
? { admission_identity: input.admission_identity }
|
|
252
|
-
: {}),
|
|
253
|
-
...(input.model_access_mode !== undefined && input.model_access_mode !== null
|
|
254
|
-
? { model_access_mode: input.model_access_mode }
|
|
255
|
-
: {}),
|
|
256
|
-
...(input.model_list !== undefined && input.model_list !== null
|
|
257
|
-
? { model_list: input.model_list as readonly string[] }
|
|
258
|
-
: {}),
|
|
259
|
-
...(input.client_router_denylist !== undefined && input.client_router_denylist !== null
|
|
260
|
-
? { client_router_denylist: input.client_router_denylist }
|
|
261
|
-
: {}),
|
|
262
|
-
...(input.rpm !== undefined && input.rpm !== null ? { rpm: input.rpm } : {}),
|
|
263
|
-
...(input.daily_tokens !== undefined && input.daily_tokens !== null
|
|
264
|
-
? { daily_tokens: input.daily_tokens }
|
|
265
|
-
: {}),
|
|
266
|
-
...(input.monthly_tokens !== undefined && input.monthly_tokens !== null
|
|
267
|
-
? { monthly_tokens: input.monthly_tokens }
|
|
268
|
-
: {}),
|
|
269
|
-
...(input.lifetime_token_budget !== undefined && input.lifetime_token_budget !== null
|
|
270
|
-
? { lifetime_token_budget: input.lifetime_token_budget }
|
|
271
|
-
: {}),
|
|
272
|
-
...(input.lifetime_tokens_consumed !== undefined && input.lifetime_tokens_consumed !== null
|
|
273
|
-
? { lifetime_tokens_consumed: input.lifetime_tokens_consumed }
|
|
274
|
-
: {}),
|
|
275
|
-
...(input.max_concurrent !== undefined && input.max_concurrent !== null
|
|
276
|
-
? { max_concurrent: input.max_concurrent }
|
|
277
|
-
: {}),
|
|
278
|
-
...(input.scopes !== undefined ? { scopes: input.scopes } : {}),
|
|
279
|
-
});
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* The model-access rule lives in `model-access-rule.ts`, a pure module the
|
|
284
|
-
* dashboard can import too (this file pulls in Drizzle/Node). Re-exported here
|
|
285
|
-
* so existing callers keep importing from `api-key-auth`.
|
|
286
|
-
*/
|
|
287
|
-
export {
|
|
288
|
-
isModelAllowed,
|
|
289
|
-
listIncludes,
|
|
290
|
-
modelRejectionReason,
|
|
291
|
-
type ModelRejectionReason,
|
|
292
|
-
} from "./model-access-rule";
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
export function getAdmissionIdentity(snapshot: ApiKeyAuthorizationSnapshot): string {
|
|
296
|
-
return snapshot.admission_identity ?? snapshot.api_key_id;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
export interface ResolvedApiKey {
|
|
300
|
-
readonly id: string;
|
|
301
|
-
readonly tenantId: string;
|
|
302
|
-
readonly scopes: readonly AccessScope[];
|
|
303
|
-
readonly snapshot: ApiKeyAuthorizationSnapshot;
|
|
304
|
-
readonly modelPrefix?: string;
|
|
305
|
-
/** Share-template id whose remote CLI mappings are inherited by this child. */
|
|
306
|
-
readonly cliMappingOwnerId?: string;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* The 401 a caller gets when a key resolves to nothing.
|
|
311
|
-
*
|
|
312
|
-
* One function, not a message repeated at each boundary: three surfaces refuse
|
|
313
|
-
* an unknown key, and any wording drift between them is a caller guessing which
|
|
314
|
-
* surface it hit. Unknown, revoked and expired stay deliberately
|
|
315
|
-
* indistinguishable — distinguishing them would let a caller confirm that a
|
|
316
|
-
* guessed token once existed — so there is no variant carrying a reason.
|
|
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
|
-
if (hasApiKey)
|
|
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
|
-
// the
|
|
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
|
-
...(policy.
|
|
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
|
-
|
|
1
|
+
// API-key authentication and authorization snapshots: token extraction, hashing, lookup, and admission identity.
|
|
2
|
+
|
|
3
|
+
import type { CartethyiaDatabase } from "../persistence/db-handle";
|
|
4
|
+
import { deleteShared, publishInvalidation, readShared, writeShared } from "../persistence/shared-cache";
|
|
5
|
+
import { DrizzleApiKeyStore } from "../persistence/stores/api-key-store";
|
|
6
|
+
import { GatewayError } from "../protocol/gateway-error";
|
|
7
|
+
import { hashSecret } from "./crypto";
|
|
8
|
+
import type { AccessScope } from "./access-control";
|
|
9
|
+
import type { ApiKeyModelAccessMode } from "../persistence/schema";
|
|
10
|
+
|
|
11
|
+
// ── Authorization cache ──────────────────────────────────────────────────────
|
|
12
|
+
/**
|
|
13
|
+
* TTL-based in-memory cache for `resolveApiKeyAuthorization`, keyed by the
|
|
14
|
+
* token hash. The Postgres round-trips this avoids (1–3 SELECTs per request)
|
|
15
|
+
* are the single largest latency contributor on the `/v1/*` hot path.
|
|
16
|
+
*
|
|
17
|
+
* Trade-off: `lifetime_tokens_consumed` in the cached snapshot may be up to
|
|
18
|
+
* `AUTH_CACHE_TTL_MS` stale. The admission service's real-time in-flight and
|
|
19
|
+
* estimated-token accounting is the actual enforcement mechanism; the
|
|
20
|
+
* snapshot's consumed value is a reference point, so a brief stale window
|
|
21
|
+
* cannot meaningfully over-consume a budget.
|
|
22
|
+
*
|
|
23
|
+
* Negative results (unknown/revoked tokens) are deliberately NOT cached, so
|
|
24
|
+
* a newly minted key is usable immediately.
|
|
25
|
+
*/
|
|
26
|
+
const AUTH_CACHE_TTL_MS = 3_000;
|
|
27
|
+
const AUTH_CACHE_MAX_ENTRIES = 10_000;
|
|
28
|
+
/** Negative-result TTL: unknown/revoked tokens miss the DB at most this often. */
|
|
29
|
+
const AUTH_NEGATIVE_CACHE_TTL_MS = 1_500;
|
|
30
|
+
|
|
31
|
+
interface AuthCacheEntry {
|
|
32
|
+
readonly at: number;
|
|
33
|
+
readonly value: ResolvedApiKey;
|
|
34
|
+
/** Owner of `value`; the reverse-index key this entry is registered under. */
|
|
35
|
+
readonly keyId: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const authCache = new Map<string, AuthCacheEntry>();
|
|
39
|
+
/** Reverse index: keyId → set of token hashes that resolved to it. */
|
|
40
|
+
const authCacheByKeyId = new Map<string, Set<string>>();
|
|
41
|
+
/** Unknown/revoked token HMACs with their miss time; cleared on any key mutation. */
|
|
42
|
+
const authNegativeCache = new Map<string, number>();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Short circuit after a credential-store failure.
|
|
46
|
+
*
|
|
47
|
+
* An overloaded or down database fails every lookup, and each failure costs a
|
|
48
|
+
* connection attempt. Without this, a saturated backend received one query per
|
|
49
|
+
* inbound request — load the operator sees as traffic, on top of the load that
|
|
50
|
+
* caused the outage. For the window, lookups answer "unavailable" instead.
|
|
51
|
+
*
|
|
52
|
+
* Deliberately short: this is an auth path, and a brief outage must not lock
|
|
53
|
+
* out valid keys for long.
|
|
54
|
+
*/
|
|
55
|
+
const AUTH_FAILURE_COOLDOWN_MS = 1_000;
|
|
56
|
+
let authFailureUntil = 0;
|
|
57
|
+
|
|
58
|
+
/** Thrown when the credential store could not be reached at all. */
|
|
59
|
+
export class ApiKeyStoreUnavailableError extends Error {
|
|
60
|
+
constructor() {
|
|
61
|
+
super("The credential store is unavailable.");
|
|
62
|
+
this.name = "ApiKeyStoreUnavailableError";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Source for lifetime-usage deltas recorded but not yet flushed.
|
|
68
|
+
*
|
|
69
|
+
* Injected rather than imported: this module is on the auth path and must not
|
|
70
|
+
* depend on the persistence/write layer it would otherwise pull in. Set once at
|
|
71
|
+
* boot (`boot/dependencies.ts`); until then it reads as zero, which is the
|
|
72
|
+
* value the code has always used.
|
|
73
|
+
*/
|
|
74
|
+
let pendingUsage: (apiKeyId: string) => number = () => 0;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Supplies the in-flight usage source. See {@link pendingUsage}.
|
|
78
|
+
*
|
|
79
|
+
* The persisted `lifetime_tokens_consumed` is behind by up to one flush
|
|
80
|
+
* interval, and the flush coalesces by design — so it is behind by the *most*
|
|
81
|
+
* exactly when a key is busiest. Adding the pending delta costs nothing and
|
|
82
|
+
* removes the gap.
|
|
83
|
+
*/
|
|
84
|
+
export function setPendingUsageSource(source: (apiKeyId: string) => number): void {
|
|
85
|
+
pendingUsage = source;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Negative-result fast path. Unknown/revoked tokens previously hit the DB on
|
|
89
|
+
* every request; now a repeat miss within 1.5 s costs one HMAC + one map
|
|
90
|
+
* lookup and no query. Keyed by the full HMAC: a cheap pre-hash would let a
|
|
91
|
+
* crafted collision reject someone else's valid key, while the HMAC key can
|
|
92
|
+
* only match its own token. Bound below beside the positive cache.
|
|
93
|
+
*/
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Drops the oldest entries until `authCache` is within budget. Every removal
|
|
97
|
+
* must also unregister the hash from `authCacheByKeyId`, otherwise the reverse
|
|
98
|
+
* index keeps referencing hashes that no longer exist and grows without bound
|
|
99
|
+
* (one dead hash per eviction, forever).
|
|
100
|
+
*/
|
|
101
|
+
function evictAuthCacheOldest(): void {
|
|
102
|
+
while (authCache.size > AUTH_CACHE_MAX_ENTRIES) {
|
|
103
|
+
const oldest = authCache.keys().next().value;
|
|
104
|
+
if (oldest === undefined) return;
|
|
105
|
+
const entry = authCache.get(oldest);
|
|
106
|
+
authCache.delete(oldest);
|
|
107
|
+
if (entry === undefined) continue;
|
|
108
|
+
const hashes = authCacheByKeyId.get(entry.keyId);
|
|
109
|
+
if (hashes === undefined) continue;
|
|
110
|
+
hashes.delete(oldest);
|
|
111
|
+
if (hashes.size === 0) authCacheByKeyId.delete(entry.keyId);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Registers a resolved hash in both the cache and the keyId reverse index. */
|
|
116
|
+
function indexAuthCache(hash: string, entry: AuthCacheEntry): void {
|
|
117
|
+
authCache.set(hash, entry);
|
|
118
|
+
authNegativeCache.delete(hash);
|
|
119
|
+
const hashes = authCacheByKeyId.get(entry.keyId) ?? new Set<string>();
|
|
120
|
+
hashes.add(hash);
|
|
121
|
+
authCacheByKeyId.set(entry.keyId, hashes);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Bounds the negative cache; same oldest-first rule as the positive cache. */
|
|
125
|
+
function evictAuthNegativeOldest(): void {
|
|
126
|
+
while (authNegativeCache.size > AUTH_CACHE_MAX_ENTRIES) {
|
|
127
|
+
const oldest = authNegativeCache.keys().next().value;
|
|
128
|
+
if (oldest === undefined) return;
|
|
129
|
+
authNegativeCache.delete(oldest);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Invalidates every cached authorization entry for one API key (or the entire
|
|
135
|
+
* cache when `keyId` is omitted). Called by the API-key domain after any
|
|
136
|
+
* mutation that changes a key's scopes, limits, or revoked state.
|
|
137
|
+
*/
|
|
138
|
+
export function invalidateApiKeyCache(keyId?: string): void {
|
|
139
|
+
// A mutation may mint the previously-unknown token, so negative entries
|
|
140
|
+
// must go with the positive ones.
|
|
141
|
+
authNegativeCache.clear();
|
|
142
|
+
if (keyId === undefined) {
|
|
143
|
+
authCache.clear();
|
|
144
|
+
authCacheByKeyId.clear();
|
|
145
|
+
void deleteShared("auth");
|
|
146
|
+
publishInvalidation({ namespace: "auth" });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const hashes = authCacheByKeyId.get(keyId);
|
|
150
|
+
if (hashes) {
|
|
151
|
+
for (const hash of hashes) authCache.delete(hash);
|
|
152
|
+
authCacheByKeyId.delete(keyId);
|
|
153
|
+
}
|
|
154
|
+
// Peer processes hold their own copy, and `reusePort` means the console
|
|
155
|
+
// write may have landed on a different one entirely. Dropping only locally
|
|
156
|
+
// leaves a revoked key valid for up to a full TTL everywhere else.
|
|
157
|
+
//
|
|
158
|
+
// Delivered by broadcast rather than by deleting a shared entry: a mutation
|
|
159
|
+
// names a key id, and the shared layer is keyed by token hash, which the
|
|
160
|
+
// mutating request does not have. The notice is at-most-once, so the TTL
|
|
161
|
+
// stays the backstop for a peer that misses it.
|
|
162
|
+
publishInvalidation({ namespace: "auth", key: keyId });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolves an inbound `/v1/*` bearer token to its persisted API key row and
|
|
167
|
+
* builds the immutable `ApiKeyAuthorizationSnapshot` consumed by
|
|
168
|
+
* `ApiKeyAdmissionService`. This is the only place a raw client bearer token
|
|
169
|
+
* is hashed and looked up against Postgres.
|
|
170
|
+
*/
|
|
171
|
+
/** Immutable tenant-scoped authorization snapshot. */
|
|
172
|
+
|
|
173
|
+
export interface ApiKeyAuthorizationSnapshot {
|
|
174
|
+
readonly api_key_id: string;
|
|
175
|
+
readonly tenant_id: string;
|
|
176
|
+
/** Admission identity used as key for rolling counters; defaults to api_key_id when absent. */
|
|
177
|
+
readonly admission_identity?: string;
|
|
178
|
+
/** Empty or absent means unrestricted; values are normalized to frozen arrays. */
|
|
179
|
+
readonly model_access_mode?: ApiKeyModelAccessMode | null | undefined;
|
|
180
|
+
/** The one list `model_access_mode` interprets; see the schema for semantics. */
|
|
181
|
+
readonly model_list?: readonly string[] | ReadonlySet<string> | null | undefined;
|
|
182
|
+
/** Client-router ids this key refuses; see `client-router-fingerprint.ts`. */
|
|
183
|
+
readonly client_router_denylist?: readonly unknown[] | ReadonlySet<unknown> | null | undefined;
|
|
184
|
+
readonly rpm?: number | null | undefined;
|
|
185
|
+
readonly rpm_limit?: number | null | undefined;
|
|
186
|
+
readonly daily_tokens?: number | null | undefined;
|
|
187
|
+
readonly monthly_tokens?: number | null | undefined;
|
|
188
|
+
readonly lifetime_token_budget?: number | null | undefined;
|
|
189
|
+
readonly lifetime_tokens_consumed?: number | null | undefined;
|
|
190
|
+
readonly max_concurrent?: number | null | undefined;
|
|
191
|
+
readonly scopes?: readonly string[] | undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function freezeList(
|
|
195
|
+
list: readonly unknown[] | ReadonlySet<unknown> | null | undefined,
|
|
196
|
+
): readonly string[] | null | undefined {
|
|
197
|
+
if (list == null) return list as null | undefined;
|
|
198
|
+
const values = list instanceof Set ? [...list] : Array.isArray(list) ? list : [];
|
|
199
|
+
const arr = Object.freeze(values.filter((value): value is string => typeof value === "string"));
|
|
200
|
+
return arr;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function freezeSnapshot(s: ApiKeyAuthorizationSnapshot): ApiKeyAuthorizationSnapshot {
|
|
204
|
+
const model_list = freezeList(s.model_list);
|
|
205
|
+
const model_access_mode: ApiKeyModelAccessMode =
|
|
206
|
+
s.model_access_mode === "blacklist" ? "blacklist" : "whitelist";
|
|
207
|
+
const client_router_denylist = freezeList(s.client_router_denylist);
|
|
208
|
+
|
|
209
|
+
const api_key_id = s.api_key_id ?? "";
|
|
210
|
+
const tenant_id = s.tenant_id ?? "";
|
|
211
|
+
const admission_identity = s.admission_identity ?? api_key_id;
|
|
212
|
+
|
|
213
|
+
const frozen: ApiKeyAuthorizationSnapshot = {
|
|
214
|
+
api_key_id,
|
|
215
|
+
tenant_id,
|
|
216
|
+
admission_identity,
|
|
217
|
+
model_access_mode,
|
|
218
|
+
model_list,
|
|
219
|
+
client_router_denylist,
|
|
220
|
+
rpm: s.rpm ?? s.rpm_limit ?? null,
|
|
221
|
+
daily_tokens: s.daily_tokens ?? null,
|
|
222
|
+
monthly_tokens: s.monthly_tokens ?? null,
|
|
223
|
+
lifetime_token_budget: s.lifetime_token_budget ?? null,
|
|
224
|
+
lifetime_tokens_consumed: s.lifetime_tokens_consumed ?? null,
|
|
225
|
+
max_concurrent: s.max_concurrent ?? null,
|
|
226
|
+
scopes: s.scopes ? Object.freeze([...s.scopes]) : undefined,
|
|
227
|
+
};
|
|
228
|
+
return Object.freeze(frozen) as ApiKeyAuthorizationSnapshot;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function createAuthorizationSnapshot(input: {
|
|
232
|
+
readonly api_key_id: string;
|
|
233
|
+
readonly tenant_id: string;
|
|
234
|
+
readonly admission_identity?: string;
|
|
235
|
+
readonly model_access_mode?: ApiKeyModelAccessMode | null;
|
|
236
|
+
readonly model_list?: readonly string[] | ReadonlySet<string> | null;
|
|
237
|
+
readonly client_router_denylist?: readonly unknown[] | ReadonlySet<unknown> | null;
|
|
238
|
+
readonly rpm?: number | null;
|
|
239
|
+
readonly daily_tokens?: number | null;
|
|
240
|
+
readonly monthly_tokens?: number | null;
|
|
241
|
+
readonly lifetime_token_budget?: number | null;
|
|
242
|
+
readonly lifetime_tokens_consumed?: number | null;
|
|
243
|
+
readonly max_concurrent?: number | null;
|
|
244
|
+
readonly rpm_limit?: number | null;
|
|
245
|
+
readonly scopes?: readonly string[];
|
|
246
|
+
}): ApiKeyAuthorizationSnapshot {
|
|
247
|
+
return freezeSnapshot({
|
|
248
|
+
api_key_id: input.api_key_id,
|
|
249
|
+
tenant_id: input.tenant_id,
|
|
250
|
+
...(input.admission_identity !== undefined
|
|
251
|
+
? { admission_identity: input.admission_identity }
|
|
252
|
+
: {}),
|
|
253
|
+
...(input.model_access_mode !== undefined && input.model_access_mode !== null
|
|
254
|
+
? { model_access_mode: input.model_access_mode }
|
|
255
|
+
: {}),
|
|
256
|
+
...(input.model_list !== undefined && input.model_list !== null
|
|
257
|
+
? { model_list: input.model_list as readonly string[] }
|
|
258
|
+
: {}),
|
|
259
|
+
...(input.client_router_denylist !== undefined && input.client_router_denylist !== null
|
|
260
|
+
? { client_router_denylist: input.client_router_denylist }
|
|
261
|
+
: {}),
|
|
262
|
+
...(input.rpm !== undefined && input.rpm !== null ? { rpm: input.rpm } : {}),
|
|
263
|
+
...(input.daily_tokens !== undefined && input.daily_tokens !== null
|
|
264
|
+
? { daily_tokens: input.daily_tokens }
|
|
265
|
+
: {}),
|
|
266
|
+
...(input.monthly_tokens !== undefined && input.monthly_tokens !== null
|
|
267
|
+
? { monthly_tokens: input.monthly_tokens }
|
|
268
|
+
: {}),
|
|
269
|
+
...(input.lifetime_token_budget !== undefined && input.lifetime_token_budget !== null
|
|
270
|
+
? { lifetime_token_budget: input.lifetime_token_budget }
|
|
271
|
+
: {}),
|
|
272
|
+
...(input.lifetime_tokens_consumed !== undefined && input.lifetime_tokens_consumed !== null
|
|
273
|
+
? { lifetime_tokens_consumed: input.lifetime_tokens_consumed }
|
|
274
|
+
: {}),
|
|
275
|
+
...(input.max_concurrent !== undefined && input.max_concurrent !== null
|
|
276
|
+
? { max_concurrent: input.max_concurrent }
|
|
277
|
+
: {}),
|
|
278
|
+
...(input.scopes !== undefined ? { scopes: input.scopes } : {}),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The model-access rule lives in `model-access-rule.ts`, a pure module the
|
|
284
|
+
* dashboard can import too (this file pulls in Drizzle/Node). Re-exported here
|
|
285
|
+
* so existing callers keep importing from `api-key-auth`.
|
|
286
|
+
*/
|
|
287
|
+
export {
|
|
288
|
+
isModelAllowed,
|
|
289
|
+
listIncludes,
|
|
290
|
+
modelRejectionReason,
|
|
291
|
+
type ModelRejectionReason,
|
|
292
|
+
} from "./model-access-rule";
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
export function getAdmissionIdentity(snapshot: ApiKeyAuthorizationSnapshot): string {
|
|
296
|
+
return snapshot.admission_identity ?? snapshot.api_key_id;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export interface ResolvedApiKey {
|
|
300
|
+
readonly id: string;
|
|
301
|
+
readonly tenantId: string;
|
|
302
|
+
readonly scopes: readonly AccessScope[];
|
|
303
|
+
readonly snapshot: ApiKeyAuthorizationSnapshot;
|
|
304
|
+
readonly modelPrefix?: string;
|
|
305
|
+
/** Share-template id whose remote CLI mappings are inherited by this child. */
|
|
306
|
+
readonly cliMappingOwnerId?: string;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The 401 a caller gets when a key resolves to nothing.
|
|
311
|
+
*
|
|
312
|
+
* One function, not a message repeated at each boundary: three surfaces refuse
|
|
313
|
+
* an unknown key, and any wording drift between them is a caller guessing which
|
|
314
|
+
* surface it hit. Unknown, revoked and expired stay deliberately
|
|
315
|
+
* indistinguishable — distinguishing them would let a caller confirm that a
|
|
316
|
+
* guessed token once existed — so there is no variant carrying a reason.
|
|
317
|
+
*/
|
|
318
|
+
const INVALID_KEY_MESSAGE = "Invalid API key. Check the key you are sending, or create a new one in API Keys and send it as: Authorization: Bearer <key>.";
|
|
319
|
+
|
|
320
|
+
export function unknownApiKeyError(): GatewayError {
|
|
321
|
+
return new GatewayError("authentication_failed", 401, INVALID_KEY_MESSAGE);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The 401 for the public model surface specifically.
|
|
326
|
+
*
|
|
327
|
+
* Model Studio is where the seeded default key is normally used, and that key
|
|
328
|
+
* cannot be deleted — it only rotates. Naming rotation there is actionable. The
|
|
329
|
+
* same sentence on a proxy request was misleading instead: a revoked or mistyped
|
|
330
|
+
* personal key was answered with an instruction that could not apply to it,
|
|
331
|
+
* sending the operator to rotate a key they were never using.
|
|
332
|
+
*/
|
|
333
|
+
export function modelsListApiKeyError(): GatewayError {
|
|
334
|
+
return new GatewayError(
|
|
335
|
+
"authentication_failed",
|
|
336
|
+
401,
|
|
337
|
+
"Invalid API key. Rotate the default Cartethyia API key in API Keys and send the new key as: Authorization: Bearer <key>.",
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Extracts exactly one supported credential source, preserving public auth statuses. */
|
|
342
|
+
export function requestToken(headers: Headers | Record<string, string>): string {
|
|
343
|
+
const get = (name: string): string | null =>
|
|
344
|
+
headers instanceof Headers ? headers.get(name) : (headers[name] ?? null);
|
|
345
|
+
const authorization = get("authorization")?.trim() ?? "";
|
|
346
|
+
const apiKey = get("x-api-key")?.trim() ?? "";
|
|
347
|
+
const bearerMatch = /^Bearer\s+(\S+)$/i.exec(authorization);
|
|
348
|
+
const hasAuthorization = authorization.length > 0;
|
|
349
|
+
const hasApiKey = apiKey.length > 0;
|
|
350
|
+
if (hasAuthorization && hasApiKey) {
|
|
351
|
+
// Both headers carrying the *same* credential is a compatibility idiom, not
|
|
352
|
+
// an error: an Anthropic-compatible client sends `x-api-key` and
|
|
353
|
+
// `Authorization: Bearer <same key>` together so a gateway reading either
|
|
354
|
+
// one works, and rejecting the pair rejects the whole client. Two
|
|
355
|
+
// *different* credentials stay a conflict — accepting whichever came first
|
|
356
|
+
// would silently decide the request's identity.
|
|
357
|
+
if (bearerMatch?.[1] === apiKey) return apiKey;
|
|
358
|
+
throw new GatewayError("invalid_request", 400, "The request sends two different credentials. Send exactly one key, either as Authorization: Bearer <key> or as the api-key header.");
|
|
359
|
+
}
|
|
360
|
+
if (hasApiKey) return apiKey;
|
|
361
|
+
const token = bearerMatch?.[1];
|
|
362
|
+
if (token === undefined)
|
|
363
|
+
throw new GatewayError("invalid_request", 401, "The request carries no usable credential. Send the key as: Authorization: Bearer <key>.");
|
|
364
|
+
return token;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Whether a key can possibly have children, and so whether summing them can
|
|
369
|
+
* return anything but zero.
|
|
370
|
+
*
|
|
371
|
+
* Children exist only for a `share` parent — the schema's own check constraint
|
|
372
|
+
* ties the mode to the parent-child shape (schema.ts:799-803), and no other
|
|
373
|
+
* mode mints recipients. Extracted and exported because the alternative is an
|
|
374
|
+
* untestable inline comparison guarding a database round-trip.
|
|
375
|
+
*/
|
|
376
|
+
export function canHaveChildKeys(key: { readonly keyMode?: string | null }): boolean {
|
|
377
|
+
return key.keyMode === "share";
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Looks up the API key by its hashed bearer token. Returns `undefined` for
|
|
382
|
+
* an unknown or revoked key — callers must reject with 401, never fall back
|
|
383
|
+
* to an implicit/default identity.
|
|
384
|
+
*/
|
|
385
|
+
export async function resolveApiKeyAuthorization(
|
|
386
|
+
db: CartethyiaDatabase,
|
|
387
|
+
token: string,
|
|
388
|
+
): Promise<ResolvedApiKey | undefined> {
|
|
389
|
+
const now = Date.now();
|
|
390
|
+
const hash = hashSecret(token);
|
|
391
|
+
const negativeAt = authNegativeCache.get(hash);
|
|
392
|
+
if (negativeAt !== undefined) {
|
|
393
|
+
if (now - negativeAt < AUTH_NEGATIVE_CACHE_TTL_MS) return undefined;
|
|
394
|
+
authNegativeCache.delete(hash);
|
|
395
|
+
}
|
|
396
|
+
const cached = authCache.get(hash);
|
|
397
|
+
if (cached && now - cached.at < AUTH_CACHE_TTL_MS) return cached.value;
|
|
398
|
+
// L2: a peer process may have resolved this token already. Reachable only
|
|
399
|
+
// after the L1 entry lapsed, so Redis is never consulted on a warm hit.
|
|
400
|
+
//
|
|
401
|
+
// Keyed by token hash, because that is what a lookup has — invalidation
|
|
402
|
+
// arrives as a key id and is delivered as a broadcast instead.
|
|
403
|
+
const shared = await readShared("auth", hash);
|
|
404
|
+
if (shared !== undefined) {
|
|
405
|
+
try {
|
|
406
|
+
const parsed = JSON.parse(shared) as ResolvedApiKey;
|
|
407
|
+
if (typeof parsed?.id === "string") {
|
|
408
|
+
indexAuthCache(hash, { at: now, value: parsed, keyId: parsed.id });
|
|
409
|
+
evictAuthCacheOldest();
|
|
410
|
+
return parsed;
|
|
411
|
+
}
|
|
412
|
+
} catch {
|
|
413
|
+
// A malformed or schema-drifted entry is ignored and re-resolved.
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// Answered from the cooldown before any I/O: during a store outage the
|
|
417
|
+
// query is the expensive part, and it is also the part that fails.
|
|
418
|
+
if (now < authFailureUntil) throw new ApiKeyStoreUnavailableError();
|
|
419
|
+
const store = new DrizzleApiKeyStore(db);
|
|
420
|
+
let row: Awaited<ReturnType<typeof store.findActiveByHash>>;
|
|
421
|
+
try {
|
|
422
|
+
row = await store.findActiveByHash(hash);
|
|
423
|
+
} catch (error) {
|
|
424
|
+
// Any failure here is the store's, not the key's — a revoked key is a
|
|
425
|
+
// normal empty result, so this only runs on a real fault.
|
|
426
|
+
authFailureUntil = Date.now() + AUTH_FAILURE_COOLDOWN_MS;
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
429
|
+
if (!row) {
|
|
430
|
+
authNegativeCache.set(hash, now);
|
|
431
|
+
evictAuthNegativeOldest();
|
|
432
|
+
return undefined;
|
|
433
|
+
}
|
|
434
|
+
// A child (shared) key consumes against its parent's quota, not just its
|
|
435
|
+
// own: the parent's budget is the operator's global cap across all
|
|
436
|
+
// recipients. Without this the parent's lifetime budget never moves no
|
|
437
|
+
// matter how much its children spend, so the limit silently never fires.
|
|
438
|
+
// One extra SELECT on the auth path, only for keys that have a parent.
|
|
439
|
+
const parent =
|
|
440
|
+
row.parentKeyId !== null ? await store.findActiveById(row.parentKeyId) : undefined;
|
|
441
|
+
// Share children authenticate as themselves but inherit the template's live
|
|
442
|
+
if (row.parentKeyId !== null && parent === undefined) return undefined;
|
|
443
|
+
// policy. Copy-on-issue alone went stale the moment an operator tightened a
|
|
444
|
+
// limit or blocked a client router on the parent — existing recipients kept
|
|
445
|
+
// the old row values forever. Reading the parent here makes denylist /
|
|
446
|
+
// one-time / recurring edits apply on the next cache miss.
|
|
447
|
+
const policy = parent ?? row;
|
|
448
|
+
// Only a `share` parent can have children, and children are the only thing
|
|
449
|
+
// the SUM below can find. Every other key ran an aggregate that was certain
|
|
450
|
+
// to return 0 — one extra Postgres round-trip on the auth path for nothing.
|
|
451
|
+
//
|
|
452
|
+
// `row.parentKeyId !== null` is the same test, kept as the mode check because
|
|
453
|
+
// the mode is what the share dialog and the schema's own check constraint
|
|
454
|
+
// key off (schema.ts:799-803), so the two cannot drift apart.
|
|
455
|
+
const canHaveChildren = canHaveChildKeys(parent ?? row);
|
|
456
|
+
const consumed =
|
|
457
|
+
((policy.id === row.id ? row.lifetimeTokensConsumed : policy.lifetimeTokensConsumed) ?? 0) +
|
|
458
|
+
(canHaveChildren ? await store.sumChildrenConsumed(policy.id) : 0) +
|
|
459
|
+
// In-flight deltas: what has been spent but not yet persisted.
|
|
460
|
+
pendingUsage(policy.id);
|
|
461
|
+
const scopes = Array.isArray(policy.scopes) ? (policy.scopes as AccessScope[]) : [];
|
|
462
|
+
const snapshot = createAuthorizationSnapshot({
|
|
463
|
+
api_key_id: row.id,
|
|
464
|
+
tenant_id: row.tenantId,
|
|
465
|
+
// Family quota / concurrency share one admission counter namespace so two
|
|
466
|
+
// recipients cannot each burn a full lifetime/daily budget.
|
|
467
|
+
...(parent ? { admission_identity: parent.id } : {}),
|
|
468
|
+
...(policy.modelList ? { model_list: policy.modelList as string[] } : {}),
|
|
469
|
+
model_access_mode: policy.modelAccessMode ?? "whitelist",
|
|
470
|
+
...(policy.clientRouterDenylist
|
|
471
|
+
? { client_router_denylist: policy.clientRouterDenylist as string[] }
|
|
472
|
+
: {}),
|
|
473
|
+
...(policy.requestsPerMinute != null ? { rpm: policy.requestsPerMinute } : {}),
|
|
474
|
+
...(policy.dailyTokenLimit != null ? { daily_tokens: policy.dailyTokenLimit } : {}),
|
|
475
|
+
...(policy.monthlyTokenLimit != null ? { monthly_tokens: policy.monthlyTokenLimit } : {}),
|
|
476
|
+
...(policy.lifetimeTokenBudget != null ? { lifetime_token_budget: policy.lifetimeTokenBudget } : {}),
|
|
477
|
+
lifetime_tokens_consumed: consumed,
|
|
478
|
+
...(policy.maxConcurrentRequests != null ? { max_concurrent: policy.maxConcurrentRequests } : {}),
|
|
479
|
+
scopes,
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const value: ResolvedApiKey = {
|
|
483
|
+
id: row.id,
|
|
484
|
+
tenantId: row.tenantId,
|
|
485
|
+
scopes,
|
|
486
|
+
snapshot,
|
|
487
|
+
...(policy.modelPrefix ? { modelPrefix: policy.modelPrefix } : {}),
|
|
488
|
+
...(parent?.keyMode === "share" ? { cliMappingOwnerId: parent.id } : {}),
|
|
489
|
+
};
|
|
490
|
+
indexAuthCache(hash, { at: Date.now(), value, keyId: value.id });
|
|
491
|
+
evictAuthCacheOldest();
|
|
492
|
+
// Shared copy for peer processes, keyed by the token hash so a lookup can
|
|
493
|
+
// find it without resolving the key first. Invalidation travels by broadcast,
|
|
494
|
+
// since a console mutation names a key id and cannot enumerate the hashes
|
|
495
|
+
// that resolved to it in another process.
|
|
496
|
+
//
|
|
497
|
+
// The cached value deliberately carries `lifetime_tokens_consumed` as read.
|
|
498
|
+
// That figure moves constantly, and the module already documents the snapshot
|
|
499
|
+
// as advisory on that axis (spending is enforced by the admission counter),
|
|
500
|
+
// so a peer serving it stale for up to the TTL is the same tradeoff the
|
|
501
|
+
// in-process cache has always made.
|
|
502
|
+
void writeShared("auth", hash, JSON.stringify(value), AUTH_CACHE_TTL_MS);
|
|
503
|
+
return value;
|
|
504
|
+
}
|