cartethyia 2.5.10 → 2.6.0
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 +8 -0
- package/dist/dashboard/assets/{BannedPage-Cd30Z0b9.js → BannedPage-l6XMnfr9.js} +1 -1
- package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-wf5u-ww6.js → CliToolDetailPage-DwOzAW45.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DykPvuck.js → CliToolsPage-BT3Up3QA.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-DwvSD3vs.js → CombosPage-6kYIUpzR.js} +1 -1
- package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-CIr7KDex.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-82X0DDdH.js → CustomizationPage-C7m-H1Rz.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-CS5mPuVF.js → LoginPage-CbphLvjg.js} +1 -1
- package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-C0UCUsVJ.js → ModelPicker-DVny4S5_.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
- package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-vdja5ZPK.js → ProvidersPage-Dfb2896L.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-BB4CNm4-.js → ProxyPage-BaCIdAUt.js} +1 -1
- package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
- package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
- package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-FadUOl9S.js → SetupPage-D4g6vOGc.js} +1 -1
- package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-Cfy6_Khv.js → SortableList-C93i14BD.js} +1 -1
- package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-CM92fgJf.js → StudioPage-SgQpymjm.js} +1 -1
- package/dist/dashboard/assets/{StudioPage-CM92fgJf.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
- package/dist/dashboard/assets/{UsagePage-BT4MyQeT.js → UsagePage-BFME-eba.js} +2 -2
- package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
- package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
- package/dist/dashboard/assets/{app-B7pVRhJ1.js → app-2KhtHCv9.js} +1 -1
- package/dist/dashboard/assets/{app-B7pVRhJ1.js.gz → app-2KhtHCv9.js.gz} +0 -0
- package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
- package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
- package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
- package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
- package/dist/dashboard/assets/{badge-Bvyr7D0u.js → badge-CKsF123d.js} +1 -1
- package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
- package/dist/dashboard/assets/{banner-DIvKAZih.js → banner-8j7uChA9.js} +1 -1
- package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
- package/dist/dashboard/assets/{card-D5_gYIS_.js → card-CdsSlWA3.js} +1 -1
- package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
- package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-B6vDBtbm.js → clipboard-button-uL5aYLfW.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-CpjrgdwJ.js → dialog-C07hr5_G.js} +1 -1
- package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-BWmt3b5y.js → drawer-BPO1TcKH.js} +1 -1
- package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
- package/dist/dashboard/assets/{entry-C_oakV3z.js → entry-Bz_CtDfC.js} +1 -1
- package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
- package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
- package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
- package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
- package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-RRHHWGxh.js → github-badge-DmQreSDd.js} +1 -1
- package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
- package/dist/dashboard/assets/{index-Bhey_rg_.js → index-Bblu6erR.js} +2 -2
- package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
- package/dist/dashboard/assets/{inline-C2_EQ5Uf.js → inline-C9dCYaBZ.js} +1 -1
- package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
- package/dist/dashboard/assets/{input-BMRlQ450.js → input-CjCnXweJ.js} +1 -1
- package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
- package/dist/dashboard/assets/{layout-XR5xP8ew.js → layout-V6y8DAkl.js} +1 -1
- package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-Cw07dioc.js → list-row-CM1AaG_y.js} +1 -1
- package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
- package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
- package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
- package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
- package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
- package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-DrdA9oSM.js → numbers-BOevJzdz.js} +1 -1
- package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
- package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
- package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
- package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
- package/dist/dashboard/assets/{select-CxVVB__N.js → select-C980c4cz.js} +1 -1
- package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
- package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
- package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
- package/dist/dashboard/assets/{stack-uPOwM9Hs.js → stack-_gfTdLVB.js} +1 -1
- package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
- package/dist/dashboard/assets/{state-D1Yz79iL.js → state-2c5V4zo7.js} +1 -1
- package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
- package/dist/dashboard/assets/{switch-sevsRrRo.js → switch-Dkke7Naf.js} +1 -1
- package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
- package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +51 -51
- package/migrations/README.md +52 -52
- package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
- package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
- package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
- package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
- package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
- package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
- package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
- package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
- package/package.json +96 -95
- package/scripts/cli/admin.ts +126 -128
- package/scripts/cli/autostart.ts +237 -237
- package/scripts/cli/background.ts +120 -110
- package/scripts/cli/bin/carte.cjs +8 -8
- package/scripts/cli/bin/cartethyia.cjs +167 -167
- package/scripts/cli/console-menu.ts +39 -30
- package/scripts/cli/controller.ts +145 -0
- package/scripts/cli/env.ts +99 -99
- package/scripts/cli/gateway.ts +264 -260
- package/scripts/cli/instance.ts +234 -224
- package/scripts/cli/main.ts +188 -167
- package/scripts/cli/platform.ts +64 -64
- package/scripts/cli/prompt.ts +217 -79
- package/scripts/cli/services.ts +88 -88
- package/scripts/cli/setup.ts +445 -445
- package/scripts/cli/start.ts +85 -85
- package/scripts/cli/startup-signal.ts +23 -23
- package/scripts/cli/telemetry.ts +70 -0
- package/scripts/cli/tray/index.ts +263 -263
- package/scripts/cli/tray/windows.ps1 +107 -107
- package/scripts/cli/tray.ts +29 -4
- package/scripts/cli/update.ts +39 -39
- package/scripts/cli/version.ts +67 -67
- package/scripts/cli/wordmark.ts +58 -58
- package/src/boot/app.ts +16 -1
- package/src/boot/dependencies.ts +128 -25
- package/src/config/resources.ts +137 -0
- package/src/config/runtime.ts +133 -76
- package/src/config/spec.ts +30 -29
- package/src/config/telemetry.ts +0 -25
- package/src/console/api-keys/bucket-spend.ts +14 -17
- package/src/console/api-keys/contracts.ts +410 -410
- package/src/console/api-keys/routes.ts +12 -0
- package/src/console/audit/store.ts +2 -3
- package/src/console/auth/setup-service.ts +8 -1
- package/src/console/backup/routes.ts +7 -0
- package/src/console/backup/service.ts +463 -463
- package/src/console/backup/store.ts +16 -11
- package/src/console/backup/validate.ts +1 -4
- package/src/console/cli-tools/fs-ops.ts +18 -2
- package/src/console/data-management/contracts.ts +1 -8
- package/src/console/data-management/operations.ts +61 -48
- package/src/console/data-management/routes.ts +4 -2
- package/src/console/domain-registration.ts +25 -3
- package/src/console/inferhub/client.ts +139 -0
- package/src/console/inferhub/key-store.ts +129 -0
- package/src/console/inferhub/normalize.ts +347 -0
- package/src/console/inferhub/routes.ts +411 -0
- package/src/console/inferhub/store.ts +109 -0
- package/src/console/inferhub/types.ts +183 -0
- package/src/console/observability/contracts.ts +2 -41
- package/src/console/observability/store.ts +176 -371
- package/src/console/providers/catalog/public-model-store.ts +0 -0
- package/src/console/providers/catalog/store.ts +23 -36
- package/src/console/quota/account-quota-shared.ts +52 -52
- package/src/console/quota/account-quota-tenant.ts +16 -24
- package/src/console/quota/refresh.ts +441 -441
- package/src/console/routing/pools/import-parse.ts +206 -206
- package/src/console/routing/pools/operations.ts +543 -543
- package/src/console/routing/pools/probe-verdict.ts +132 -132
- package/src/console/routing/pools/routes.ts +181 -181
- package/src/console/settings/contracts.ts +42 -40
- package/src/console/settings/store.ts +25 -25
- package/src/console/share/share-stats.ts +22 -4
- package/src/console/share/share-usage.ts +30 -4
- package/src/console/shared/cursor.ts +15 -0
- package/src/console/updates/routes.ts +12 -0
- package/src/main.ts +13 -5
- package/src/network/pool/resolver.ts +1104 -1104
- package/src/observability/client-name.ts +22 -0
- package/src/observability/event-log.ts +5 -29
- package/src/observability/event-query.ts +690 -0
- package/src/observability/log-ring.ts +26 -2
- package/src/observability/log-sanitize.ts +118 -0
- package/src/observability/logger.ts +17 -13
- package/src/observability/metrics.ts +35 -0
- package/src/observability/payload-capture.ts +114 -41
- package/src/observability/payload-store.ts +249 -24
- package/src/observability/raw-event-writer.ts +118 -0
- package/src/observability/runtime-metrics.ts +71 -1
- package/src/observability/telemetry-buffer.ts +100 -17
- package/src/observability/telemetry-reconciliation.ts +417 -0
- package/src/persistence/boot.ts +20 -9
- package/src/persistence/db-pg.ts +13 -0
- package/src/persistence/db-sqlite.ts +569 -497
- package/src/persistence/dialect.ts +3 -2
- package/src/persistence/migrate.ts +181 -181
- package/src/persistence/schema.ts +1496 -1406
- package/src/persistence/shared-cache.ts +5 -1
- package/src/persistence/stores/api-key-store.ts +581 -581
- package/src/persistence/stores/telemetry-store.ts +124 -63
- package/src/protocol/reasoning-text.ts +30 -0
- package/src/protocol/response/chat.ts +33 -7
- package/src/protocol/response/codex.ts +14 -2
- package/src/protocol/response/messages.ts +5 -2
- package/src/providers/authentication/oauth-flow-store.ts +3 -10
- package/src/providers/bundled-discovery-providers.ts +122 -122
- package/src/providers/default-registry.ts +10 -0
- package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
- package/src/providers/discovery/models-dev-catalog.ts +354 -354
- package/src/providers/discovery/probe-phases.ts +43 -7
- package/src/providers/discovery/probing-service.ts +70 -32
- package/src/providers/integrations/antigravity/antigravity.ts +9 -2
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
- package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
- package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
- package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
- package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
- package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
- package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
- package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
- package/src/providers/integrations/claude/claude-quota.ts +2 -2
- package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
- package/src/providers/integrations/claude/claude.ts +3 -4
- package/src/providers/integrations/cline/cline.ts +94 -8
- package/src/providers/integrations/codex/codex.ts +61 -37
- package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
- package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
- package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
- package/src/providers/integrations/cursor/cursor-request.ts +11 -0
- package/src/providers/integrations/cursor/cursor.ts +29 -17
- package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
- package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
- package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
- package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
- package/src/providers/integrations/grok/grok-quota.ts +279 -279
- package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
- package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
- package/src/providers/integrations/hermes.ts +7 -1
- package/src/providers/integrations/inferhub.ts +201 -73
- package/src/providers/integrations/kilo/kilo.ts +152 -152
- package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
- package/src/providers/integrations/openrouter/provider.ts +129 -129
- package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
- package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
- package/src/providers/integrations/trae/trae-oauth.ts +4 -26
- package/src/providers/integrations/trae/trae-quota.ts +389 -384
- package/src/providers/integrations/trae/trae-realm.ts +15 -2
- package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
- package/src/providers/integrations/zcode/oauth.ts +1 -1
- package/src/providers/operations/account-health-service.ts +605 -279
- package/src/providers/provider-metadata.ts +12 -1
- package/src/providers/provider-model-definition.ts +373 -373
- package/src/providers/quota/quota-contracts.ts +3 -7
- package/src/providers/search/search-provider.ts +2 -6
- package/src/security/admission/contracts.ts +10 -0
- package/src/security/admission/in-memory-store.ts +89 -14
- package/src/security/admission/lua.ts +191 -182
- package/src/security/admission/redis-store.ts +30 -0
- package/src/security/admission/service.ts +18 -0
- package/src/security/api-key-auth-cache.ts +96 -0
- package/src/security/api-key-auth.ts +11 -100
- package/src/security/client-router-fingerprint.ts +3 -7
- package/src/security/ip-boundary.ts +7 -3
- package/src/shared/coerce.ts +67 -0
- package/src/shared/hash.ts +6 -0
- package/src/shared/time.ts +7 -0
- package/src/transport/dispatch/attempt-finalize.ts +26 -138
- package/src/transport/dispatch/attempt-loop.ts +103 -81
- package/src/transport/dispatch/capture-policy.ts +78 -0
- package/src/transport/dispatch/proxy-request.ts +4 -2
- package/src/transport/dispatch/streaming/encoder.ts +111 -5
- package/src/transport/dispatch/streaming/stream.ts +19 -2
- package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
- package/src/transport/failure-policy.ts +27 -0
- package/src/transport/guards/abuse-guard.ts +2 -2
- package/src/transport/guards/auth-guard.ts +17 -0
- package/src/transport/guards/body-policy.ts +3 -2
- package/src/transport/guards/concurrency-guard.ts +89 -13
- package/src/transport/guards/early-rejection-capture.ts +43 -3
- package/src/transport/guards/error-lifecycle.ts +10 -2
- package/src/transport/guards/guard-errors.ts +23 -0
- package/src/transport/guards/headroom-guard.ts +41 -7
- package/src/transport/guards/pipeline.ts +24 -0
- package/src/transport/guards/request-context.ts +2 -1
- package/src/transport/routing/admission.ts +51 -9
- package/src/transport/routing/bounded-lru.ts +49 -0
- package/src/transport/routing/candidate-index.ts +88 -0
- package/src/transport/routing/reservations.ts +121 -7
- package/src/transport/routing/round-robin.ts +10 -22
- package/src/transport/routing/route-catalog.ts +26 -13
- package/src/transport/routing/route-model.ts +22 -2
- package/src/transport/routing/router.ts +53 -31
- package/src/transport/surface/chat/encode.ts +2 -3
- package/src/transport/surface/chat/parse.ts +2 -1
- package/src/transport/surface/dialects.ts +11 -3
- package/src/transport/surface/messages/encode.ts +2 -1
- package/src/transport/surface/messages/parse.ts +3 -1
- package/src/transport/surface/messages/stream.ts +2 -6
- package/src/workers/maintenance-lease.ts +200 -0
- package/src/workers/scheduler.ts +101 -1
- package/dist/dashboard/assets/BannedPage-Cd30Z0b9.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-wf5u-ww6.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DykPvuck.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-DwvSD3vs.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-CIr7KDex.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-82X0DDdH.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-CS5mPuVF.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-C0UCUsVJ.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js +0 -1
- package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-vdja5ZPK.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BB4CNm4-.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js +0 -1
- package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js +0 -1
- package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-FadUOl9S.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-Cfy6_Khv.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-BT4MyQeT.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
- package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
- package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
- package/dist/dashboard/assets/badge-Bvyr7D0u.js.gz +0 -0
- package/dist/dashboard/assets/banner-DIvKAZih.js.gz +0 -0
- package/dist/dashboard/assets/card-D5_gYIS_.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-B6vDBtbm.js.gz +0 -0
- package/dist/dashboard/assets/dialog-CpjrgdwJ.js.gz +0 -0
- package/dist/dashboard/assets/drawer-BWmt3b5y.js.gz +0 -0
- package/dist/dashboard/assets/entry-B-gvimrg.js +0 -1
- package/dist/dashboard/assets/entry-B-gvimrg.js.gz +0 -0
- package/dist/dashboard/assets/entry-C_oakV3z.js.gz +0 -0
- package/dist/dashboard/assets/entry-Dj0cHfEc.js +0 -4
- package/dist/dashboard/assets/entry-Dj0cHfEc.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-RRHHWGxh.js.gz +0 -0
- package/dist/dashboard/assets/index-Bhey_rg_.js.gz +0 -0
- package/dist/dashboard/assets/inline-C2_EQ5Uf.js.gz +0 -0
- package/dist/dashboard/assets/input-BMRlQ450.js.gz +0 -0
- package/dist/dashboard/assets/layout-XR5xP8ew.js.gz +0 -0
- package/dist/dashboard/assets/list-row-Cw07dioc.js.gz +0 -0
- package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
- package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
- package/dist/dashboard/assets/numbers-DrdA9oSM.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
- package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
- package/dist/dashboard/assets/select-CxVVB__N.js.gz +0 -0
- package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
- package/dist/dashboard/assets/stack-uPOwM9Hs.js.gz +0 -0
- package/dist/dashboard/assets/state-D1Yz79iL.js.gz +0 -0
- package/dist/dashboard/assets/switch-sevsRrRo.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
- package/src/observability/event-compaction.ts +0 -317
- package/src/observability/event-day-index.ts +0 -439
|
@@ -61,6 +61,7 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
|
|
|
61
61
|
request.apiKeyId,
|
|
62
62
|
String(LEASE_TTL_MS),
|
|
63
63
|
String(LEASE_KEY_TTL_SECONDS),
|
|
64
|
+
String(Math.max(0, request.concurrencyTolerance ?? 0)),
|
|
64
65
|
);
|
|
65
66
|
assertResult(result, "reserve");
|
|
66
67
|
}
|
|
@@ -142,11 +143,20 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
|
|
|
142
143
|
* Drops every counter for one key (revocation). Active in-flight leases
|
|
143
144
|
* settle through the release script first so their concurrency slots do
|
|
144
145
|
* not leak; remaining lease rows expire via TTL. Best-effort by contract.
|
|
146
|
+
*
|
|
147
|
+
* Bounded per invocation: a keyspace that has grown for months cannot turn
|
|
148
|
+
* one revocation into an unbounded walk — lease-scan pages, leases released,
|
|
149
|
+
* and counter-scan pages each stop cleanly at their budget, and the rest
|
|
150
|
+
* ages out via TTL (the fallback this contract already relies on). A later
|
|
151
|
+
* revocation or the lease sweeper picks up what a budgeted pass left.
|
|
145
152
|
*/
|
|
146
153
|
async purge(apiKeyId: string): Promise<void> {
|
|
147
154
|
try {
|
|
155
|
+
let leaseScanPages = 0;
|
|
156
|
+
let leasesReleased = 0;
|
|
148
157
|
let cursor = "0";
|
|
149
158
|
do {
|
|
159
|
+
if (leaseScanPages >= MAX_PURGE_LEASE_SCAN_PAGES) break;
|
|
150
160
|
const [nextCursor, keys] = await this.redis.scan(
|
|
151
161
|
cursor,
|
|
152
162
|
"MATCH",
|
|
@@ -155,6 +165,7 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
|
|
|
155
165
|
100,
|
|
156
166
|
);
|
|
157
167
|
cursor = String(nextCursor);
|
|
168
|
+
leaseScanPages += 1;
|
|
158
169
|
const leaseKeys = keys as string[];
|
|
159
170
|
if (leaseKeys.length === 0) continue;
|
|
160
171
|
// One pipeline for the whole scan page instead of an HGET per lease:
|
|
@@ -172,15 +183,22 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
|
|
|
172
183
|
return Array.isArray(entry) && entry[1] === apiKeyId;
|
|
173
184
|
});
|
|
174
185
|
for (const leaseKey of owned) {
|
|
186
|
+
if (leasesReleased >= MAX_PURGE_LEASES_RELEASED) break;
|
|
175
187
|
await redisEvalNumber(this.redis, RELEASE_SCRIPT, 1, leaseKey, apiKeyId).catch(
|
|
176
188
|
() => -1,
|
|
177
189
|
);
|
|
190
|
+
leasesReleased += 1;
|
|
178
191
|
}
|
|
192
|
+
// Releasing is the point of the lease phase; once its budget is spent
|
|
193
|
+
// there is nothing more this pass can usefully do with further pages.
|
|
194
|
+
if (leasesReleased >= MAX_PURGE_LEASES_RELEASED) break;
|
|
179
195
|
} while (cursor !== "0");
|
|
196
|
+
let counterScanPages = 0;
|
|
180
197
|
let counterCursor = "0";
|
|
181
198
|
const patterns = [`admission:*:${apiKeyId}`, `admission:*:${apiKeyId}:*`];
|
|
182
199
|
for (const pattern of patterns) {
|
|
183
200
|
do {
|
|
201
|
+
if (counterScanPages >= MAX_PURGE_COUNTER_SCAN_PAGES) break;
|
|
184
202
|
const [nextCursor, keys] = await this.redis.scan(
|
|
185
203
|
counterCursor,
|
|
186
204
|
"MATCH",
|
|
@@ -189,8 +207,10 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
|
|
|
189
207
|
100,
|
|
190
208
|
);
|
|
191
209
|
counterCursor = String(nextCursor);
|
|
210
|
+
counterScanPages += 1;
|
|
192
211
|
if ((keys as string[]).length > 0) await this.redis.del(...(keys as string[]));
|
|
193
212
|
} while (counterCursor !== "0");
|
|
213
|
+
if (counterScanPages >= MAX_PURGE_COUNTER_SCAN_PAGES) break;
|
|
194
214
|
}
|
|
195
215
|
} catch {
|
|
196
216
|
// Best-effort: revocation already succeeded; stale counters expire via TTL.
|
|
@@ -198,6 +218,16 @@ export class RedisAdmissionCounterStore implements AdmissionCounterStore {
|
|
|
198
218
|
}
|
|
199
219
|
}
|
|
200
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Per-invocation purge budgets. Chosen so one full-budgeted pass still costs
|
|
223
|
+
* well under a second of Redis time (hundreds of small commands), while a
|
|
224
|
+
* normal revocation — tens of leases, a handful of counters — never notices
|
|
225
|
+
* them. Mirrors the lease sweeper's own bounded-pass philosophy.
|
|
226
|
+
*/
|
|
227
|
+
export const MAX_PURGE_LEASE_SCAN_PAGES = 40;
|
|
228
|
+
export const MAX_PURGE_LEASES_RELEASED = 100;
|
|
229
|
+
export const MAX_PURGE_COUNTER_SCAN_PAGES = 40;
|
|
230
|
+
|
|
201
231
|
/**
|
|
202
232
|
* Releases a lease identified only by its Redis key (used by the sweeper).
|
|
203
233
|
* Reads the owning api key id from the lease hash and runs RELEASE_SCRIPT,
|
|
@@ -35,6 +35,14 @@ export class ApiKeyAdmissionService {
|
|
|
35
35
|
) => Promise<number | null> | number | null = () => null,
|
|
36
36
|
private readonly persistLifetimeUsage?: LifetimeUsagePersister,
|
|
37
37
|
private readonly freshLifetimeConsumed?: FreshLifetimeConsumedReader,
|
|
38
|
+
/**
|
|
39
|
+
* Hidden allowance above each key's concurrency limit. Injected rather
|
|
40
|
+
* than read from the snapshot so the number is operator configuration,
|
|
41
|
+
* not something a key can carry about itself.
|
|
42
|
+
*/
|
|
43
|
+
private readonly concurrencyTolerance?: (
|
|
44
|
+
tenantId: string,
|
|
45
|
+
) => Promise<number | null> | number | null,
|
|
38
46
|
) {
|
|
39
47
|
if (!store) throw new Error("ApiKeyAdmissionService requires an admission store");
|
|
40
48
|
}
|
|
@@ -96,6 +104,15 @@ export class ApiKeyAdmissionService {
|
|
|
96
104
|
const reservationId = `${apiKeyId}:${now}:${crypto.randomUUID()}`;
|
|
97
105
|
const rawTenantLimit = this.tenantConcurrencyProvider(snapshot.tenant_id);
|
|
98
106
|
const tenantLimit = rawTenantLimit instanceof Promise ? await rawTenantLimit : rawTenantLimit;
|
|
107
|
+
/*
|
|
108
|
+
* One number, one place. The allowance used to be overridable per key,
|
|
109
|
+
* so the tenant-wide value was only the answer for a key that had never
|
|
110
|
+
* been given one — an operator who raised it in Settings could not tell
|
|
111
|
+
* why one key still refused early, because the override lived in a modal
|
|
112
|
+
* the Settings page does not show. It is tenant-wide only now.
|
|
113
|
+
*/
|
|
114
|
+
const rawTolerance = this.concurrencyTolerance?.(snapshot.tenant_id);
|
|
115
|
+
const tolerance = rawTolerance instanceof Promise ? await rawTolerance : rawTolerance;
|
|
99
116
|
|
|
100
117
|
try {
|
|
101
118
|
await this.store.reserve({
|
|
@@ -113,6 +130,7 @@ export class ApiKeyAdmissionService {
|
|
|
113
130
|
lifetimeBudget: snapshot.lifetime_token_budget ?? null,
|
|
114
131
|
lifetimeConsumed: snapshot.lifetime_tokens_consumed ?? 0,
|
|
115
132
|
concurrencyLimit: snapshot.max_concurrent ?? null,
|
|
133
|
+
concurrencyTolerance: tolerance ?? null,
|
|
116
134
|
tenantId: snapshot.tenant_id,
|
|
117
135
|
tenantConcurrencyLimit: tenantLimit,
|
|
118
136
|
// Fresh lifetime reader is invoked by the store only when the counter
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** Bounded process-local authorization cache; shared-cache I/O stays in the auth service. */
|
|
2
|
+
export const AUTH_CACHE_TTL_MS = 3_000;
|
|
3
|
+
const AUTH_CACHE_MAX_ENTRIES = 10_000;
|
|
4
|
+
const AUTH_NEGATIVE_CACHE_TTL_MS = 1_500;
|
|
5
|
+
const AUTH_FAILURE_COOLDOWN_MS = 1_000;
|
|
6
|
+
|
|
7
|
+
interface CacheEntry<T> {
|
|
8
|
+
readonly at: number;
|
|
9
|
+
readonly value: T;
|
|
10
|
+
readonly keyId: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type Lookup<T> =
|
|
14
|
+
| { readonly kind: "positive"; readonly value: T }
|
|
15
|
+
| { readonly kind: "negative" }
|
|
16
|
+
| { readonly kind: "miss" };
|
|
17
|
+
|
|
18
|
+
/** Create the process-local positive/negative authorization cache. */
|
|
19
|
+
export function createApiKeyAuthorizationCache<T>() {
|
|
20
|
+
const entries = new Map<string, CacheEntry<T>>();
|
|
21
|
+
const hashesByKeyId = new Map<string, Set<string>>();
|
|
22
|
+
const negativeEntries = new Map<string, number>();
|
|
23
|
+
let failureUntil = 0;
|
|
24
|
+
|
|
25
|
+
const evictOldest = (): void => {
|
|
26
|
+
while (entries.size > AUTH_CACHE_MAX_ENTRIES) {
|
|
27
|
+
const oldest = entries.keys().next().value;
|
|
28
|
+
if (oldest === undefined) return;
|
|
29
|
+
const entry = entries.get(oldest);
|
|
30
|
+
entries.delete(oldest);
|
|
31
|
+
if (entry === undefined) continue;
|
|
32
|
+
const hashes = hashesByKeyId.get(entry.keyId);
|
|
33
|
+
if (hashes === undefined) continue;
|
|
34
|
+
hashes.delete(oldest);
|
|
35
|
+
if (hashes.size === 0) hashesByKeyId.delete(entry.keyId);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const evictNegativeOldest = (): void => {
|
|
40
|
+
while (negativeEntries.size > AUTH_CACHE_MAX_ENTRIES) {
|
|
41
|
+
const oldest = negativeEntries.keys().next().value;
|
|
42
|
+
if (oldest === undefined) return;
|
|
43
|
+
negativeEntries.delete(oldest);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
lookup(hash: string, now: number): Lookup<T> {
|
|
49
|
+
const negativeAt = negativeEntries.get(hash);
|
|
50
|
+
if (negativeAt !== undefined) {
|
|
51
|
+
if (now - negativeAt < AUTH_NEGATIVE_CACHE_TTL_MS) return { kind: "negative" };
|
|
52
|
+
negativeEntries.delete(hash);
|
|
53
|
+
}
|
|
54
|
+
const cached = entries.get(hash);
|
|
55
|
+
if (cached === undefined || now - cached.at >= AUTH_CACHE_TTL_MS) return { kind: "miss" };
|
|
56
|
+
entries.delete(hash);
|
|
57
|
+
entries.set(hash, cached);
|
|
58
|
+
return { kind: "positive", value: cached.value };
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
rememberPositive(hash: string, keyId: string, value: T, now: number): void {
|
|
62
|
+
entries.set(hash, { at: now, value, keyId });
|
|
63
|
+
negativeEntries.delete(hash);
|
|
64
|
+
const hashes = hashesByKeyId.get(keyId) ?? new Set<string>();
|
|
65
|
+
hashes.add(hash);
|
|
66
|
+
hashesByKeyId.set(keyId, hashes);
|
|
67
|
+
evictOldest();
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
rememberNegative(hash: string, now: number): void {
|
|
71
|
+
negativeEntries.set(hash, now);
|
|
72
|
+
evictNegativeOldest();
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
invalidate(keyId?: string): void {
|
|
76
|
+
negativeEntries.clear();
|
|
77
|
+
if (keyId === undefined) {
|
|
78
|
+
entries.clear();
|
|
79
|
+
hashesByKeyId.clear();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const hashes = hashesByKeyId.get(keyId);
|
|
83
|
+
if (hashes === undefined) return;
|
|
84
|
+
for (const hash of hashes) entries.delete(hash);
|
|
85
|
+
hashesByKeyId.delete(keyId);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
markStoreUnavailable(now: number): void {
|
|
89
|
+
failureUntil = now + AUTH_FAILURE_COOLDOWN_MS;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
isStoreUnavailable(now: number): boolean {
|
|
93
|
+
return now < failureUntil;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -5,6 +5,7 @@ import { deleteShared, publishInvalidation, readShared, writeShared } from "../p
|
|
|
5
5
|
import { DrizzleApiKeyStore } from "../persistence/stores/api-key-store";
|
|
6
6
|
import { GatewayError } from "../protocol/gateway-error";
|
|
7
7
|
import { hashSecret } from "./crypto";
|
|
8
|
+
import { AUTH_CACHE_TTL_MS, createApiKeyAuthorizationCache } from "./api-key-auth-cache";
|
|
8
9
|
import type { AccessScope } from "./access-control";
|
|
9
10
|
import type { ApiKeyModelAccessMode } from "../persistence/schema";
|
|
10
11
|
|
|
@@ -23,37 +24,7 @@ import type { ApiKeyModelAccessMode } from "../persistence/schema";
|
|
|
23
24
|
* Negative results (unknown/revoked tokens) are deliberately NOT cached, so
|
|
24
25
|
* a newly minted key is usable immediately.
|
|
25
26
|
*/
|
|
26
|
-
const
|
|
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;
|
|
27
|
+
const authorizationCache = createApiKeyAuthorizationCache<ResolvedApiKey>();
|
|
57
28
|
|
|
58
29
|
/** Thrown when the credential store could not be reached at all. */
|
|
59
30
|
export class ApiKeyStoreUnavailableError extends Error {
|
|
@@ -84,52 +55,6 @@ let pendingUsage: (apiKeyId: string) => number = () => 0;
|
|
|
84
55
|
export function setPendingUsageSource(source: (apiKeyId: string) => number): void {
|
|
85
56
|
pendingUsage = source;
|
|
86
57
|
}
|
|
87
|
-
/**
|
|
88
|
-
* Negative-result fast path. A repeat miss for an unknown/revoked token within
|
|
89
|
-
* 1.5 s costs one HMAC + one map lookup and no query. Keyed by the full HMAC: a
|
|
90
|
-
* cheap pre-hash would let a crafted collision reject someone else's valid key,
|
|
91
|
-
* while the HMAC key can only match its own token. Bound below beside the
|
|
92
|
-
* 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
58
|
/**
|
|
134
59
|
* Invalidates every cached authorization entry for one API key (or the entire
|
|
135
60
|
* cache when `keyId` is omitted). Called by the API-key domain after any
|
|
@@ -138,19 +63,12 @@ function evictAuthNegativeOldest(): void {
|
|
|
138
63
|
export function invalidateApiKeyCache(keyId?: string): void {
|
|
139
64
|
// A mutation may mint a token the negative cache has already rejected, so
|
|
140
65
|
// negative entries must go with the positive ones.
|
|
141
|
-
|
|
66
|
+
authorizationCache.invalidate(keyId);
|
|
142
67
|
if (keyId === undefined) {
|
|
143
|
-
authCache.clear();
|
|
144
|
-
authCacheByKeyId.clear();
|
|
145
68
|
void deleteShared("auth");
|
|
146
69
|
publishInvalidation({ namespace: "auth" });
|
|
147
70
|
return;
|
|
148
71
|
}
|
|
149
|
-
const hashes = authCacheByKeyId.get(keyId);
|
|
150
|
-
if (hashes) {
|
|
151
|
-
for (const hash of hashes) authCache.delete(hash);
|
|
152
|
-
authCacheByKeyId.delete(keyId);
|
|
153
|
-
}
|
|
154
72
|
// Peer processes hold their own copy, and `reusePort` means the console
|
|
155
73
|
// write may have landed on a different one entirely. Dropping only locally
|
|
156
74
|
// leaves a revoked key valid for up to a full TTL everywhere else.
|
|
@@ -388,13 +306,9 @@ export async function resolveApiKeyAuthorization(
|
|
|
388
306
|
): Promise<ResolvedApiKey | undefined> {
|
|
389
307
|
const now = Date.now();
|
|
390
308
|
const hash = hashSecret(token);
|
|
391
|
-
const
|
|
392
|
-
if (
|
|
393
|
-
|
|
394
|
-
authNegativeCache.delete(hash);
|
|
395
|
-
}
|
|
396
|
-
const cached = authCache.get(hash);
|
|
397
|
-
if (cached && now - cached.at < AUTH_CACHE_TTL_MS) return cached.value;
|
|
309
|
+
const cached = authorizationCache.lookup(hash, now);
|
|
310
|
+
if (cached.kind === "negative") return undefined;
|
|
311
|
+
if (cached.kind === "positive") return cached.value;
|
|
398
312
|
// L2: a peer process may have resolved this token already. Reachable only
|
|
399
313
|
// after the L1 entry lapsed, so Redis is never consulted on a warm hit.
|
|
400
314
|
//
|
|
@@ -405,8 +319,7 @@ export async function resolveApiKeyAuthorization(
|
|
|
405
319
|
try {
|
|
406
320
|
const parsed = JSON.parse(shared) as ResolvedApiKey;
|
|
407
321
|
if (typeof parsed?.id === "string") {
|
|
408
|
-
|
|
409
|
-
evictAuthCacheOldest();
|
|
322
|
+
authorizationCache.rememberPositive(hash, parsed.id, parsed, now);
|
|
410
323
|
return parsed;
|
|
411
324
|
}
|
|
412
325
|
} catch {
|
|
@@ -415,7 +328,7 @@ export async function resolveApiKeyAuthorization(
|
|
|
415
328
|
}
|
|
416
329
|
// Answered from the cooldown before any I/O: during a store outage the
|
|
417
330
|
// query is the expensive part, and it is also the part that fails.
|
|
418
|
-
if (now
|
|
331
|
+
if (authorizationCache.isStoreUnavailable(now)) throw new ApiKeyStoreUnavailableError();
|
|
419
332
|
const store = new DrizzleApiKeyStore(db);
|
|
420
333
|
let row: Awaited<ReturnType<typeof store.findActiveByHash>>;
|
|
421
334
|
try {
|
|
@@ -423,12 +336,11 @@ export async function resolveApiKeyAuthorization(
|
|
|
423
336
|
} catch (error) {
|
|
424
337
|
// Any failure here is the store's, not the key's — a revoked key is a
|
|
425
338
|
// normal empty result, so this only runs on a real fault.
|
|
426
|
-
|
|
339
|
+
authorizationCache.markStoreUnavailable(Date.now());
|
|
427
340
|
throw error;
|
|
428
341
|
}
|
|
429
342
|
if (!row) {
|
|
430
|
-
|
|
431
|
-
evictAuthNegativeOldest();
|
|
343
|
+
authorizationCache.rememberNegative(hash, now);
|
|
432
344
|
return undefined;
|
|
433
345
|
}
|
|
434
346
|
// A child (shared) key consumes against its parent's quota, not just its
|
|
@@ -487,8 +399,7 @@ export async function resolveApiKeyAuthorization(
|
|
|
487
399
|
...(policy.modelPrefix ? { modelPrefix: policy.modelPrefix } : {}),
|
|
488
400
|
...(parent?.keyMode === "share" ? { cliMappingOwnerId: parent.id } : {}),
|
|
489
401
|
};
|
|
490
|
-
|
|
491
|
-
evictAuthCacheOldest();
|
|
402
|
+
authorizationCache.rememberPositive(hash, value.id, value, Date.now());
|
|
492
403
|
// Shared copy for peer processes, keyed by the token hash so a lookup can
|
|
493
404
|
// find it without resolving the key first. Invalidation travels by broadcast,
|
|
494
405
|
// since a console mutation names a key id and cannot enumerate the hashes
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getHeader as readRecordHeader } from "../protocol/canonical-model";
|
|
2
|
+
|
|
1
3
|
// Client-router fingerprinting for inbound `/v1/*` traffic.
|
|
2
4
|
//
|
|
3
5
|
// Several AI-gateway products can themselves be pointed at this gateway. An
|
|
@@ -133,13 +135,7 @@ export interface ClientRouterProbe {
|
|
|
133
135
|
function readHeader(probe: ClientRouterProbe, name: string): string | undefined {
|
|
134
136
|
const headers = probe.headers;
|
|
135
137
|
if (headers instanceof Headers) return headers.get(name) ?? undefined;
|
|
136
|
-
|
|
137
|
-
if (direct !== undefined) return direct;
|
|
138
|
-
const lowered = name.toLowerCase();
|
|
139
|
-
for (const [key, value] of Object.entries(headers)) {
|
|
140
|
-
if (key.toLowerCase() === lowered) return value;
|
|
141
|
-
}
|
|
142
|
-
return undefined;
|
|
138
|
+
return readRecordHeader(headers, name);
|
|
143
139
|
}
|
|
144
140
|
|
|
145
141
|
function signalMatches(signal: ClientRouterSignal, value: string): boolean {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { isIP } from "node:net";
|
|
4
4
|
import type { TrustedProxyBoundary } from "../config/network";
|
|
5
|
+
import { log } from "../observability/logger";
|
|
5
6
|
|
|
6
7
|
function ipv4ToNumber(address: string): number | undefined {
|
|
7
8
|
const parts = address.split(".");
|
|
@@ -319,9 +320,12 @@ function maybeWarnIgnoredForwardedHeaders(
|
|
|
319
320
|
if (collapseWarned) return;
|
|
320
321
|
if (!isSuspectedProxyCollapse(request, boundary, peer)) return;
|
|
321
322
|
collapseWarned = true;
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
console
|
|
323
|
+
// Via `log`, so this ships through the same transport as everything else and
|
|
324
|
+
// is level-filtered. It was `console.warn` to keep the module importable
|
|
325
|
+
// without the logger, but the logger pulls in only pino, the console log ring
|
|
326
|
+
// and its own sanitizer — none of which import this module — so there is no
|
|
327
|
+
// cycle and the boundary tests import it the same way production does.
|
|
328
|
+
log.warn(
|
|
325
329
|
`[ip-boundary] A forwarded client-address header arrived from a loopback peer ` +
|
|
326
330
|
`(${unwrapMappedIpv4(peer)}) but the trusted-proxy boundary does not trust it, so ` +
|
|
327
331
|
`the client address is the loopback peer for every caller. Every caller behind this ` +
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared value-coercion primitives for untyped upstream JSON.
|
|
3
|
+
*
|
|
4
|
+
* These used to exist as private copies in a dozen modules, spelled
|
|
5
|
+
* `record`/`asRecord`/`recordOf`/`isPlainObject` with two different nullish
|
|
6
|
+
* sentinels. One implementation, one sentinel (`undefined`), one place to fix.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Narrow an unknown value to a plain (non-array) object, else `undefined`. */
|
|
10
|
+
export function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
11
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
12
|
+
? (value as Record<string, unknown>)
|
|
13
|
+
: undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Narrow to a finite number, else `undefined`. `NaN`/`Infinity` are not numbers here. */
|
|
17
|
+
export function asNumber(value: unknown): number | undefined {
|
|
18
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Finite number accepting numeric strings (`"42"` → 42). Whitespace-only or
|
|
23
|
+
* non-finite results are `undefined`. This is the "upstream JSON may quote a
|
|
24
|
+
* number" contract; use `asNumber` when strings must stay rejected.
|
|
25
|
+
*/
|
|
26
|
+
export function asCoercedNumber(value: unknown): number | undefined {
|
|
27
|
+
if (typeof value === "number") return asNumber(value);
|
|
28
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
29
|
+
const parsed = Number(value);
|
|
30
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Trim a string-ish value; empty/whitespace-only becomes `undefined`. */
|
|
36
|
+
export function asTrimmedString(value: unknown): string | undefined {
|
|
37
|
+
if (typeof value !== "string") return undefined;
|
|
38
|
+
const trimmed = value.trim();
|
|
39
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** First non-empty trimmed string among `keys` of `record`. */
|
|
43
|
+
export function firstNonEmptyString(
|
|
44
|
+
record: Record<string, unknown>,
|
|
45
|
+
keys: readonly string[],
|
|
46
|
+
): string | undefined {
|
|
47
|
+
for (const key of keys) {
|
|
48
|
+
const value = asTrimmedString(record[key]);
|
|
49
|
+
if (value !== undefined) return value;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `JSON.parse` that yields `undefined` instead of throwing, for untyped
|
|
56
|
+
* upstream/console JSON that may be malformed. Non-string/empty input is
|
|
57
|
+
* `undefined` without attempting a parse.
|
|
58
|
+
*/
|
|
59
|
+
export function tryParseJson(value: string | null | undefined): unknown {
|
|
60
|
+
if (typeof value !== "string" || value.length === 0) return undefined;
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(value) as unknown;
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|