cartethyia 2.5.3 → 2.5.6
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 +13 -4
- package/README.md +2 -2
- package/dist/dashboard/assets/{BannedPage-hfCzi-Ux.js → BannedPage-CADDq4IU.js} +1 -1
- package/dist/dashboard/assets/BannedPage-CADDq4IU.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js +1 -0
- package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DEDyKBlC.js → CliToolsPage-DuO1Z7dq.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-DuO1Z7dq.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-DNEPi3xU.js +1 -0
- package/dist/dashboard/assets/CombosPage-DNEPi3xU.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-bsLuZmYH.js → ConsoleLogPage-BVdpfiz1.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-BVdpfiz1.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-_UWhwziJ.js → CustomizationPage-BR5HHvbi.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-BR5HHvbi.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-DDVIfZGW.js → LoginPage-V5faV9V2.js} +1 -1
- package/dist/dashboard/assets/LoginPage-V5faV9V2.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js +1 -0
- package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js +7 -0
- package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js +1 -0
- package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js +1 -0
- package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-D6pmQ86L.js → SetupPage-UB690SE2.js} +1 -1
- package/dist/dashboard/assets/SetupPage-UB690SE2.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-DWvKiuSl.js → SortableList-BLujdX7N.js} +1 -1
- package/dist/dashboard/assets/SortableList-BLujdX7N.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-CmkUrgdj.js → StudioPage-D5C2raAc.js} +3 -3
- package/dist/dashboard/assets/StudioPage-D5C2raAc.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-8uIcxa1W.js +56 -0
- package/dist/dashboard/assets/UsagePage-8uIcxa1W.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
- package/dist/dashboard/assets/{assert-NHwYq95t.js → assert-C9FD_JwJ.js} +1 -1
- package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
- package/dist/dashboard/assets/{badge-6uzDDvqs.js → badge-CR72dasT.js} +1 -1
- package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
- package/dist/dashboard/assets/{banner-4_WNg2WZ.js → banner-CI9_eSfO.js} +1 -1
- package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
- package/dist/dashboard/assets/{card--AtFedAq.js → card-DS7CQEsO.js} +1 -1
- package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-B3e7dx3N.js → cli-tools-CNqzrfdb.js} +1 -1
- package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-NreUx6c8.js → clipboard-button-611pSsaX.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
- package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
- package/dist/dashboard/assets/concurrency-BDF0ok7y.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-k37exjNt.js +1 -0
- package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
- package/dist/dashboard/assets/entry-B9Xvtnfj.js +1 -0
- package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
- package/dist/dashboard/assets/entry-DxBHnauy.js +4 -0
- package/dist/dashboard/assets/entry-DxBHnauy.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-D_eM3IgG.js → github-badge-sltk1qUi.js} +1 -1
- package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
- package/dist/dashboard/assets/{index-BydyKaDz.js → index-CmxZr14F.js} +2 -2
- package/dist/dashboard/assets/index-CmxZr14F.js.gz +0 -0
- package/dist/dashboard/assets/{inline-BcNGF8Gr.js → inline-BhY2sSnO.js} +1 -1
- package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
- package/dist/dashboard/assets/{input-BG41et9G.js → input-V1umFiBk.js} +1 -1
- package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-Gh_W4OR_.js → list-row-1zzZYM0B.js} +1 -1
- package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
- package/dist/dashboard/assets/{live-BJGYRBnR.js → live-BivGfuUs.js} +1 -1
- package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-CmffjLuG.js +1 -0
- package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-BkoLM5Ba.js → mutation-46P0oWMe.js} +1 -1
- package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
- package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
- package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-D7wX2vM5.js → query-keys-BFhGpwR1.js} +1 -1
- package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
- package/dist/dashboard/assets/{routing-CcusqXA7.js → routing-CAb4F37R.js} +1 -1
- package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
- package/dist/dashboard/assets/{select-BO6h8yNv.js → select-BYR_5K2c.js} +2 -2
- package/dist/dashboard/assets/select-BYR_5K2c.js.gz +0 -0
- package/dist/dashboard/assets/{settings-mpmXTgUa.js → settings-DNX9sydi.js} +1 -1
- package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
- package/dist/dashboard/assets/{stack-DwLmjUFK.js → stack-BHTfXdz_.js} +1 -1
- package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
- package/dist/dashboard/assets/{state-DYdQmdAl.js → state-mPDRlQkl.js} +1 -1
- package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
- package/dist/dashboard/assets/switch-DOmJxg7H.js +1 -0
- package/dist/dashboard/assets/switch-DOmJxg7H.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/tokens-COzario9.js +1 -0
- package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
- package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
- package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-w4u4CVue.js → useMutation-Dem0lXQj.js} +1 -1
- package/dist/dashboard/assets/useMutation-Dem0lXQj.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 +57 -57
- package/migrations/README.md +52 -52
- package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
- package/package.json +96 -95
- package/scripts/cli/admin.ts +125 -0
- package/scripts/cli/autostart.ts +237 -0
- package/scripts/cli/{ui/background.ts → background.ts} +1 -1
- package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -248
- package/scripts/cli/doctor.ts +37 -0
- package/scripts/cli/{support/env.ts → env.ts} +99 -99
- package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
- package/scripts/cli/instance.ts +224 -0
- package/scripts/cli/main.ts +88 -18
- package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
- package/scripts/cli/port.ts +100 -0
- package/scripts/cli/{support/services.ts → services.ts} +88 -88
- package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
- package/scripts/cli/{command/start.ts → start.ts} +10 -5
- package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
- package/scripts/cli/tray/index.ts +62 -12
- package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
- package/scripts/cli/update.ts +40 -0
- package/scripts/cli/version.ts +67 -0
- package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
- package/src/boot/app.ts +11 -7
- package/src/boot/dependencies.ts +50 -19
- package/src/boot/timeout.ts +2 -13
- package/src/config/cooldowns.ts +186 -138
- package/src/config/network.ts +8 -14
- package/src/config/providers.ts +7 -10
- package/src/config/runtime.ts +76 -76
- package/src/config/security.ts +15 -0
- package/src/config/spec.ts +52 -7
- package/src/config/telemetry.ts +26 -0
- package/src/config/timeouts.ts +0 -6
- package/src/console/admin/model-abuse/contracts.ts +28 -6
- package/src/console/admin/model-abuse/routes.ts +56 -3
- package/src/console/api-keys/bucket-spend.ts +15 -4
- package/src/console/api-keys/contracts.ts +410 -414
- package/src/console/api-keys/routes.ts +881 -880
- package/src/console/auth/credentials.ts +3 -3
- package/src/console/auth/setup-service.ts +2 -2
- package/src/console/backup/contracts.ts +15 -2
- package/src/console/backup/export-endpoint.ts +6 -2
- package/src/console/backup/nine-router.ts +2 -5
- package/src/console/backup/service.ts +4 -0
- package/src/console/backup/store.ts +192 -29
- package/src/console/backup/validate.ts +3 -45
- package/src/console/cli-tools/fs-ops.ts +487 -487
- package/src/console/console-router-guards.ts +1 -1
- package/src/console/console-router.ts +8 -0
- package/src/console/data-management/contracts.ts +100 -0
- package/src/console/data-management/operations.ts +234 -0
- package/src/console/data-management/routes.ts +103 -0
- package/src/console/domain-registration.ts +45 -1
- package/src/console/observability/contracts.ts +524 -485
- package/src/console/observability/live.ts +22 -0
- package/src/console/observability/store.ts +1138 -1018
- package/src/console/observability/usage-dimensions.ts +4 -4
- package/src/console/providers/catalog/catalog-projections.ts +6 -7
- package/src/console/providers/catalog/contracts.ts +6 -0
- package/src/console/providers/catalog/model-operations.ts +5 -2
- package/src/console/providers/catalog/provider-operations.ts +24 -4
- package/src/console/providers/catalog/public-model-store.ts +8 -11
- package/src/console/providers/catalog/routes.ts +7 -0
- package/src/console/providers/catalog/store.ts +1553 -1532
- package/src/console/providers/oauth/routes.ts +8 -12
- package/src/console/quota/account-quota-shared.ts +52 -39
- package/src/console/quota/account-quota-tenant.ts +6 -6
- package/src/console/quota/quota-view.ts +1 -1
- package/src/console/quota/refresh.ts +441 -429
- package/src/console/remote-support/contracts.ts +61 -0
- package/src/console/remote-support/routes.ts +65 -0
- package/src/console/remote-support/service.ts +207 -0
- package/src/console/response-compression.ts +0 -3
- package/src/console/routing/pools/import-parse.ts +206 -0
- package/src/console/routing/pools/operations.ts +543 -501
- package/src/console/routing/pools/probe-result.ts +3 -2
- package/src/console/routing/pools/probe-verdict.ts +132 -132
- package/src/console/routing/pools/routes.ts +181 -164
- package/src/console/routing/pools/store.ts +8 -8
- package/src/console/settings/contracts.ts +95 -2
- package/src/console/settings/store.ts +81 -3
- package/src/console/shared/cursor.ts +8 -8
- package/src/console/shared/errors.ts +1 -3
- package/src/console/updates/routes.ts +16 -0
- package/src/main.ts +10 -0
- package/src/network/http2-bidi.ts +2 -4
- package/src/network/pool/agent.ts +478 -478
- package/src/network/pool/selector.ts +5 -4
- package/src/network/retry.ts +1 -2
- package/src/network/ssrf.ts +5 -4
- package/src/observability/event-compaction.ts +317 -0
- package/src/observability/event-day-index.ts +440 -0
- package/src/observability/event-log.ts +155 -6
- package/src/observability/logger.ts +6 -6
- package/src/observability/payload-capture.ts +158 -33
- package/src/observability/payload-store.ts +82 -61
- package/src/observability/redaction.ts +5 -6
- package/src/observability/request-notify.ts +43 -0
- package/src/observability/telemetry-status.ts +8 -9
- package/src/observability/token-speed.ts +5 -4
- package/src/persistence/db-mode.ts +7 -7
- package/src/persistence/health-event-flush.ts +36 -0
- package/src/persistence/readiness.ts +1 -1
- package/src/persistence/schema.ts +75 -29
- package/src/persistence/shared-cache.ts +1 -2
- package/src/persistence/stores/api-key-store.ts +18 -6
- package/src/persistence/stores/share-store.ts +4 -3
- package/src/persistence/stores/telemetry-store.ts +33 -11
- package/src/protocol/canonical-model.ts +11 -3
- package/src/protocol/codecs.ts +139 -6
- package/src/protocol/gateway-error.ts +6 -4
- package/src/protocol/image-clamp.ts +19 -1
- package/src/protocol/registry.ts +8 -0
- package/src/protocol/request/chat.ts +21 -1
- package/src/protocol/request/codex.ts +10 -1
- package/src/protocol/request/gemini.ts +9 -2
- package/src/protocol/request/messages.ts +18 -7
- package/src/protocol/request/responses.ts +9 -1
- package/src/protocol/response/chat.ts +34 -4
- package/src/protocol/response/codex.ts +7 -4
- package/src/protocol/response/dsml.ts +140 -28
- package/src/protocol/response/responses.ts +11 -5
- package/src/protocol/stream-error-frames.ts +4 -5
- package/src/providers/adapters/compatible-adapter.ts +3 -0
- package/src/providers/authentication/jwt-validator.ts +9 -10
- package/src/providers/authentication/oauth-refresh-service.ts +9 -11
- package/src/providers/bundled-discovery-providers.ts +122 -119
- package/src/providers/configured-openai-providers.ts +4 -4
- package/src/providers/default-registry.ts +5 -4
- package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
- package/src/providers/discovery/catalog/catalog-format.ts +3 -6
- package/src/providers/discovery/models-dev-catalog.ts +354 -354
- package/src/providers/discovery/openai-model-discovery.ts +5 -0
- package/src/providers/discovery/openrouter-free-roster.ts +15 -17
- package/src/providers/discovery/probe-phases.ts +4 -4
- package/src/providers/discovery/probe-wire.ts +6 -13
- package/src/providers/discovery/probing-service.ts +1117 -1045
- package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
- package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
- package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
- package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
- package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
- package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
- package/src/providers/integrations/buddy/buddy-media.ts +2 -2
- package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
- package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
- package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
- package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
- package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
- package/src/providers/integrations/claude/claude.ts +608 -608
- package/src/providers/integrations/cline/cline-quota.ts +27 -20
- package/src/providers/integrations/cline/cline.ts +537 -539
- package/src/providers/integrations/codex/codex-headers.ts +25 -39
- package/src/providers/integrations/codex/codex-oauth.ts +1 -1
- package/src/providers/integrations/codex/codex.ts +2 -29
- package/src/providers/integrations/commandcode/commandcode.ts +674 -675
- package/src/providers/integrations/commandcode/discovery.ts +117 -116
- package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
- package/src/providers/integrations/cursor/cursor-quota.ts +309 -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 +1333 -960
- package/src/providers/integrations/cursor/protocol/messages.ts +25 -0
- package/src/providers/integrations/factory/factory-models.ts +3 -5
- package/src/providers/integrations/factory/factory.ts +8 -1
- package/src/providers/integrations/gemini.ts +6 -1
- package/src/providers/integrations/grok/grok-install-id.ts +2 -3
- 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/grok/grok.ts +6 -7
- package/src/providers/integrations/hermes.ts +14 -18
- package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
- package/src/providers/integrations/kiro/kiro.ts +1 -1
- package/src/providers/integrations/mistral.ts +6 -6
- package/src/providers/integrations/opencode/fingerprint.ts +3 -3
- package/src/providers/integrations/opencode/provider.ts +19 -20
- package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
- package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
- package/src/providers/integrations/qoder/qoder-quota.ts +192 -0
- package/src/providers/integrations/qoder/qoder.ts +884 -865
- package/src/providers/integrations/shared/connect.ts +6 -6
- package/src/providers/integrations/trae/trae-catalog.ts +1 -2
- package/src/providers/integrations/trae/trae-quota.ts +384 -0
- package/src/providers/integrations/trae/trae.ts +2 -2
- package/src/providers/integrations/zcode/oauth.ts +6 -1
- package/src/providers/operations/account-health-service.ts +75 -27
- package/src/providers/operations/account-reset-service.ts +1 -1
- package/src/providers/operations/client-versions.ts +6 -6
- package/src/providers/operations/custom-cli-headers.ts +3 -4
- package/src/providers/operations/provider-catalog-seeder.ts +95 -95
- package/src/providers/operations/provider-catalog-service.ts +3 -4
- package/src/providers/provider-contracts.ts +7 -0
- package/src/providers/provider-metadata.ts +10 -8
- package/src/providers/provider-registry.ts +6 -7
- package/src/providers/provider-usage.ts +1 -1
- package/src/providers/quota/quota-support.ts +8 -0
- package/src/security/abuse.ts +15 -10
- package/src/security/access-control.ts +144 -146
- package/src/security/admission/contracts.ts +11 -0
- package/src/security/admission/in-memory-store.ts +4 -3
- package/src/security/admission/lease-sweep.ts +6 -6
- package/src/security/admission/reasons.ts +5 -1
- package/src/security/admission/redis-store.ts +211 -210
- package/src/security/admission/service.ts +4 -0
- package/src/security/api-key-auth.ts +27 -17
- package/src/security/ip-boundary.ts +155 -15
- package/src/security/model-abuse.ts +366 -94
- package/src/security/outbound-headers.ts +3 -4
- package/src/shared/sleep.ts +4 -0
- package/src/transport/dispatch/attempt-finalize.ts +47 -5
- package/src/transport/dispatch/attempt-loop.ts +531 -530
- package/src/transport/dispatch/generation.ts +6 -0
- package/src/transport/dispatch/proxy-request.ts +24 -1
- package/src/transport/dispatch/ratelimit-capture.ts +37 -0
- package/src/transport/dispatch/stream-bridge.ts +1 -3
- package/src/transport/dispatch/streaming/finalize.ts +1 -1
- package/src/transport/dispatch/streaming/stream.ts +19 -6
- package/src/transport/dispatch/upstream.ts +9 -0
- package/src/transport/failure-policy.ts +162 -8
- package/src/transport/guards/auth-guard.ts +7 -4
- package/src/transport/guards/concurrency-guard.ts +0 -5
- package/src/transport/guards/error-lifecycle.ts +13 -6
- package/src/transport/guards/pipeline.ts +15 -3
- package/src/transport/guards/request-context.ts +8 -2
- package/src/transport/request/inflight.ts +5 -6
- package/src/transport/request/preparer.ts +9 -8
- package/src/transport/request/state.ts +21 -8
- package/src/transport/request/tenant-preferences.ts +60 -0
- package/src/transport/routing/admission.ts +4 -0
- package/src/transport/routing/route-catalog.ts +65 -10
- package/src/transport/routing/route-model.ts +46 -14
- package/src/transport/routing/router.ts +21 -14
- package/src/transport/surface/completion.ts +2 -3
- package/src/transport/surface/dialects.ts +5 -6
- package/src/transport/surface/responses/encode.ts +38 -0
- package/src/transport/surface/responses/parse.ts +4 -0
- package/src/transport/translation/capabilities.ts +2 -3
- package/src/transport/translation/quirks.ts +3 -3
- package/src/workers/buddy-growth-tasks.ts +5 -10
- package/src/workers/daily-checkin.ts +17 -23
- package/src/workers/quota-refresh-sweep.ts +26 -26
- package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js +0 -1
- package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js +0 -1
- package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js +0 -1
- package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-B53YYMng.js +0 -1
- package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js +0 -7
- package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BpOQCnun.js +0 -1
- package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +0 -1
- package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-B47uOydr.js +0 -56
- package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-uNywYlfv.js +0 -1
- 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 +0 -1
- 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 +0 -1
- package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
- package/dist/dashboard/assets/entry-CkryrIea.js +0 -4
- package/dist/dashboard/assets/entry-CkryrIea.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/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
- package/dist/dashboard/assets/index-BydyKaDz.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 +0 -1
- 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/query-keys-D7wX2vM5.js.gz +0 -0
- package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
- package/dist/dashboard/assets/select-BO6h8yNv.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/tokens-DDxMbSRL.js +0 -1
- package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
- package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
- package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-w4u4CVue.js.gz +0 -0
- package/scripts/cli/command/admin.ts +0 -65
- package/scripts/cli/command/doctor.ts +0 -257
- package/scripts/cli/command/update.ts +0 -23
- package/scripts/cli/gateway/drain-token.ts +0 -78
- package/scripts/cli/gateway/instance-lock.ts +0 -105
- package/scripts/cli/gateway/stop.ts +0 -51
- package/scripts/cli/support/bun.ts +0 -27
- package/scripts/cli/support/version.ts +0 -37
- /package/dist/dashboard/assets/{app-CafWtVXA.js → app-BIt446yE.js} +0 -0
- /package/dist/dashboard/assets/{app-CafWtVXA.js.gz → app-BIt446yE.js.gz} +0 -0
- /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
|
@@ -1,880 +1,881 @@
|
|
|
1
|
-
// Console API-key domain: HTTP routes.
|
|
2
|
-
//
|
|
3
|
-
// Owns validation, CRUD operations, and route wiring through the ApiKeyStore.
|
|
4
|
-
|
|
5
|
-
import { Elysia, t } from "elysia";
|
|
6
|
-
import { randomBytes, randomUUID } from "node:crypto";
|
|
7
|
-
import { decryptCredentialToString, encryptCredential } from "../../security/crypto";
|
|
8
|
-
import { invalidateApiKeyCache } from "../../security/api-key-auth";
|
|
9
|
-
import { generateShareSlug, hashShareToken } from "../../persistence/stores/share-store";
|
|
10
|
-
import type { AccessDecision } from "../../security/access-control";
|
|
11
|
-
import { ConsoleDomainError, errorResponse, requireTenantScope } from "../shared/errors";
|
|
12
|
-
import { literalUnion } from "../shared/elysia-schema";
|
|
13
|
-
import { API_KEY_MODES, API_KEY_MODEL_ACCESS_MODES, type ShareLinkKind } from "../../persistence/schema";
|
|
14
|
-
import type { ApiKeyConfig, ApiKeyResponse, UpdateApiKeyResponse } from "./contracts";
|
|
15
|
-
import type { ApiKeyPatch, ApiKeyRecord } from "../../persistence/stores/api-key-store";
|
|
16
|
-
import {
|
|
17
|
-
generateApiKeySecret,
|
|
18
|
-
isDefaultGatewayApiKey,
|
|
19
|
-
parseSharePopupImage,
|
|
20
|
-
sanitizeApiKeyResponse,
|
|
21
|
-
validateApiKeyRequest,
|
|
22
|
-
parseRequest,
|
|
23
|
-
finitePositive,
|
|
24
|
-
type CreateApiKeyRequest,
|
|
25
|
-
type CreateApiKeyResponse,
|
|
26
|
-
resolveKeyPrefix,
|
|
27
|
-
prepareCustomKey,
|
|
28
|
-
mapShareLinkResponse,
|
|
29
|
-
type ShareKeyResponse,
|
|
30
|
-
type ShareLinkResponse,
|
|
31
|
-
resolveShareKeyPrefix,
|
|
32
|
-
} from "./contracts";
|
|
33
|
-
import type { SharedKeyActivityDetail, SharedKeySummary } from "../share/share-usage";
|
|
34
|
-
import { popupImageBytes } from "./share-popup-image";
|
|
35
|
-
|
|
36
|
-
export function createApiKeyOperations(config: ApiKeyConfig) {
|
|
37
|
-
const operations = {
|
|
38
|
-
async listKeys(access: AccessDecision | undefined): Promise<ApiKeyResponse[]> {
|
|
39
|
-
const authorized = requireTenantScope(access, "dashboard:read");
|
|
40
|
-
const records = await config.store.list(authorized.tenantId);
|
|
41
|
-
const topLevel = records.filter(
|
|
42
|
-
(record) => record.revokedAt === undefined && record.parentKeyId === undefined,
|
|
43
|
-
);
|
|
44
|
-
// A share template's own counter never moves for child traffic, so
|
|
45
|
-
// reporting it raw would show a template as untouched while the gateway
|
|
46
|
-
// is refusing its recipients on the parent budget. Report the figure the
|
|
47
|
-
// gateway actually enforces: the template plus every recipient it has
|
|
48
|
-
// issued, revoked ones included (their spend is already spent).
|
|
49
|
-
const sumChildren = config.store.sumChildrenConsumed?.bind(config.store);
|
|
50
|
-
return Promise.all(
|
|
51
|
-
topLevel.map(async (record) => {
|
|
52
|
-
const response = sanitizeApiKeyResponse(record);
|
|
53
|
-
if (record.keyMode !== "share" || !sumChildren) return response;
|
|
54
|
-
const children = await sumChildren(record.id).catch(() => 0);
|
|
55
|
-
return children > 0
|
|
56
|
-
? { ...response, tokensConsumed: response.tokensConsumed + children }
|
|
57
|
-
: response;
|
|
58
|
-
}),
|
|
59
|
-
);
|
|
60
|
-
},
|
|
61
|
-
/** Persists a new credential order. `keyIds` must name every top-level key
|
|
62
|
-
the tenant has, so a partial list cannot silently renumber rows. */
|
|
63
|
-
async reorderKeys(access: AccessDecision | undefined, keyIds: readonly string[]): Promise<void> {
|
|
64
|
-
const authorized = requireTenantScope(access, "dashboard:write");
|
|
65
|
-
const records = await config.store.list(authorized.tenantId);
|
|
66
|
-
const reorderable = records.filter(
|
|
67
|
-
(record) => record.revokedAt === undefined && record.parentKeyId === undefined,
|
|
68
|
-
);
|
|
69
|
-
const known = new Set(reorderable.map((record) => record.id));
|
|
70
|
-
const seen = new Set<string>();
|
|
71
|
-
for (const keyId of keyIds) {
|
|
72
|
-
if (!known.has(keyId))
|
|
73
|
-
throw new ConsoleDomainError("key_not_found", 404, `Key not found: ${keyId}`);
|
|
74
|
-
if (seen.has(keyId))
|
|
75
|
-
throw new ConsoleDomainError("invalid_request", 422, `Duplicate key id: ${keyId}`);
|
|
76
|
-
seen.add(keyId);
|
|
77
|
-
}
|
|
78
|
-
if (seen.size !== known.size)
|
|
79
|
-
throw new ConsoleDomainError(
|
|
80
|
-
"invalid_request",
|
|
81
|
-
422,
|
|
82
|
-
"Reorder must list every key exactly once",
|
|
83
|
-
);
|
|
84
|
-
await config.store.reorder(authorized.tenantId, keyIds);
|
|
85
|
-
},
|
|
86
|
-
async getKeyDetail(access: AccessDecision | undefined, keyId: string): Promise<ApiKeyResponse> {
|
|
87
|
-
const authorized = requireTenantScope(access, "dashboard:read");
|
|
88
|
-
const record = await config.store.get(authorized.tenantId, keyId);
|
|
89
|
-
if (!record) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
|
|
90
|
-
return sanitizeApiKeyResponse(record);
|
|
91
|
-
},
|
|
92
|
-
async getSharePopupImage(
|
|
93
|
-
access: AccessDecision | undefined,
|
|
94
|
-
keyId: string,
|
|
95
|
-
): Promise<{ mime: string; image: Buffer }> {
|
|
96
|
-
const authorized = requireTenantScope(access, "dashboard:read");
|
|
97
|
-
const record = await config.store.get(authorized.tenantId, keyId);
|
|
98
|
-
if (!record) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
|
|
99
|
-
if (record.sharePopupImage === undefined || record.sharePopupImageMime === undefined)
|
|
100
|
-
throw new ConsoleDomainError("key_not_found", 404, "This key has no popup image");
|
|
101
|
-
return { mime: record.sharePopupImageMime, image: record.sharePopupImage };
|
|
102
|
-
},
|
|
103
|
-
async createKey(
|
|
104
|
-
access: AccessDecision | undefined,
|
|
105
|
-
request: CreateApiKeyRequest,
|
|
106
|
-
): Promise<CreateApiKeyResponse> {
|
|
107
|
-
const authorized = requireTenantScope(access, "dashboard:write");
|
|
108
|
-
const scopes = validateApiKeyRequest(request);
|
|
109
|
-
const popupImage = parseSharePopupImage(request.sharePopupImage);
|
|
110
|
-
const keyMode = request.keyMode ?? "personal";
|
|
111
|
-
const keyPrefix =
|
|
112
|
-
keyMode === "share"
|
|
113
|
-
? resolveShareKeyPrefix(request.keyPrefix)
|
|
114
|
-
: resolveKeyPrefix(request.keyPrefix);
|
|
115
|
-
const generated =
|
|
116
|
-
keyMode === "personal"
|
|
117
|
-
? request.key === undefined
|
|
118
|
-
? generateApiKeySecret(keyPrefix)
|
|
119
|
-
: { ...prepareCustomKey(request.key), prefix: keyPrefix }
|
|
120
|
-
: undefined;
|
|
121
|
-
const record: ApiKeyRecord = {
|
|
122
|
-
id: randomUUID(),
|
|
123
|
-
tenantId: authorized.tenantId,
|
|
124
|
-
enabled: request.enabled ?? true,
|
|
125
|
-
label: request.label?.trim() || (keyMode === "share" ? "Share template" : "API key"),
|
|
126
|
-
keyHash: generated?.hash ?? null,
|
|
127
|
-
keyMode,
|
|
128
|
-
scopes,
|
|
129
|
-
// Only first boot seeds the default; a key created from the console is
|
|
130
|
-
// never it, so it stays renamable and revocable.
|
|
131
|
-
isDefault: false,
|
|
132
|
-
keyPrefix: generated?.prefix ?? keyPrefix,
|
|
133
|
-
...(generated ? { keyEncrypted: encryptCredential(generated.secret) } : {}),
|
|
134
|
-
...(request.notesTitle === undefined ? {} : { notesTitle: request.notesTitle }),
|
|
135
|
-
...(request.notesSubtitle === undefined ? {} : { notesSubtitle: request.notesSubtitle }),
|
|
136
|
-
...(request.notesBody === undefined ? {} : { notesBody: request.notesBody }),
|
|
137
|
-
...(request.sharePopupEnabled == null ? {} : { sharePopupEnabled: request.sharePopupEnabled }),
|
|
138
|
-
...(popupImage
|
|
139
|
-
? { sharePopupImage: popupImage.image, sharePopupImageMime: popupImage.mime }
|
|
140
|
-
: {}),
|
|
141
|
-
...(request.sharePopupTitle?.trim() ? { sharePopupTitle: request.sharePopupTitle.trim() } : {}),
|
|
142
|
-
...(request.sharePopupBody?.trim() ? { sharePopupBody: request.sharePopupBody.trim() } : {}),
|
|
143
|
-
...(request.requestsPerMinute == null ? {} : { requestsPerMinute: request.requestsPerMinute }),
|
|
144
|
-
...(request.dailyTokenLimit == null ? {} : { dailyTokenLimit: request.dailyTokenLimit }),
|
|
145
|
-
...(request.monthlyTokenLimit == null ? {} : { monthlyTokenLimit: request.monthlyTokenLimit }),
|
|
146
|
-
...(request.lifetimeTokenBudget == null ? {} : { lifetimeTokenBudget: request.lifetimeTokenBudget }),
|
|
147
|
-
...(request.maxConcurrentRequests == null ? {} : { maxConcurrentRequests: request.maxConcurrentRequests }),
|
|
148
|
-
...(request.modelPrefix === undefined ? {} : { modelPrefix: request.modelPrefix }),
|
|
149
|
-
modelAccessMode: request.modelAccessMode ?? "whitelist",
|
|
150
|
-
...(request.modelList === undefined ? {} : { modelList: request.modelList }),
|
|
151
|
-
...(request.clientRouterDenylist === undefined
|
|
152
|
-
? {}
|
|
153
|
-
: { clientRouterDenylist: request.clientRouterDenylist }),
|
|
154
|
-
createdAt: new Date(),
|
|
155
|
-
tokensConsumed: 0,
|
|
156
|
-
};
|
|
157
|
-
await config.store.create(record);
|
|
158
|
-
await config.auditSink?.record({
|
|
159
|
-
access: authorized,
|
|
160
|
-
action: "api_key.created",
|
|
161
|
-
target: record.id,
|
|
162
|
-
detail: { scopes, keyMode },
|
|
163
|
-
});
|
|
164
|
-
return {
|
|
165
|
-
...sanitizeApiKeyResponse(record),
|
|
166
|
-
...(generated ? { secret: generated.secret } : {}),
|
|
167
|
-
};
|
|
168
|
-
},
|
|
169
|
-
async updateKey(
|
|
170
|
-
access: AccessDecision | undefined,
|
|
171
|
-
keyId: string,
|
|
172
|
-
request: Partial<CreateApiKeyRequest>,
|
|
173
|
-
): Promise<UpdateApiKeyResponse> {
|
|
174
|
-
const authorized = requireTenantScope(access, "dashboard:write");
|
|
175
|
-
const patchRequest = parseRequest(request);
|
|
176
|
-
const current = await config.store.get(authorized.tenantId, keyId);
|
|
177
|
-
if (!current || current.revokedAt !== undefined)
|
|
178
|
-
throw new ConsoleDomainError("key_not_found", 404, "Key not found");
|
|
179
|
-
if (current.parentKeyId !== undefined)
|
|
180
|
-
throw new ConsoleDomainError(
|
|
181
|
-
"shared_key_managed_by_parent",
|
|
182
|
-
409,
|
|
183
|
-
"Edit or revoke this key through its share parent",
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
validateApiKeyRequest(patchRequest);
|
|
187
|
-
finitePositive(patchRequest.requestsPerMinute, "requestsPerMinute");
|
|
188
|
-
finitePositive(patchRequest.dailyTokenLimit, "dailyTokenLimit");
|
|
189
|
-
finitePositive(patchRequest.monthlyTokenLimit, "monthlyTokenLimit");
|
|
190
|
-
finitePositive(patchRequest.lifetimeTokenBudget, "lifetimeTokenBudget");
|
|
191
|
-
finitePositive(patchRequest.maxConcurrentRequests, "maxConcurrentRequests");
|
|
192
|
-
|
|
193
|
-
const nextMode = patchRequest.keyMode ?? current.keyMode;
|
|
194
|
-
const modeChanged = nextMode !== current.keyMode;
|
|
195
|
-
const nextPrefix =
|
|
196
|
-
nextMode === "share"
|
|
197
|
-
? resolveShareKeyPrefix(
|
|
198
|
-
patchRequest.keyPrefix ?? (modeChanged ? undefined : current.keyPrefix),
|
|
199
|
-
)
|
|
200
|
-
: resolveKeyPrefix(patchRequest.keyPrefix ?? current.keyPrefix);
|
|
201
|
-
const prefixChanged =
|
|
202
|
-
patchRequest.keyPrefix !== undefined &&
|
|
203
|
-
nextPrefix !== resolveKeyPrefix(current.keyPrefix);
|
|
204
|
-
|
|
205
|
-
let secret: string | undefined;
|
|
206
|
-
let credentialPatch: ApiKeyPatch = {};
|
|
207
|
-
if (modeChanged && nextMode === "share") {
|
|
208
|
-
credentialPatch = {
|
|
209
|
-
keyMode: "share",
|
|
210
|
-
keyHash: null,
|
|
211
|
-
keyEncrypted: null,
|
|
212
|
-
keyPrefix: nextPrefix,
|
|
213
|
-
};
|
|
214
|
-
} else if (
|
|
215
|
-
nextMode === "personal" &&
|
|
216
|
-
(modeChanged || patchRequest.key !== undefined || prefixChanged)
|
|
217
|
-
) {
|
|
218
|
-
const generated = patchRequest.key === undefined
|
|
219
|
-
? generateApiKeySecret(
|
|
220
|
-
patchRequest.keyPrefix === undefined
|
|
221
|
-
? undefined
|
|
222
|
-
: resolveKeyPrefix(patchRequest.keyPrefix),
|
|
223
|
-
)
|
|
224
|
-
: {
|
|
225
|
-
...prepareCustomKey(patchRequest.key),
|
|
226
|
-
prefix: resolveKeyPrefix(patchRequest.keyPrefix ?? current.keyPrefix),
|
|
227
|
-
};
|
|
228
|
-
secret = generated.secret;
|
|
229
|
-
credentialPatch = {
|
|
230
|
-
...(modeChanged ? { keyMode: "personal" as const } : {}),
|
|
231
|
-
keyHash: generated.hash,
|
|
232
|
-
keyEncrypted: encryptCredential(generated.secret),
|
|
233
|
-
keyPrefix: generated.prefix,
|
|
234
|
-
};
|
|
235
|
-
} else if (nextMode === "share" && nextPrefix !== current.keyPrefix) {
|
|
236
|
-
credentialPatch = { keyPrefix: nextPrefix };
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const scopes =
|
|
240
|
-
patchRequest.scopes === undefined ? undefined : validateApiKeyRequest(patchRequest);
|
|
241
|
-
const popupImage =
|
|
242
|
-
patchRequest.sharePopupImage === undefined
|
|
243
|
-
? undefined
|
|
244
|
-
: parseSharePopupImage(patchRequest.sharePopupImage);
|
|
245
|
-
const updated = await config.store.update(authorized.tenantId, keyId, {
|
|
246
|
-
...(patchRequest.enabled === undefined ? {} : { enabled: patchRequest.enabled }),
|
|
247
|
-
...(isDefaultGatewayApiKey(current) ? { isDefault: true } : {}),
|
|
248
|
-
...credentialPatch,
|
|
249
|
-
...(scopes === undefined ? {} : { scopes }),
|
|
250
|
-
// Rename. The label
|
|
251
|
-
//
|
|
252
|
-
// the
|
|
253
|
-
// rather than wiping it, since `label` is NOT NULL. The
|
|
254
|
-
// renameable like any other (c2a93545); gating on
|
|
255
|
-
// exactly the one patch that changes a name
|
|
256
|
-
// empty `set(...)` that
|
|
257
|
-
...(patchRequest.label === undefined
|
|
258
|
-
? {}
|
|
259
|
-
: { label: patchRequest.label.trim() === "" ? current.label : patchRequest.label.trim() }),
|
|
260
|
-
...(patchRequest.notesTitle === undefined
|
|
261
|
-
? {}
|
|
262
|
-
: { notesTitle: patchRequest.notesTitle.trim() === "" ? null : patchRequest.notesTitle }),
|
|
263
|
-
...(patchRequest.notesSubtitle === undefined
|
|
264
|
-
? {}
|
|
265
|
-
: { notesSubtitle: patchRequest.notesSubtitle.trim() === "" ? null : patchRequest.notesSubtitle }),
|
|
266
|
-
...(patchRequest.notesBody === undefined
|
|
267
|
-
? {}
|
|
268
|
-
: { notesBody: patchRequest.notesBody.trim() === "" ? null : patchRequest.notesBody }),
|
|
269
|
-
...(patchRequest.sharePopupEnabled === undefined ? {} : { sharePopupEnabled: patchRequest.sharePopupEnabled }),
|
|
270
|
-
...(popupImage === undefined
|
|
271
|
-
? {}
|
|
272
|
-
: popupImage === null
|
|
273
|
-
? { sharePopupImage: null, sharePopupImageMime: null }
|
|
274
|
-
: { sharePopupImage: popupImage.image, sharePopupImageMime: popupImage.mime }),
|
|
275
|
-
...(patchRequest.sharePopupTitle === undefined ? {} : { sharePopupTitle: patchRequest.sharePopupTitle.trim() || null }),
|
|
276
|
-
...(patchRequest.sharePopupBody === undefined ? {} : { sharePopupBody: patchRequest.sharePopupBody.trim() || null }),
|
|
277
|
-
...(patchRequest.requestsPerMinute === undefined ? {} : { requestsPerMinute: patchRequest.requestsPerMinute }),
|
|
278
|
-
...(patchRequest.dailyTokenLimit === undefined ? {} : { dailyTokenLimit: patchRequest.dailyTokenLimit }),
|
|
279
|
-
...(patchRequest.monthlyTokenLimit === undefined ? {} : { monthlyTokenLimit: patchRequest.monthlyTokenLimit }),
|
|
280
|
-
...(patchRequest.lifetimeTokenBudget === undefined ? {} : { lifetimeTokenBudget: patchRequest.lifetimeTokenBudget }),
|
|
281
|
-
...(patchRequest.maxConcurrentRequests === undefined ? {} : { maxConcurrentRequests: patchRequest.maxConcurrentRequests }),
|
|
282
|
-
...(patchRequest.modelPrefix === undefined ? {} : { modelPrefix: patchRequest.modelPrefix }),
|
|
283
|
-
...(patchRequest.modelAccessMode === undefined ? {} : { modelAccessMode: patchRequest.modelAccessMode }),
|
|
284
|
-
...(patchRequest.modelList === undefined ? {} : { modelList: patchRequest.modelList }),
|
|
285
|
-
...(patchRequest.clientRouterDenylist === undefined
|
|
286
|
-
? {}
|
|
287
|
-
: { clientRouterDenylist: patchRequest.clientRouterDenylist }),
|
|
288
|
-
});
|
|
289
|
-
if (!updated) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
|
|
290
|
-
const children = await config.store.listChildren(authorized.tenantId, keyId);
|
|
291
|
-
// Parent policy is live-inherited by children at auth time, so their
|
|
292
|
-
// cached snapshots must die with the parent — otherwise a denylist /
|
|
293
|
-
// budget edit looks like a no-op for up to AUTH_CACHE_TTL_MS per child.
|
|
294
|
-
invalidateApiKeyCache(keyId);
|
|
295
|
-
for (const child of children) invalidateApiKeyCache(child.id);
|
|
296
|
-
|
|
297
|
-
// Denylist / allowlist / scopes are auth-snapshot fields: cache
|
|
298
|
-
// invalidation above is enough. Only quota/concurrency edits need an
|
|
299
|
-
// admission purge — otherwise changing a client denylist would also wipe
|
|
300
|
-
// RPM / daily counters for every recipient mid-day.
|
|
301
|
-
//
|
|
302
|
-
// And only a *tightened* limit needs it. The dashboard edit form always
|
|
303
|
-
// sends all five limit fields, so a `!== undefined` test treated every
|
|
304
|
-
// save — renaming a key, editing notes — as a limit change and dropped
|
|
305
|
-
// the whole family's daily/monthly counters, restarting the budget
|
|
306
|
-
// mid-bucket. Loosening a limit cannot be bypassed by a stale counter
|
|
307
|
-
// (the counter only ever grows toward the new ceiling), so it needs no
|
|
308
|
-
// purge either; only a lowered or newly-added ceiling does.
|
|
309
|
-
const tightened = (
|
|
310
|
-
next: number | null | undefined,
|
|
311
|
-
previous: number | null | undefined,
|
|
312
|
-
): boolean => {
|
|
313
|
-
if (next === undefined || next === null) return false;
|
|
314
|
-
if (previous === null || previous === undefined) return true;
|
|
315
|
-
return next < previous;
|
|
316
|
-
};
|
|
317
|
-
/** A ceiling that did not exist before this request. */
|
|
318
|
-
const isNewLimit = (
|
|
319
|
-
next: number | null | undefined,
|
|
320
|
-
previous: number | null | undefined,
|
|
321
|
-
): boolean => next !== undefined && next !== null && (previous === null || previous === undefined);
|
|
322
|
-
const limitsChanged =
|
|
323
|
-
modeChanged ||
|
|
324
|
-
secret !== undefined ||
|
|
325
|
-
tightened(patchRequest.requestsPerMinute, current.requestsPerMinute) ||
|
|
326
|
-
tightened(patchRequest.dailyTokenLimit, current.dailyTokenLimit) ||
|
|
327
|
-
tightened(patchRequest.monthlyTokenLimit, current.monthlyTokenLimit) ||
|
|
328
|
-
tightened(patchRequest.lifetimeTokenBudget, current.lifetimeTokenBudget) ||
|
|
329
|
-
tightened(patchRequest.maxConcurrentRequests, current.maxConcurrentRequests);
|
|
330
|
-
if (limitsChanged) {
|
|
331
|
-
// Drop stale admission counters so a newly lowered one-time/recurring
|
|
332
|
-
// budget cannot be bypassed by a counter seeded under the
|
|
333
|
-
// Share children admit under the parent id, so purge the parent too.
|
|
334
|
-
await Promise.all([
|
|
335
|
-
config.admissionService.purgeKey(keyId),
|
|
336
|
-
...children.map((child) => config.admissionService.purgeKey(child.id)),
|
|
337
|
-
]);
|
|
338
|
-
}
|
|
339
|
-
// Adding a daily/monthly ceiling to a key that already spent in the
|
|
340
|
-
// current bucket must not hand it a full fresh budget: seed the counter
|
|
341
|
-
// from the spend already recorded, so the limit covers the whole bucket.
|
|
342
|
-
// Only a *newly added* ceiling needs this — a lowered one was just
|
|
343
|
-
// purged above, and re-seeding there would restore the
|
|
344
|
-
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
...(
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
const
|
|
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
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
token
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
token
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
*
|
|
482
|
-
* link
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
// slug
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
token
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
token
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
*
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
const
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
// `
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
})
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
*
|
|
728
|
-
*
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
"
|
|
772
|
-
"
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
params.
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
params.
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
...(input.
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
}
|
|
1
|
+
// Console API-key domain: HTTP routes.
|
|
2
|
+
//
|
|
3
|
+
// Owns validation, CRUD operations, and route wiring through the ApiKeyStore.
|
|
4
|
+
|
|
5
|
+
import { Elysia, t } from "elysia";
|
|
6
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
7
|
+
import { decryptCredentialToString, encryptCredential } from "../../security/crypto";
|
|
8
|
+
import { invalidateApiKeyCache } from "../../security/api-key-auth";
|
|
9
|
+
import { generateShareSlug, hashShareToken } from "../../persistence/stores/share-store";
|
|
10
|
+
import type { AccessDecision } from "../../security/access-control";
|
|
11
|
+
import { ConsoleDomainError, errorResponse, requireTenantScope } from "../shared/errors";
|
|
12
|
+
import { literalUnion } from "../shared/elysia-schema";
|
|
13
|
+
import { API_KEY_MODES, API_KEY_MODEL_ACCESS_MODES, type ShareLinkKind } from "../../persistence/schema";
|
|
14
|
+
import type { ApiKeyConfig, ApiKeyResponse, UpdateApiKeyResponse } from "./contracts";
|
|
15
|
+
import type { ApiKeyPatch, ApiKeyRecord } from "../../persistence/stores/api-key-store";
|
|
16
|
+
import {
|
|
17
|
+
generateApiKeySecret,
|
|
18
|
+
isDefaultGatewayApiKey,
|
|
19
|
+
parseSharePopupImage,
|
|
20
|
+
sanitizeApiKeyResponse,
|
|
21
|
+
validateApiKeyRequest,
|
|
22
|
+
parseRequest,
|
|
23
|
+
finitePositive,
|
|
24
|
+
type CreateApiKeyRequest,
|
|
25
|
+
type CreateApiKeyResponse,
|
|
26
|
+
resolveKeyPrefix,
|
|
27
|
+
prepareCustomKey,
|
|
28
|
+
mapShareLinkResponse,
|
|
29
|
+
type ShareKeyResponse,
|
|
30
|
+
type ShareLinkResponse,
|
|
31
|
+
resolveShareKeyPrefix,
|
|
32
|
+
} from "./contracts";
|
|
33
|
+
import type { SharedKeyActivityDetail, SharedKeySummary } from "../share/share-usage";
|
|
34
|
+
import { popupImageBytes } from "./share-popup-image";
|
|
35
|
+
|
|
36
|
+
export function createApiKeyOperations(config: ApiKeyConfig) {
|
|
37
|
+
const operations = {
|
|
38
|
+
async listKeys(access: AccessDecision | undefined): Promise<ApiKeyResponse[]> {
|
|
39
|
+
const authorized = requireTenantScope(access, "dashboard:read");
|
|
40
|
+
const records = await config.store.list(authorized.tenantId);
|
|
41
|
+
const topLevel = records.filter(
|
|
42
|
+
(record) => record.revokedAt === undefined && record.parentKeyId === undefined,
|
|
43
|
+
);
|
|
44
|
+
// A share template's own counter never moves for child traffic, so
|
|
45
|
+
// reporting it raw would show a template as untouched while the gateway
|
|
46
|
+
// is refusing its recipients on the parent budget. Report the figure the
|
|
47
|
+
// gateway actually enforces: the template plus every recipient it has
|
|
48
|
+
// issued, revoked ones included (their spend is already spent).
|
|
49
|
+
const sumChildren = config.store.sumChildrenConsumed?.bind(config.store);
|
|
50
|
+
return Promise.all(
|
|
51
|
+
topLevel.map(async (record) => {
|
|
52
|
+
const response = sanitizeApiKeyResponse(record);
|
|
53
|
+
if (record.keyMode !== "share" || !sumChildren) return response;
|
|
54
|
+
const children = await sumChildren(record.id).catch(() => 0);
|
|
55
|
+
return children > 0
|
|
56
|
+
? { ...response, tokensConsumed: response.tokensConsumed + children }
|
|
57
|
+
: response;
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
},
|
|
61
|
+
/** Persists a new credential order. `keyIds` must name every top-level key
|
|
62
|
+
the tenant has, so a partial list cannot silently renumber rows. */
|
|
63
|
+
async reorderKeys(access: AccessDecision | undefined, keyIds: readonly string[]): Promise<void> {
|
|
64
|
+
const authorized = requireTenantScope(access, "dashboard:write");
|
|
65
|
+
const records = await config.store.list(authorized.tenantId);
|
|
66
|
+
const reorderable = records.filter(
|
|
67
|
+
(record) => record.revokedAt === undefined && record.parentKeyId === undefined,
|
|
68
|
+
);
|
|
69
|
+
const known = new Set(reorderable.map((record) => record.id));
|
|
70
|
+
const seen = new Set<string>();
|
|
71
|
+
for (const keyId of keyIds) {
|
|
72
|
+
if (!known.has(keyId))
|
|
73
|
+
throw new ConsoleDomainError("key_not_found", 404, `Key not found: ${keyId}`);
|
|
74
|
+
if (seen.has(keyId))
|
|
75
|
+
throw new ConsoleDomainError("invalid_request", 422, `Duplicate key id: ${keyId}`);
|
|
76
|
+
seen.add(keyId);
|
|
77
|
+
}
|
|
78
|
+
if (seen.size !== known.size)
|
|
79
|
+
throw new ConsoleDomainError(
|
|
80
|
+
"invalid_request",
|
|
81
|
+
422,
|
|
82
|
+
"Reorder must list every key exactly once",
|
|
83
|
+
);
|
|
84
|
+
await config.store.reorder(authorized.tenantId, keyIds);
|
|
85
|
+
},
|
|
86
|
+
async getKeyDetail(access: AccessDecision | undefined, keyId: string): Promise<ApiKeyResponse> {
|
|
87
|
+
const authorized = requireTenantScope(access, "dashboard:read");
|
|
88
|
+
const record = await config.store.get(authorized.tenantId, keyId);
|
|
89
|
+
if (!record) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
|
|
90
|
+
return sanitizeApiKeyResponse(record);
|
|
91
|
+
},
|
|
92
|
+
async getSharePopupImage(
|
|
93
|
+
access: AccessDecision | undefined,
|
|
94
|
+
keyId: string,
|
|
95
|
+
): Promise<{ mime: string; image: Buffer }> {
|
|
96
|
+
const authorized = requireTenantScope(access, "dashboard:read");
|
|
97
|
+
const record = await config.store.get(authorized.tenantId, keyId);
|
|
98
|
+
if (!record) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
|
|
99
|
+
if (record.sharePopupImage === undefined || record.sharePopupImageMime === undefined)
|
|
100
|
+
throw new ConsoleDomainError("key_not_found", 404, "This key has no popup image");
|
|
101
|
+
return { mime: record.sharePopupImageMime, image: record.sharePopupImage };
|
|
102
|
+
},
|
|
103
|
+
async createKey(
|
|
104
|
+
access: AccessDecision | undefined,
|
|
105
|
+
request: CreateApiKeyRequest,
|
|
106
|
+
): Promise<CreateApiKeyResponse> {
|
|
107
|
+
const authorized = requireTenantScope(access, "dashboard:write");
|
|
108
|
+
const scopes = validateApiKeyRequest(request);
|
|
109
|
+
const popupImage = parseSharePopupImage(request.sharePopupImage);
|
|
110
|
+
const keyMode = request.keyMode ?? "personal";
|
|
111
|
+
const keyPrefix =
|
|
112
|
+
keyMode === "share"
|
|
113
|
+
? resolveShareKeyPrefix(request.keyPrefix)
|
|
114
|
+
: resolveKeyPrefix(request.keyPrefix);
|
|
115
|
+
const generated =
|
|
116
|
+
keyMode === "personal"
|
|
117
|
+
? request.key === undefined
|
|
118
|
+
? generateApiKeySecret(keyPrefix)
|
|
119
|
+
: { ...prepareCustomKey(request.key), prefix: keyPrefix }
|
|
120
|
+
: undefined;
|
|
121
|
+
const record: ApiKeyRecord = {
|
|
122
|
+
id: randomUUID(),
|
|
123
|
+
tenantId: authorized.tenantId,
|
|
124
|
+
enabled: request.enabled ?? true,
|
|
125
|
+
label: request.label?.trim() || (keyMode === "share" ? "Share template" : "API key"),
|
|
126
|
+
keyHash: generated?.hash ?? null,
|
|
127
|
+
keyMode,
|
|
128
|
+
scopes,
|
|
129
|
+
// Only first boot seeds the default; a key created from the console is
|
|
130
|
+
// never it, so it stays renamable and revocable.
|
|
131
|
+
isDefault: false,
|
|
132
|
+
keyPrefix: generated?.prefix ?? keyPrefix,
|
|
133
|
+
...(generated ? { keyEncrypted: encryptCredential(generated.secret) } : {}),
|
|
134
|
+
...(request.notesTitle === undefined ? {} : { notesTitle: request.notesTitle }),
|
|
135
|
+
...(request.notesSubtitle === undefined ? {} : { notesSubtitle: request.notesSubtitle }),
|
|
136
|
+
...(request.notesBody === undefined ? {} : { notesBody: request.notesBody }),
|
|
137
|
+
...(request.sharePopupEnabled == null ? {} : { sharePopupEnabled: request.sharePopupEnabled }),
|
|
138
|
+
...(popupImage
|
|
139
|
+
? { sharePopupImage: popupImage.image, sharePopupImageMime: popupImage.mime }
|
|
140
|
+
: {}),
|
|
141
|
+
...(request.sharePopupTitle?.trim() ? { sharePopupTitle: request.sharePopupTitle.trim() } : {}),
|
|
142
|
+
...(request.sharePopupBody?.trim() ? { sharePopupBody: request.sharePopupBody.trim() } : {}),
|
|
143
|
+
...(request.requestsPerMinute == null ? {} : { requestsPerMinute: request.requestsPerMinute }),
|
|
144
|
+
...(request.dailyTokenLimit == null ? {} : { dailyTokenLimit: request.dailyTokenLimit }),
|
|
145
|
+
...(request.monthlyTokenLimit == null ? {} : { monthlyTokenLimit: request.monthlyTokenLimit }),
|
|
146
|
+
...(request.lifetimeTokenBudget == null ? {} : { lifetimeTokenBudget: request.lifetimeTokenBudget }),
|
|
147
|
+
...(request.maxConcurrentRequests == null ? {} : { maxConcurrentRequests: request.maxConcurrentRequests }),
|
|
148
|
+
...(request.modelPrefix === undefined ? {} : { modelPrefix: request.modelPrefix }),
|
|
149
|
+
modelAccessMode: request.modelAccessMode ?? "whitelist",
|
|
150
|
+
...(request.modelList === undefined ? {} : { modelList: request.modelList }),
|
|
151
|
+
...(request.clientRouterDenylist === undefined
|
|
152
|
+
? {}
|
|
153
|
+
: { clientRouterDenylist: request.clientRouterDenylist }),
|
|
154
|
+
createdAt: new Date(),
|
|
155
|
+
tokensConsumed: 0,
|
|
156
|
+
};
|
|
157
|
+
await config.store.create(record);
|
|
158
|
+
await config.auditSink?.record({
|
|
159
|
+
access: authorized,
|
|
160
|
+
action: "api_key.created",
|
|
161
|
+
target: record.id,
|
|
162
|
+
detail: { scopes, keyMode },
|
|
163
|
+
});
|
|
164
|
+
return {
|
|
165
|
+
...sanitizeApiKeyResponse(record),
|
|
166
|
+
...(generated ? { secret: generated.secret } : {}),
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
async updateKey(
|
|
170
|
+
access: AccessDecision | undefined,
|
|
171
|
+
keyId: string,
|
|
172
|
+
request: Partial<CreateApiKeyRequest>,
|
|
173
|
+
): Promise<UpdateApiKeyResponse> {
|
|
174
|
+
const authorized = requireTenantScope(access, "dashboard:write");
|
|
175
|
+
const patchRequest = parseRequest(request);
|
|
176
|
+
const current = await config.store.get(authorized.tenantId, keyId);
|
|
177
|
+
if (!current || current.revokedAt !== undefined)
|
|
178
|
+
throw new ConsoleDomainError("key_not_found", 404, "Key not found");
|
|
179
|
+
if (current.parentKeyId !== undefined)
|
|
180
|
+
throw new ConsoleDomainError(
|
|
181
|
+
"shared_key_managed_by_parent",
|
|
182
|
+
409,
|
|
183
|
+
"Edit or revoke this key through its share parent",
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
validateApiKeyRequest(patchRequest);
|
|
187
|
+
finitePositive(patchRequest.requestsPerMinute, "requestsPerMinute");
|
|
188
|
+
finitePositive(patchRequest.dailyTokenLimit, "dailyTokenLimit");
|
|
189
|
+
finitePositive(patchRequest.monthlyTokenLimit, "monthlyTokenLimit");
|
|
190
|
+
finitePositive(patchRequest.lifetimeTokenBudget, "lifetimeTokenBudget");
|
|
191
|
+
finitePositive(patchRequest.maxConcurrentRequests, "maxConcurrentRequests");
|
|
192
|
+
|
|
193
|
+
const nextMode = patchRequest.keyMode ?? current.keyMode;
|
|
194
|
+
const modeChanged = nextMode !== current.keyMode;
|
|
195
|
+
const nextPrefix =
|
|
196
|
+
nextMode === "share"
|
|
197
|
+
? resolveShareKeyPrefix(
|
|
198
|
+
patchRequest.keyPrefix ?? (modeChanged ? undefined : current.keyPrefix),
|
|
199
|
+
)
|
|
200
|
+
: resolveKeyPrefix(patchRequest.keyPrefix ?? current.keyPrefix);
|
|
201
|
+
const prefixChanged =
|
|
202
|
+
patchRequest.keyPrefix !== undefined &&
|
|
203
|
+
nextPrefix !== resolveKeyPrefix(current.keyPrefix);
|
|
204
|
+
|
|
205
|
+
let secret: string | undefined;
|
|
206
|
+
let credentialPatch: ApiKeyPatch = {};
|
|
207
|
+
if (modeChanged && nextMode === "share") {
|
|
208
|
+
credentialPatch = {
|
|
209
|
+
keyMode: "share",
|
|
210
|
+
keyHash: null,
|
|
211
|
+
keyEncrypted: null,
|
|
212
|
+
keyPrefix: nextPrefix,
|
|
213
|
+
};
|
|
214
|
+
} else if (
|
|
215
|
+
nextMode === "personal" &&
|
|
216
|
+
(modeChanged || patchRequest.key !== undefined || prefixChanged)
|
|
217
|
+
) {
|
|
218
|
+
const generated = patchRequest.key === undefined
|
|
219
|
+
? generateApiKeySecret(
|
|
220
|
+
patchRequest.keyPrefix === undefined
|
|
221
|
+
? undefined
|
|
222
|
+
: resolveKeyPrefix(patchRequest.keyPrefix),
|
|
223
|
+
)
|
|
224
|
+
: {
|
|
225
|
+
...prepareCustomKey(patchRequest.key),
|
|
226
|
+
prefix: resolveKeyPrefix(patchRequest.keyPrefix ?? current.keyPrefix),
|
|
227
|
+
};
|
|
228
|
+
secret = generated.secret;
|
|
229
|
+
credentialPatch = {
|
|
230
|
+
...(modeChanged ? { keyMode: "personal" as const } : {}),
|
|
231
|
+
keyHash: generated.hash,
|
|
232
|
+
keyEncrypted: encryptCredential(generated.secret),
|
|
233
|
+
keyPrefix: generated.prefix,
|
|
234
|
+
};
|
|
235
|
+
} else if (nextMode === "share" && nextPrefix !== current.keyPrefix) {
|
|
236
|
+
credentialPatch = { keyPrefix: nextPrefix };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const scopes =
|
|
240
|
+
patchRequest.scopes === undefined ? undefined : validateApiKeyRequest(patchRequest);
|
|
241
|
+
const popupImage =
|
|
242
|
+
patchRequest.sharePopupImage === undefined
|
|
243
|
+
? undefined
|
|
244
|
+
: parseSharePopupImage(patchRequest.sharePopupImage);
|
|
245
|
+
const updated = await config.store.update(authorized.tenantId, keyId, {
|
|
246
|
+
...(patchRequest.enabled === undefined ? {} : { enabled: patchRequest.enabled }),
|
|
247
|
+
...(isDefaultGatewayApiKey(current) ? { isDefault: true } : {}),
|
|
248
|
+
...credentialPatch,
|
|
249
|
+
...(scopes === undefined ? {} : { scopes }),
|
|
250
|
+
// Rename. The label must be copied into the patch explicitly — it is
|
|
251
|
+
// accepted and validated by the body schema but is not the credential,
|
|
252
|
+
// so omitting it here leaves the name unchanged. Blank falls back to the
|
|
253
|
+
// existing label rather than wiping it, since `label` is NOT NULL. The
|
|
254
|
+
// default key is renameable like any other (c2a93545); gating on
|
|
255
|
+
// `isDefault` would drop exactly the one patch that changes a name and
|
|
256
|
+
// leave the store with an empty `set(...)` that throws.
|
|
257
|
+
...(patchRequest.label === undefined
|
|
258
|
+
? {}
|
|
259
|
+
: { label: patchRequest.label.trim() === "" ? current.label : patchRequest.label.trim() }),
|
|
260
|
+
...(patchRequest.notesTitle === undefined
|
|
261
|
+
? {}
|
|
262
|
+
: { notesTitle: patchRequest.notesTitle.trim() === "" ? null : patchRequest.notesTitle }),
|
|
263
|
+
...(patchRequest.notesSubtitle === undefined
|
|
264
|
+
? {}
|
|
265
|
+
: { notesSubtitle: patchRequest.notesSubtitle.trim() === "" ? null : patchRequest.notesSubtitle }),
|
|
266
|
+
...(patchRequest.notesBody === undefined
|
|
267
|
+
? {}
|
|
268
|
+
: { notesBody: patchRequest.notesBody.trim() === "" ? null : patchRequest.notesBody }),
|
|
269
|
+
...(patchRequest.sharePopupEnabled === undefined ? {} : { sharePopupEnabled: patchRequest.sharePopupEnabled }),
|
|
270
|
+
...(popupImage === undefined
|
|
271
|
+
? {}
|
|
272
|
+
: popupImage === null
|
|
273
|
+
? { sharePopupImage: null, sharePopupImageMime: null }
|
|
274
|
+
: { sharePopupImage: popupImage.image, sharePopupImageMime: popupImage.mime }),
|
|
275
|
+
...(patchRequest.sharePopupTitle === undefined ? {} : { sharePopupTitle: patchRequest.sharePopupTitle.trim() || null }),
|
|
276
|
+
...(patchRequest.sharePopupBody === undefined ? {} : { sharePopupBody: patchRequest.sharePopupBody.trim() || null }),
|
|
277
|
+
...(patchRequest.requestsPerMinute === undefined ? {} : { requestsPerMinute: patchRequest.requestsPerMinute }),
|
|
278
|
+
...(patchRequest.dailyTokenLimit === undefined ? {} : { dailyTokenLimit: patchRequest.dailyTokenLimit }),
|
|
279
|
+
...(patchRequest.monthlyTokenLimit === undefined ? {} : { monthlyTokenLimit: patchRequest.monthlyTokenLimit }),
|
|
280
|
+
...(patchRequest.lifetimeTokenBudget === undefined ? {} : { lifetimeTokenBudget: patchRequest.lifetimeTokenBudget }),
|
|
281
|
+
...(patchRequest.maxConcurrentRequests === undefined ? {} : { maxConcurrentRequests: patchRequest.maxConcurrentRequests }),
|
|
282
|
+
...(patchRequest.modelPrefix === undefined ? {} : { modelPrefix: patchRequest.modelPrefix }),
|
|
283
|
+
...(patchRequest.modelAccessMode === undefined ? {} : { modelAccessMode: patchRequest.modelAccessMode }),
|
|
284
|
+
...(patchRequest.modelList === undefined ? {} : { modelList: patchRequest.modelList }),
|
|
285
|
+
...(patchRequest.clientRouterDenylist === undefined
|
|
286
|
+
? {}
|
|
287
|
+
: { clientRouterDenylist: patchRequest.clientRouterDenylist }),
|
|
288
|
+
});
|
|
289
|
+
if (!updated) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
|
|
290
|
+
const children = await config.store.listChildren(authorized.tenantId, keyId);
|
|
291
|
+
// Parent policy is live-inherited by children at auth time, so their
|
|
292
|
+
// cached snapshots must die with the parent — otherwise a denylist /
|
|
293
|
+
// budget edit looks like a no-op for up to AUTH_CACHE_TTL_MS per child.
|
|
294
|
+
invalidateApiKeyCache(keyId);
|
|
295
|
+
for (const child of children) invalidateApiKeyCache(child.id);
|
|
296
|
+
|
|
297
|
+
// Denylist / allowlist / scopes are auth-snapshot fields: cache
|
|
298
|
+
// invalidation above is enough. Only quota/concurrency edits need an
|
|
299
|
+
// admission purge — otherwise changing a client denylist would also wipe
|
|
300
|
+
// RPM / daily counters for every recipient mid-day.
|
|
301
|
+
//
|
|
302
|
+
// And only a *tightened* limit needs it. The dashboard edit form always
|
|
303
|
+
// sends all five limit fields, so a `!== undefined` test treated every
|
|
304
|
+
// save — renaming a key, editing notes — as a limit change and dropped
|
|
305
|
+
// the whole family's daily/monthly counters, restarting the budget
|
|
306
|
+
// mid-bucket. Loosening a limit cannot be bypassed by a stale counter
|
|
307
|
+
// (the counter only ever grows toward the new ceiling), so it needs no
|
|
308
|
+
// purge either; only a lowered or newly-added ceiling does.
|
|
309
|
+
const tightened = (
|
|
310
|
+
next: number | null | undefined,
|
|
311
|
+
previous: number | null | undefined,
|
|
312
|
+
): boolean => {
|
|
313
|
+
if (next === undefined || next === null) return false;
|
|
314
|
+
if (previous === null || previous === undefined) return true;
|
|
315
|
+
return next < previous;
|
|
316
|
+
};
|
|
317
|
+
/** A ceiling that did not exist before this request. */
|
|
318
|
+
const isNewLimit = (
|
|
319
|
+
next: number | null | undefined,
|
|
320
|
+
previous: number | null | undefined,
|
|
321
|
+
): boolean => next !== undefined && next !== null && (previous === null || previous === undefined);
|
|
322
|
+
const limitsChanged =
|
|
323
|
+
modeChanged ||
|
|
324
|
+
secret !== undefined ||
|
|
325
|
+
tightened(patchRequest.requestsPerMinute, current.requestsPerMinute) ||
|
|
326
|
+
tightened(patchRequest.dailyTokenLimit, current.dailyTokenLimit) ||
|
|
327
|
+
tightened(patchRequest.monthlyTokenLimit, current.monthlyTokenLimit) ||
|
|
328
|
+
tightened(patchRequest.lifetimeTokenBudget, current.lifetimeTokenBudget) ||
|
|
329
|
+
tightened(patchRequest.maxConcurrentRequests, current.maxConcurrentRequests);
|
|
330
|
+
if (limitsChanged) {
|
|
331
|
+
// Drop stale admission counters so a newly lowered one-time/recurring
|
|
332
|
+
// budget cannot be bypassed by a counter seeded under the looser limit.
|
|
333
|
+
// Share children admit under the parent id, so purge the parent too.
|
|
334
|
+
await Promise.all([
|
|
335
|
+
config.admissionService.purgeKey(keyId),
|
|
336
|
+
...children.map((child) => config.admissionService.purgeKey(child.id)),
|
|
337
|
+
]);
|
|
338
|
+
}
|
|
339
|
+
// Adding a daily/monthly ceiling to a key that already spent in the
|
|
340
|
+
// current bucket must not hand it a full fresh budget: seed the counter
|
|
341
|
+
// from the spend already recorded, so the limit covers the whole bucket.
|
|
342
|
+
// Only a *newly added* ceiling needs this — a lowered one was just
|
|
343
|
+
// purged above, and re-seeding there would restore the baseline the purge
|
|
344
|
+
// removed.
|
|
345
|
+
const dailyAdded = isNewLimit(patchRequest.dailyTokenLimit, current.dailyTokenLimit);
|
|
346
|
+
const monthlyAdded = isNewLimit(patchRequest.monthlyTokenLimit, current.monthlyTokenLimit);
|
|
347
|
+
if ((dailyAdded || monthlyAdded) && config.bucketSpend && config.admissionService.seedBuckets) {
|
|
348
|
+
const now = new Date();
|
|
349
|
+
const spend = await config.bucketSpend(keyId, now).catch(() => null);
|
|
350
|
+
if (spend) {
|
|
351
|
+
await config.admissionService.seedBuckets({
|
|
352
|
+
apiKeyId: keyId,
|
|
353
|
+
now: now.getTime(),
|
|
354
|
+
...(dailyAdded ? { daily: spend.daily } : {}),
|
|
355
|
+
...(monthlyAdded ? { monthly: spend.monthly } : {}),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
await config.auditSink?.record({
|
|
360
|
+
access: authorized,
|
|
361
|
+
action: "api_key.updated",
|
|
362
|
+
target: keyId,
|
|
363
|
+
detail: { fields: Object.keys(request) },
|
|
364
|
+
});
|
|
365
|
+
return { ...sanitizeApiKeyResponse(updated), ...(secret === undefined ? {} : { secret }) };
|
|
366
|
+
},
|
|
367
|
+
async revokeKey(access: AccessDecision | undefined, keyId: string): Promise<{ success: true }> {
|
|
368
|
+
const authorized = requireTenantScope(access, "dashboard:write");
|
|
369
|
+
const target = await config.store.get(authorized.tenantId, keyId);
|
|
370
|
+
if (!target)
|
|
371
|
+
throw new ConsoleDomainError("key_not_found", 404, "Key not found");
|
|
372
|
+
// The gateway routes every request off this key and the CLI reads it for
|
|
373
|
+
// the CLI, so removing it would strand the installation with no way to
|
|
374
|
+
// authenticate. Rotation replaces the secret and keeps the key present.
|
|
375
|
+
if (isDefaultGatewayApiKey(target))
|
|
376
|
+
throw new ConsoleDomainError(
|
|
377
|
+
"default_key_not_revocable",
|
|
378
|
+
409,
|
|
379
|
+
"The default gateway API key cannot be deleted. Rotate it instead.",
|
|
380
|
+
);
|
|
381
|
+
const children = await config.store.listChildren(authorized.tenantId, keyId);
|
|
382
|
+
const removed = await config.store.remove(authorized.tenantId, keyId);
|
|
383
|
+
if (!removed) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
|
|
384
|
+
invalidateApiKeyCache(keyId);
|
|
385
|
+
for (const child of children) invalidateApiKeyCache(child.id);
|
|
386
|
+
await Promise.all([
|
|
387
|
+
config.admissionService.purgeKey(keyId),
|
|
388
|
+
...children.map((child) => config.admissionService.purgeKey(child.id)),
|
|
389
|
+
]);
|
|
390
|
+
await config.auditSink?.record({
|
|
391
|
+
access: authorized,
|
|
392
|
+
action: "api_key.revoked",
|
|
393
|
+
target: keyId,
|
|
394
|
+
});
|
|
395
|
+
return { success: true };
|
|
396
|
+
},
|
|
397
|
+
/**
|
|
398
|
+
* Establishes or rotates the key's single stable link.
|
|
399
|
+
*
|
|
400
|
+
* The console shows one link that never moves; `regenerate` replaces the
|
|
401
|
+
* token in place so the previous URL stops resolving. A share template
|
|
402
|
+
* hands out child keys; a personal key hands out the key itself.
|
|
403
|
+
*/
|
|
404
|
+
async shareKey(
|
|
405
|
+
access: AccessDecision | undefined,
|
|
406
|
+
keyId: string,
|
|
407
|
+
options: { expiresAt?: string | null; origin?: string; regenerate?: boolean } = {},
|
|
408
|
+
): Promise<ShareKeyResponse> {
|
|
409
|
+
const authorized = requireTenantScope(access, "dashboard:write");
|
|
410
|
+
const shareStore = config.shareStore;
|
|
411
|
+
if (!shareStore)
|
|
412
|
+
throw new ConsoleDomainError("capability_unsupported", 501, "key share not configured");
|
|
413
|
+
const record = await config.store.get(authorized.tenantId, keyId);
|
|
414
|
+
if (!record || record.revokedAt !== undefined)
|
|
415
|
+
throw new ConsoleDomainError("key_not_found", 404, "Key not found");
|
|
416
|
+
if (record.parentKeyId !== undefined)
|
|
417
|
+
throw new ConsoleDomainError(
|
|
418
|
+
"key_not_share_parent",
|
|
419
|
+
409,
|
|
420
|
+
"Only a top-level key can carry a handoff link",
|
|
421
|
+
);
|
|
422
|
+
const kind: ShareLinkKind = record.keyMode === "share" ? "enroll" : "handoff";
|
|
423
|
+
const origin = (options.origin ?? "").replace(/\/$/, "");
|
|
424
|
+
// Re-issuing without regeneration must return the link that is actually
|
|
425
|
+
// stored. Minting a fresh token here would hand back a URL that was never
|
|
426
|
+
// persisted, so the link would be dead on arrival.
|
|
427
|
+
if (options.regenerate !== true) {
|
|
428
|
+
const existing = await shareStore.findTokenForApiKey(keyId);
|
|
429
|
+
if (existing !== null && existing.tokenEncrypted !== null) {
|
|
430
|
+
try {
|
|
431
|
+
const token = decryptCredentialToString(existing.tokenEncrypted);
|
|
432
|
+
return {
|
|
433
|
+
id: existing.id,
|
|
434
|
+
url: `${origin}/share/${token}`,
|
|
435
|
+
token,
|
|
436
|
+
kind: existing.kind,
|
|
437
|
+
expiresAt: existing.expiresAt === null ? null : existing.expiresAt.toISOString(),
|
|
438
|
+
};
|
|
439
|
+
} catch {
|
|
440
|
+
// A row written under a different encryption key cannot be shown;
|
|
441
|
+
// fall through and establish a fresh link.
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
let expiresAt: Date | null = null;
|
|
446
|
+
if (options.expiresAt !== undefined && options.expiresAt !== null) {
|
|
447
|
+
const parsed = new Date(options.expiresAt);
|
|
448
|
+
if (Number.isNaN(parsed.getTime()))
|
|
449
|
+
throw new ConsoleDomainError(
|
|
450
|
+
"invalid_share_expiry",
|
|
451
|
+
400,
|
|
452
|
+
"expiresAt must be a valid ISO timestamp",
|
|
453
|
+
);
|
|
454
|
+
expiresAt = parsed;
|
|
455
|
+
}
|
|
456
|
+
const token = randomBytes(32).toString("base64url");
|
|
457
|
+
const link = await shareStore.create({
|
|
458
|
+
apiKeyId: keyId,
|
|
459
|
+
tokenHash: hashShareToken(token),
|
|
460
|
+
tokenEncrypted: encryptCredential(token),
|
|
461
|
+
slug: generateShareSlug(),
|
|
462
|
+
kind,
|
|
463
|
+
expiresAt,
|
|
464
|
+
rotate: options.regenerate === true,
|
|
465
|
+
});
|
|
466
|
+
await config.auditSink?.record({
|
|
467
|
+
access: authorized,
|
|
468
|
+
action: options.regenerate === true ? "api_key.share_regenerated" : "api_key.shared",
|
|
469
|
+
target: keyId,
|
|
470
|
+
detail: { shareId: link.id, kind },
|
|
471
|
+
});
|
|
472
|
+
return {
|
|
473
|
+
id: link.id,
|
|
474
|
+
url: `${origin}/share/${token}`,
|
|
475
|
+
token,
|
|
476
|
+
kind: link.kind,
|
|
477
|
+
expiresAt: link.expiresAt === null ? null : link.expiresAt.toISOString(),
|
|
478
|
+
};
|
|
479
|
+
},
|
|
480
|
+
/**
|
|
481
|
+
* The key's stable link with its retained token, for the console to show
|
|
482
|
+
* without rotating it. Returns null when the key has no link yet or the
|
|
483
|
+
* link predates token retention.
|
|
484
|
+
*/
|
|
485
|
+
async getShare(
|
|
486
|
+
access: AccessDecision | undefined,
|
|
487
|
+
keyId: string,
|
|
488
|
+
options: { origin?: string } = {},
|
|
489
|
+
): Promise<ShareKeyResponse | null> {
|
|
490
|
+
const authorized = requireTenantScope(access, "dashboard:read");
|
|
491
|
+
const shareStore = config.shareStore;
|
|
492
|
+
if (!shareStore)
|
|
493
|
+
throw new ConsoleDomainError("capability_unsupported", 501, "key share not configured");
|
|
494
|
+
const record = await config.store.get(authorized.tenantId, keyId);
|
|
495
|
+
if (!record || record.revokedAt !== undefined)
|
|
496
|
+
throw new ConsoleDomainError("key_not_found", 404, "Key not found");
|
|
497
|
+
if (record.parentKeyId !== undefined)
|
|
498
|
+
throw new ConsoleDomainError(
|
|
499
|
+
"key_not_share_parent",
|
|
500
|
+
409,
|
|
501
|
+
"Only a top-level key can carry a handoff link",
|
|
502
|
+
);
|
|
503
|
+
const link = await shareStore.findTokenForApiKey(keyId);
|
|
504
|
+
if (link === null) return null;
|
|
505
|
+
let token: string | null = null;
|
|
506
|
+
if (link.tokenEncrypted !== null) {
|
|
507
|
+
try {
|
|
508
|
+
token = decryptCredentialToString(link.tokenEncrypted);
|
|
509
|
+
} catch {
|
|
510
|
+
// A row written under a different encryption key cannot be shown.
|
|
511
|
+
// The page itself survives: the slug is held in the clear, so the
|
|
512
|
+
// address stays valid and the operator only loses the bearer half.
|
|
513
|
+
token = null;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
// Nothing to point at: neither the address nor the secret survived.
|
|
517
|
+
if (token === null && link.slug === null) return null;
|
|
518
|
+
const origin = (options.origin ?? "").replace(/\/$/, "");
|
|
519
|
+
return {
|
|
520
|
+
id: link.id,
|
|
521
|
+
// The slug is the address; the token is the bearer half. Preferring the
|
|
522
|
+
// slug keeps a restored page reachable under the same URL even when the
|
|
523
|
+
// encryption key that sealed its token is gone.
|
|
524
|
+
url: `${origin}/share/${link.slug ?? token}`,
|
|
525
|
+
token,
|
|
526
|
+
kind: link.kind,
|
|
527
|
+
expiresAt: link.expiresAt === null ? null : link.expiresAt.toISOString(),
|
|
528
|
+
};
|
|
529
|
+
},
|
|
530
|
+
/**
|
|
531
|
+
* Rotates a personal key's credential in place and re-points its handoff
|
|
532
|
+
* link at the new secret. Share templates have no credential of their own,
|
|
533
|
+
* so they are rejected here.
|
|
534
|
+
*/
|
|
535
|
+
async regenerateKey(
|
|
536
|
+
access: AccessDecision | undefined,
|
|
537
|
+
keyId: string,
|
|
538
|
+
options: { origin?: string } = {},
|
|
539
|
+
): Promise<{ secret: string; share: ShareKeyResponse | null }> {
|
|
540
|
+
const authorized = requireTenantScope(access, "dashboard:write");
|
|
541
|
+
const current = await config.store.get(authorized.tenantId, keyId);
|
|
542
|
+
if (!current || current.revokedAt !== undefined)
|
|
543
|
+
throw new ConsoleDomainError("key_not_found", 404, "Key not found");
|
|
544
|
+
if (current.parentKeyId !== undefined)
|
|
545
|
+
throw new ConsoleDomainError(
|
|
546
|
+
"shared_key_managed_by_parent",
|
|
547
|
+
409,
|
|
548
|
+
"Edit or revoke this key through its share parent",
|
|
549
|
+
);
|
|
550
|
+
if (current.keyMode !== "personal")
|
|
551
|
+
throw new ConsoleDomainError(
|
|
552
|
+
"key_not_personal",
|
|
553
|
+
409,
|
|
554
|
+
"Only a personal key can be regenerated; rotate a share template's link instead",
|
|
555
|
+
);
|
|
556
|
+
const generated = generateApiKeySecret();
|
|
557
|
+
const updated = await config.store.update(authorized.tenantId, keyId, {
|
|
558
|
+
keyHash: generated.hash,
|
|
559
|
+
keyEncrypted: encryptCredential(generated.secret),
|
|
560
|
+
keyPrefix: generated.prefix,
|
|
561
|
+
});
|
|
562
|
+
if (!updated) throw new ConsoleDomainError("key_not_found", 404, "Key not found");
|
|
563
|
+
invalidateApiKeyCache(keyId);
|
|
564
|
+
await config.admissionService.purgeKey(keyId);
|
|
565
|
+
const shareStore = config.shareStore;
|
|
566
|
+
let share: ShareKeyResponse | null = null;
|
|
567
|
+
if (shareStore) {
|
|
568
|
+
const token = randomBytes(32).toString("base64url");
|
|
569
|
+
const link = await shareStore.create({
|
|
570
|
+
apiKeyId: keyId,
|
|
571
|
+
tokenHash: hashShareToken(token),
|
|
572
|
+
tokenEncrypted: encryptCredential(token),
|
|
573
|
+
slug: generateShareSlug(),
|
|
574
|
+
kind: "handoff",
|
|
575
|
+
expiresAt: null,
|
|
576
|
+
rotate: true,
|
|
577
|
+
});
|
|
578
|
+
const origin = (options.origin ?? "").replace(/\/$/, "");
|
|
579
|
+
share = {
|
|
580
|
+
id: link.id,
|
|
581
|
+
url: `${origin}/share/${token}`,
|
|
582
|
+
token,
|
|
583
|
+
kind: link.kind,
|
|
584
|
+
expiresAt: null,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
await config.auditSink?.record({
|
|
588
|
+
access: authorized,
|
|
589
|
+
action: "api_key.regenerated",
|
|
590
|
+
target: keyId,
|
|
591
|
+
});
|
|
592
|
+
return { secret: generated.secret, share };
|
|
593
|
+
},
|
|
594
|
+
/**
|
|
595
|
+
* Returns a personal key's stored secret. The ciphertext exists exactly so
|
|
596
|
+
* the owner can read it back; a share template has none, so it is refused.
|
|
597
|
+
*/
|
|
598
|
+
async revealKey(
|
|
599
|
+
access: AccessDecision | undefined,
|
|
600
|
+
keyId: string,
|
|
601
|
+
): Promise<{ secret: string }> {
|
|
602
|
+
const authorized = requireTenantScope(access, "dashboard:read");
|
|
603
|
+
const current = await config.store.get(authorized.tenantId, keyId);
|
|
604
|
+
if (!current || current.revokedAt !== undefined)
|
|
605
|
+
throw new ConsoleDomainError("key_not_found", 404, "Key not found");
|
|
606
|
+
if (current.keyMode !== "personal" || current.keyEncrypted === undefined)
|
|
607
|
+
throw new ConsoleDomainError(
|
|
608
|
+
"key_not_personal",
|
|
609
|
+
409,
|
|
610
|
+
"Only a personal key keeps a recoverable secret",
|
|
611
|
+
);
|
|
612
|
+
await config.auditSink?.record({
|
|
613
|
+
access: authorized,
|
|
614
|
+
action: "api_key.revealed",
|
|
615
|
+
target: keyId,
|
|
616
|
+
});
|
|
617
|
+
return { secret: decryptCredentialToString(current.keyEncrypted) };
|
|
618
|
+
},
|
|
619
|
+
async listShares(
|
|
620
|
+
access: AccessDecision | undefined,
|
|
621
|
+
keyId: string,
|
|
622
|
+
): Promise<ShareLinkResponse[]> {
|
|
623
|
+
const authorized = requireTenantScope(access, "dashboard:read");
|
|
624
|
+
if (!config.shareStore)
|
|
625
|
+
throw new ConsoleDomainError("capability_unsupported", 501, "key share not configured");
|
|
626
|
+
const record = await config.store.get(authorized.tenantId, keyId);
|
|
627
|
+
if (!record || record.keyMode !== "share" || record.parentKeyId !== undefined)
|
|
628
|
+
throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
|
|
629
|
+
return (await config.shareStore.listForApiKey(keyId)).map(mapShareLinkResponse);
|
|
630
|
+
},
|
|
631
|
+
async listSharedKeys(
|
|
632
|
+
access: AccessDecision | undefined,
|
|
633
|
+
parentKeyId: string,
|
|
634
|
+
): Promise<readonly SharedKeySummary[]> {
|
|
635
|
+
const authorized = requireTenantScope(access, "dashboard:read");
|
|
636
|
+
const activity = config.shareActivity;
|
|
637
|
+
if (!activity)
|
|
638
|
+
throw new ConsoleDomainError("capability_unsupported", 501, "share telemetry not configured");
|
|
639
|
+
const parent = await config.store.get(authorized.tenantId, parentKeyId);
|
|
640
|
+
if (!parent || parent.revokedAt !== undefined ||
|
|
641
|
+
parent.keyMode !== "share" || parent.parentKeyId !== undefined)
|
|
642
|
+
throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
|
|
643
|
+
const children = await config.store.listChildren(authorized.tenantId, parentKeyId);
|
|
644
|
+
return activity.getSharedKeySummaries(authorized.tenantId, children);
|
|
645
|
+
},
|
|
646
|
+
async getSharedKeyActivity(
|
|
647
|
+
access: AccessDecision | undefined,
|
|
648
|
+
parentKeyId: string,
|
|
649
|
+
childKeyId: string,
|
|
650
|
+
): Promise<SharedKeyActivityDetail> {
|
|
651
|
+
const authorized = requireTenantScope(access, "dashboard:read");
|
|
652
|
+
const activity = config.shareActivity;
|
|
653
|
+
if (!activity)
|
|
654
|
+
throw new ConsoleDomainError("capability_unsupported", 501, "share telemetry not configured");
|
|
655
|
+
const parent = await config.store.get(authorized.tenantId, parentKeyId);
|
|
656
|
+
if (!parent || parent.revokedAt !== undefined ||
|
|
657
|
+
parent.keyMode !== "share" || parent.parentKeyId !== undefined)
|
|
658
|
+
throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
|
|
659
|
+
const children = await config.store.listChildren(authorized.tenantId, parentKeyId);
|
|
660
|
+
if (!children.some((child) => child.id === childKeyId))
|
|
661
|
+
throw new ConsoleDomainError("key_not_found", 404, "Shared API key not found");
|
|
662
|
+
return activity.getSharedKeyDetail(authorized.tenantId, childKeyId);
|
|
663
|
+
},
|
|
664
|
+
async revokeShare(
|
|
665
|
+
access: AccessDecision | undefined,
|
|
666
|
+
keyId: string,
|
|
667
|
+
shareId: string,
|
|
668
|
+
): Promise<{ success: true }> {
|
|
669
|
+
const authorized = requireTenantScope(access, "dashboard:write");
|
|
670
|
+
const shareStore = config.shareStore;
|
|
671
|
+
if (!shareStore)
|
|
672
|
+
throw new ConsoleDomainError("capability_unsupported", 501, "key share not configured");
|
|
673
|
+
const record = await config.store.get(authorized.tenantId, keyId);
|
|
674
|
+
if (!record || record.keyMode !== "share" || record.parentKeyId !== undefined)
|
|
675
|
+
throw new ConsoleDomainError("key_not_found", 404, "Share template not found");
|
|
676
|
+
const revoked = await shareStore.revoke(keyId, shareId);
|
|
677
|
+
if (!revoked) throw new ConsoleDomainError("share_not_found", 404, "Share link not found");
|
|
678
|
+
await config.auditSink?.record({
|
|
679
|
+
access: authorized,
|
|
680
|
+
action: "api_key.share_revoked",
|
|
681
|
+
target: shareId,
|
|
682
|
+
detail: { keyId },
|
|
683
|
+
});
|
|
684
|
+
return { success: true };
|
|
685
|
+
},
|
|
686
|
+
};
|
|
687
|
+
return operations;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const apiKeyBody = t.Object({
|
|
691
|
+
enabled: t.Optional(t.Boolean()),
|
|
692
|
+
label: t.Optional(t.String()),
|
|
693
|
+
keyMode: t.Optional(literalUnion(API_KEY_MODES)),
|
|
694
|
+
scopes: t.Optional(t.Array(t.String())),
|
|
695
|
+
keyPrefix: t.Optional(t.String()),
|
|
696
|
+
key: t.Optional(t.String()),
|
|
697
|
+
// Limit fields accept `null` to clear a previously set limit ("unlimited");
|
|
698
|
+
// `undefined` leaves it unchanged.
|
|
699
|
+
requestsPerMinute: t.Optional(t.Union([t.Number(), t.Null()])),
|
|
700
|
+
dailyTokenLimit: t.Optional(t.Union([t.Number(), t.Null()])),
|
|
701
|
+
monthlyTokenLimit: t.Optional(t.Union([t.Number(), t.Null()])),
|
|
702
|
+
lifetimeTokenBudget: t.Optional(t.Union([t.Number(), t.Null()])),
|
|
703
|
+
maxConcurrentRequests: t.Optional(t.Union([t.Number(), t.Null()])),
|
|
704
|
+
modelPrefix: t.Optional(t.String()),
|
|
705
|
+
modelAccessMode: t.Optional(literalUnion(API_KEY_MODEL_ACCESS_MODES)),
|
|
706
|
+
modelList: t.Optional(t.Array(t.String())),
|
|
707
|
+
clientRouterDenylist: t.Optional(t.Array(t.String())),
|
|
708
|
+
notesTitle: t.Optional(t.String()),
|
|
709
|
+
notesSubtitle: t.Optional(t.String()),
|
|
710
|
+
notesBody: t.Optional(t.String()),
|
|
711
|
+
sharePopupEnabled: t.Optional(t.Union([t.Boolean(), t.Null()])),
|
|
712
|
+
sharePopupImage: t.Optional(t.Union([t.String({ maxLength: 2_800_000 }), t.Null()])),
|
|
713
|
+
sharePopupTitle: t.Optional(t.Union([t.String({ maxLength: 120 }), t.Null()])),
|
|
714
|
+
sharePopupBody: t.Optional(t.Union([t.String({ maxLength: 1200 }), t.Null()])),
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
/** Full replacement order for the tenant's top-level credential list. */
|
|
718
|
+
const reorderKeysBody = t.Object({ keyIds: t.Array(t.String(), { minItems: 1 }) });
|
|
719
|
+
|
|
720
|
+
const apiKeyShareBody = t.Object({
|
|
721
|
+
expiresAt: t.Optional(t.Union([t.String(), t.Null()])),
|
|
722
|
+
/** Replaces the existing link's token so the previous URL stops resolving. */
|
|
723
|
+
regenerate: t.Optional(t.Boolean()),
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Serializes an intentional `null` payload as the JSON literal.
|
|
728
|
+
*
|
|
729
|
+
* Elysia reads a bare `null` return as "no content" and emits `200` with an
|
|
730
|
+
* empty body and no `content-type`, which a JSON client cannot parse. `null`
|
|
731
|
+
* here is a real answer — "this key has no link yet" — and the response type
|
|
732
|
+
* declares it, so it has to arrive as the bytes `null` rather than as nothing.
|
|
733
|
+
*/
|
|
734
|
+
function jsonNull(): Response {
|
|
735
|
+
return new Response("null", {
|
|
736
|
+
status: 200,
|
|
737
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
/** Creates tenant-bound API-key routes with request-local authorization. */
|
|
741
|
+
export function createApiKeyRoutes(config: ApiKeyConfig): Elysia {
|
|
742
|
+
const factory = createApiKeyOperations(config);
|
|
743
|
+
return new Elysia({ prefix: "/api-keys" })
|
|
744
|
+
.get("/", async ({ request, set }) => {
|
|
745
|
+
try {
|
|
746
|
+
return await factory.listKeys(config.accessResolver(request));
|
|
747
|
+
} catch (error) {
|
|
748
|
+
return errorResponse(error, set, "API-key operation failed");
|
|
749
|
+
}
|
|
750
|
+
})
|
|
751
|
+
.post("/reorder", { body: reorderKeysBody }, async ({ request, body, set }) => {
|
|
752
|
+
try {
|
|
753
|
+
await factory.reorderKeys(config.accessResolver(request), body.keyIds);
|
|
754
|
+
return { success: true };
|
|
755
|
+
} catch (error) {
|
|
756
|
+
return errorResponse(error, set, "API-key operation failed");
|
|
757
|
+
}
|
|
758
|
+
})
|
|
759
|
+
.get("/:keyId", async ({ request, params, set }) => {
|
|
760
|
+
try {
|
|
761
|
+
return await factory.getKeyDetail(config.accessResolver(request), params.keyId);
|
|
762
|
+
} catch (error) {
|
|
763
|
+
return errorResponse(error, set, "API-key operation failed");
|
|
764
|
+
}
|
|
765
|
+
})
|
|
766
|
+
.get("/:keyId/share-popup-image", async ({ request, params, set }) => {
|
|
767
|
+
try {
|
|
768
|
+
const result = await factory.getSharePopupImage(config.accessResolver(request), params.keyId);
|
|
769
|
+
return new Response(new Blob([popupImageBytes(result.image)], { type: result.mime }), {
|
|
770
|
+
headers: {
|
|
771
|
+
"content-length": String(result.image.byteLength),
|
|
772
|
+
"cache-control": "private, max-age=300",
|
|
773
|
+
"x-content-type-options": "nosniff",
|
|
774
|
+
},
|
|
775
|
+
});
|
|
776
|
+
} catch (error) {
|
|
777
|
+
return errorResponse(error, set, "API key operation failed");
|
|
778
|
+
}
|
|
779
|
+
})
|
|
780
|
+
.get("/:keyId/shares", async ({ request, params, set }) => {
|
|
781
|
+
try {
|
|
782
|
+
return await factory.listShares(config.accessResolver(request), params.keyId);
|
|
783
|
+
} catch (error) {
|
|
784
|
+
return errorResponse(error, set, "API-key operation failed");
|
|
785
|
+
}
|
|
786
|
+
})
|
|
787
|
+
.get("/:keyId/shared-keys", async ({ request, params, set }) => {
|
|
788
|
+
try {
|
|
789
|
+
return await factory.listSharedKeys(config.accessResolver(request), params.keyId);
|
|
790
|
+
} catch (error) {
|
|
791
|
+
return errorResponse(error, set, "API-key operation failed");
|
|
792
|
+
}
|
|
793
|
+
})
|
|
794
|
+
.get("/:keyId/shared-keys/:childKeyId/activity", async ({ request, params, set }) => {
|
|
795
|
+
try {
|
|
796
|
+
return await factory.getSharedKeyActivity(
|
|
797
|
+
config.accessResolver(request),
|
|
798
|
+
params.keyId,
|
|
799
|
+
params.childKeyId,
|
|
800
|
+
);
|
|
801
|
+
} catch (error) {
|
|
802
|
+
return errorResponse(error, set, "API-key operation failed");
|
|
803
|
+
}
|
|
804
|
+
})
|
|
805
|
+
.delete("/:keyId/shares/:shareId", async ({ request, params, set }) => {
|
|
806
|
+
try {
|
|
807
|
+
return await factory.revokeShare(
|
|
808
|
+
config.accessResolver(request),
|
|
809
|
+
params.keyId,
|
|
810
|
+
params.shareId,
|
|
811
|
+
);
|
|
812
|
+
} catch (error) {
|
|
813
|
+
return errorResponse(error, set, "API-key operation failed");
|
|
814
|
+
}
|
|
815
|
+
})
|
|
816
|
+
.post("/", { body: apiKeyBody }, async ({ request, body, set }) => {
|
|
817
|
+
try {
|
|
818
|
+
set.status = 201;
|
|
819
|
+
return await factory.createKey(config.accessResolver(request), parseRequest(body));
|
|
820
|
+
} catch (error) {
|
|
821
|
+
return errorResponse(error, set, "API-key operation failed");
|
|
822
|
+
}
|
|
823
|
+
})
|
|
824
|
+
.patch("/:keyId", { body: apiKeyBody }, async ({ request, params, body, set }) => {
|
|
825
|
+
try {
|
|
826
|
+
return await factory.updateKey(
|
|
827
|
+
config.accessResolver(request),
|
|
828
|
+
params.keyId,
|
|
829
|
+
parseRequest(body),
|
|
830
|
+
);
|
|
831
|
+
} catch (error) {
|
|
832
|
+
return errorResponse(error, set, "API-key operation failed");
|
|
833
|
+
}
|
|
834
|
+
})
|
|
835
|
+
.delete("/:keyId", async ({ request, params, set }) => {
|
|
836
|
+
try {
|
|
837
|
+
return await factory.revokeKey(config.accessResolver(request), params.keyId);
|
|
838
|
+
} catch (error) {
|
|
839
|
+
return errorResponse(error, set, "API-key operation failed");
|
|
840
|
+
}
|
|
841
|
+
})
|
|
842
|
+
.get("/:keyId/share", async ({ request, params, set }) => {
|
|
843
|
+
try {
|
|
844
|
+
const share = await factory.getShare(config.accessResolver(request), params.keyId, {
|
|
845
|
+
origin: new URL(request.url).origin,
|
|
846
|
+
});
|
|
847
|
+
return share ?? jsonNull();
|
|
848
|
+
} catch (error) {
|
|
849
|
+
return errorResponse(error, set, "API-key operation failed");
|
|
850
|
+
}
|
|
851
|
+
})
|
|
852
|
+
.post("/:keyId/share", { body: t.Optional(apiKeyShareBody) }, async ({ request, params, body, set }) => {
|
|
853
|
+
try {
|
|
854
|
+
set.status = 201;
|
|
855
|
+
const input = (body ?? {}) as { expiresAt?: string | null; regenerate?: boolean };
|
|
856
|
+
return await factory.shareKey(config.accessResolver(request), params.keyId, {
|
|
857
|
+
...(input.expiresAt === undefined ? {} : { expiresAt: input.expiresAt }),
|
|
858
|
+
...(input.regenerate === undefined ? {} : { regenerate: input.regenerate }),
|
|
859
|
+
origin: new URL(request.url).origin,
|
|
860
|
+
});
|
|
861
|
+
} catch (error) {
|
|
862
|
+
return errorResponse(error, set, "API-key operation failed");
|
|
863
|
+
}
|
|
864
|
+
})
|
|
865
|
+
.post("/:keyId/reveal", async ({ request, params, set }) => {
|
|
866
|
+
try {
|
|
867
|
+
return await factory.revealKey(config.accessResolver(request), params.keyId);
|
|
868
|
+
} catch (error) {
|
|
869
|
+
return errorResponse(error, set, "API-key operation failed");
|
|
870
|
+
}
|
|
871
|
+
})
|
|
872
|
+
.post("/:keyId/regenerate", async ({ request, params, set }) => {
|
|
873
|
+
try {
|
|
874
|
+
return await factory.regenerateKey(config.accessResolver(request), params.keyId, {
|
|
875
|
+
origin: new URL(request.url).origin,
|
|
876
|
+
});
|
|
877
|
+
} catch (error) {
|
|
878
|
+
return errorResponse(error, set, "API-key operation failed");
|
|
879
|
+
}
|
|
880
|
+
}) as unknown as Elysia;
|
|
881
|
+
}
|