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,418 +1,418 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cursor OAuth client and token refresher.
|
|
3
|
-
*
|
|
4
|
-
* Cursor's login is its own PKCE device flow, not RFC 8628: the verifier and a
|
|
5
|
-
* UUID are generated locally, the operator opens a `loginDeepControl` URL in a
|
|
6
|
-
* browser, and the gateway polls `/auth/poll` until the tokens land. There is no
|
|
7
|
-
* user code — approval is bound to the browser session — so `userCode` is empty
|
|
8
|
-
* and the private verifier rides in `providerState`, which the console persists
|
|
9
|
-
* server-side and never returns to the browser.
|
|
10
|
-
*
|
|
11
|
-
* Two credential families reach dispatch:
|
|
12
|
-
*
|
|
13
|
-
* - **Browser session** (`/oauth/token` refresh grant, as Cursor's IDE performs
|
|
14
|
-
* it). A rejected token is not an HTTP error: Cursor answers 200 with
|
|
15
|
-
* `{"access_token":"","shouldLogout":true}`, so the body decides the outcome.
|
|
16
|
-
* - **Raw API key** (`crsr_`, exchanged at `/auth/exchange_user_api_key`). The
|
|
17
|
-
* exchanged JWT is the dispatch secret; the raw key is kept in the encrypted
|
|
18
|
-
* refresh field so the refresher can re-exchange it, which is how Cursor CLI
|
|
19
|
-
* renews an API-key login.
|
|
20
|
-
*
|
|
21
|
-
* A raw key never reaches `auth_state` and is never logged: `auth_state` is
|
|
22
|
-
* operator-visible in the console.
|
|
23
|
-
*/
|
|
24
|
-
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
25
|
-
import { OAuthDeviceFlow } from "../../authentication/oauth-device-flow";
|
|
26
|
-
import type {
|
|
27
|
-
OAuthDeviceFlowContext,
|
|
28
|
-
OAuthDevicePollResult,
|
|
29
|
-
OAuthDeviceStartResult,
|
|
30
|
-
OAuthExchangeResult,
|
|
31
|
-
OAuthImportInput,
|
|
32
|
-
OAuthLoginField,
|
|
33
|
-
} from "../../authentication/oauth-flow-store";
|
|
34
|
-
import { base64UrlEncode, nonEmptyTrimmedString, record } from "../../authentication/oauth-flow-store";
|
|
35
|
-
import type { FetchLike } from "../../authentication/oauth-client";
|
|
36
|
-
import type { OAuthRefreshContext, OAuthTokenRefreshResult } from "../../authentication/oauth-refresh-service";
|
|
37
|
-
import {
|
|
38
|
-
AUTH_REQUEST_TIMEOUT_MS,
|
|
39
|
-
CURSOR_API_BASE_URL,
|
|
40
|
-
CURSOR_OAUTH_CLIENT_ID,
|
|
41
|
-
CURSOR_WEBSITE_URL,
|
|
42
|
-
SESSION_REFRESH_TIMEOUT_MS,
|
|
43
|
-
SIGN_IN_POLICY_VIOLATION,
|
|
44
|
-
TOKEN_EXPIRY_THRESHOLD_S,
|
|
45
|
-
isExchangeableApiKey,
|
|
46
|
-
} from "./cursor-shared";
|
|
47
|
-
|
|
48
|
-
/** Device authorizations live 5 minutes before the flow is abandoned. */
|
|
49
|
-
const DEVICE_EXPIRY_SECONDS = 300;
|
|
50
|
-
/** Dashboard poll cadence for the device authorization. */
|
|
51
|
-
const DEVICE_INTERVAL_SECONDS = 5;
|
|
52
|
-
/** Token lifetime assumed when the upstream omits every expiry hint. */
|
|
53
|
-
const EXPIRY_FALLBACK_MS = 30 * 24 * 60 * 60 * 1000;
|
|
54
|
-
|
|
55
|
-
/** Fields the import flow collects: one raw Cursor API key. */
|
|
56
|
-
const IMPORT_FIELDS: readonly OAuthLoginField[] = [
|
|
57
|
-
{
|
|
58
|
-
key: "credential",
|
|
59
|
-
label: "Cursor API key",
|
|
60
|
-
placeholder: "crsr_…",
|
|
61
|
-
secret: true,
|
|
62
|
-
required: true,
|
|
63
|
-
},
|
|
64
|
-
];
|
|
65
|
-
|
|
66
|
-
function base64urlDecode(segment: string): string | undefined {
|
|
67
|
-
try {
|
|
68
|
-
return Buffer.from(segment, "base64url").toString("utf8");
|
|
69
|
-
} catch {
|
|
70
|
-
return undefined;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Decodes a JWT payload without verifying; the trust boundary stays at TLS. */
|
|
75
|
-
export function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
|
|
76
|
-
const segment = token.split(".")[1];
|
|
77
|
-
if (segment === undefined) return undefined;
|
|
78
|
-
const json = base64urlDecode(segment);
|
|
79
|
-
if (json === undefined) return undefined;
|
|
80
|
-
try {
|
|
81
|
-
return record(JSON.parse(json) as unknown);
|
|
82
|
-
} catch {
|
|
83
|
-
return undefined;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** JWT `exp` claim as epoch milliseconds, or `undefined` when absent/malformed. */
|
|
88
|
-
export function decodeJwtExpiryMs(token: string): number | undefined {
|
|
89
|
-
const payload = decodeJwtPayload(token);
|
|
90
|
-
const exp = payload?.["exp"];
|
|
91
|
-
if (typeof exp !== "number" || !Number.isFinite(exp)) return undefined;
|
|
92
|
-
return exp * 1000;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** True when a JWT expires within the refresh threshold. */
|
|
96
|
-
export function isExpiringSoon(token: string, thresholdSeconds = TOKEN_EXPIRY_THRESHOLD_S): boolean {
|
|
97
|
-
const expiry = decodeJwtExpiryMs(token);
|
|
98
|
-
if (expiry === undefined) return false;
|
|
99
|
-
return expiry - Date.now() < thresholdSeconds * 1000;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* The account identity Cursor itself asserts, from the access token's `sub`.
|
|
104
|
-
*
|
|
105
|
-
* Cursor publishes no email on this grant, so
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*/
|
|
111
|
-
export function cursorAccountIdentity(token: string): string | undefined {
|
|
112
|
-
const payload = decodeJwtPayload(token);
|
|
113
|
-
const subject = payload?.["sub"];
|
|
114
|
-
return typeof subject === "string" && subject.trim().length > 0 ? subject.trim() : undefined;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Private device-flow state. Never returned to the browser. */
|
|
118
|
-
interface CursorDeviceState {
|
|
119
|
-
readonly verifier: string;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function parseDeviceState(value: string | undefined): CursorDeviceState | undefined {
|
|
123
|
-
if (value === undefined || value.length === 0) return undefined;
|
|
124
|
-
let parsed: unknown;
|
|
125
|
-
try {
|
|
126
|
-
parsed = JSON.parse(value) as unknown;
|
|
127
|
-
} catch {
|
|
128
|
-
return undefined;
|
|
129
|
-
}
|
|
130
|
-
const root = record(parsed);
|
|
131
|
-
const verifier = root === undefined ? undefined : nonEmptyTrimmedString(root["verifier"]);
|
|
132
|
-
return verifier === undefined ? undefined : { verifier };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** Minimal fetch surface accepted by the token request helpers. */
|
|
136
|
-
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
137
|
-
|
|
138
|
-
/** The base OAuth client's fetch surface; widened to `FetchFn` at each call. */
|
|
139
|
-
function asFetchFn(fetchFn: FetchLike): FetchFn {
|
|
140
|
-
// Structural identity: the base class's `FetchLike` is the same signature as
|
|
141
|
-
// `FetchFn`, so this only reconciles the two declarations.
|
|
142
|
-
return fetchFn as FetchFn;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
type JsonOutcome =
|
|
146
|
-
| { ok: true; body: Record<string, unknown>; status: number }
|
|
147
|
-
| { ok: false; status?: number; message: string };
|
|
148
|
-
|
|
149
|
-
async function fetchJson(
|
|
150
|
-
fetchFn: FetchFn,
|
|
151
|
-
url: string,
|
|
152
|
-
init: RequestInit,
|
|
153
|
-
timeoutMs: number,
|
|
154
|
-
label: string,
|
|
155
|
-
): Promise<JsonOutcome> {
|
|
156
|
-
let response: Response;
|
|
157
|
-
try {
|
|
158
|
-
response = await fetchFn(url, {
|
|
159
|
-
...init,
|
|
160
|
-
signal: signalOrTimeout(init.signal, timeoutMs),
|
|
161
|
-
});
|
|
162
|
-
} catch (error) {
|
|
163
|
-
return {
|
|
164
|
-
ok: false,
|
|
165
|
-
message: `${label} request failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
const text = await response.text().catch(() => "");
|
|
169
|
-
let body: Record<string, unknown> | undefined;
|
|
170
|
-
try {
|
|
171
|
-
body = record(JSON.parse(text) as unknown) ?? undefined;
|
|
172
|
-
} catch {
|
|
173
|
-
body = undefined;
|
|
174
|
-
}
|
|
175
|
-
if (!response.ok) {
|
|
176
|
-
return {
|
|
177
|
-
ok: false,
|
|
178
|
-
status: response.status,
|
|
179
|
-
message: `${label} failed (${response.status}): ${text.slice(0, 200)}`,
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
if (body === undefined) {
|
|
183
|
-
return { ok: false, status: response.status, message: `${label} returned malformed JSON` };
|
|
184
|
-
}
|
|
185
|
-
return { ok: true, body, status: response.status };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** Composes an outer signal with a bounded timeout; absent signal → timeout alone. */
|
|
189
|
-
function signalOrTimeout(signal: AbortSignal | null | undefined, timeoutMs: number): AbortSignal {
|
|
190
|
-
const timeout = AbortSignal.timeout(timeoutMs);
|
|
191
|
-
return signal === null || signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Exchanges a raw `crsr_` key for a short-lived JWT.
|
|
196
|
-
*
|
|
197
|
-
* Also how an API-key login is renewed: Cursor CLI re-runs the exchange and
|
|
198
|
-
* never uses the refresh token the response carries.
|
|
199
|
-
*/
|
|
200
|
-
async function exchangeApiKey(
|
|
201
|
-
fetchFn: FetchFn,
|
|
202
|
-
apiKey: string,
|
|
203
|
-
): Promise<{ accessToken: string; refreshToken: string; expiresAt: Date }> {
|
|
204
|
-
const outcome = await fetchJson(
|
|
205
|
-
fetchFn,
|
|
206
|
-
`${CURSOR_API_BASE_URL}/auth/exchange_user_api_key`,
|
|
207
|
-
{
|
|
208
|
-
method: "POST",
|
|
209
|
-
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
210
|
-
body: "{}",
|
|
211
|
-
},
|
|
212
|
-
AUTH_REQUEST_TIMEOUT_MS,
|
|
213
|
-
"Cursor API key exchange",
|
|
214
|
-
);
|
|
215
|
-
if (!outcome.ok) throw new Error(outcome.message);
|
|
216
|
-
const accessToken = nonEmptyTrimmedString(outcome.body["accessToken"]);
|
|
217
|
-
if (accessToken === undefined) throw new Error("Cursor API key exchange returned no access token");
|
|
218
|
-
const refreshToken = nonEmptyTrimmedString(outcome.body["refreshToken"]) ?? apiKey;
|
|
219
|
-
const expiry = decodeJwtExpiryMs(accessToken);
|
|
220
|
-
return {
|
|
221
|
-
accessToken,
|
|
222
|
-
refreshToken,
|
|
223
|
-
expiresAt: new Date(expiry ?? Date.now() + EXPIRY_FALLBACK_MS),
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
export class CursorOAuthClient extends OAuthDeviceFlow {
|
|
228
|
-
override readonly supportsDeviceCode = true;
|
|
229
|
-
override readonly supportsBrowserCode = false;
|
|
230
|
-
readonly importFields = IMPORT_FIELDS;
|
|
231
|
-
|
|
232
|
-
protected override readonly providerLabel = "Cursor";
|
|
233
|
-
// Cursor's poll endpoint takes no client id; the abstract field stays empty
|
|
234
|
-
// rather than inventing one, and the poll request omits it.
|
|
235
|
-
protected override readonly clientId = "";
|
|
236
|
-
protected override readonly tokenUrl = `${CURSOR_API_BASE_URL}/oauth/token`;
|
|
237
|
-
protected override readonly scopes = "";
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Starts the browser login.
|
|
241
|
-
*
|
|
242
|
-
* The PKCE verifier is the flow's private half and is returned only as
|
|
243
|
-
* `providerState` — the console persists it in Redis, and the browser sees the
|
|
244
|
-
* URL and the UUID alone. Publishing it would let anyone who can read the
|
|
245
|
-
* start response mint the tokens the operator is about to authorize.
|
|
246
|
-
*/
|
|
247
|
-
override async startDeviceAuth(
|
|
248
|
-
_context?: OAuthDeviceFlowContext,
|
|
249
|
-
): Promise<OAuthDeviceStartResult> {
|
|
250
|
-
const verifier = base64UrlEncode(randomBytes(32));
|
|
251
|
-
const challenge = base64UrlEncode(createHash("sha256").update(verifier).digest());
|
|
252
|
-
const uuid = randomUUID();
|
|
253
|
-
const params = new URLSearchParams({
|
|
254
|
-
challenge,
|
|
255
|
-
uuid,
|
|
256
|
-
mode: "login",
|
|
257
|
-
redirectTarget: "cli",
|
|
258
|
-
});
|
|
259
|
-
return {
|
|
260
|
-
verificationUri: `${CURSOR_WEBSITE_URL}/loginDeepControl?${params.toString()}`,
|
|
261
|
-
// No user code: approval happens against the browser session.
|
|
262
|
-
userCode: "",
|
|
263
|
-
deviceAuthId: uuid,
|
|
264
|
-
intervalSeconds: DEVICE_INTERVAL_SECONDS,
|
|
265
|
-
expiresInSeconds: DEVICE_EXPIRY_SECONDS,
|
|
266
|
-
providerState: JSON.stringify({ verifier } satisfies CursorDeviceState),
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* Polls `/auth/poll` with the private verifier.
|
|
272
|
-
*
|
|
273
|
-
* 404 is Cursor's "not approved yet" answer, not a failure. A 429 is a
|
|
274
|
-
* backoff request rather than a dead credential, so the dashboard widens its
|
|
275
|
-
* cadence and keeps waiting. A 200 without tokens is still pending: the
|
|
276
|
-
* operator may not have finished approving, and ending the flow there would
|
|
277
|
-
* abandon a login in progress (a genuinely changed shape surfaces as the
|
|
278
|
-
* flow's own expiry).
|
|
279
|
-
*/
|
|
280
|
-
override async pollDeviceAuth(
|
|
281
|
-
deviceAuthId: string,
|
|
282
|
-
context?: OAuthDeviceFlowContext,
|
|
283
|
-
): Promise<OAuthDevicePollResult> {
|
|
284
|
-
const state = parseDeviceState(context?.providerState);
|
|
285
|
-
if (state === undefined) return { status: "failed", reason: "unknown Cursor device authorization" };
|
|
286
|
-
const url =
|
|
287
|
-
`${CURSOR_API_BASE_URL}/auth/poll?uuid=${encodeURIComponent(deviceAuthId)}` +
|
|
288
|
-
`&verifier=${encodeURIComponent(state.verifier)}`;
|
|
289
|
-
const outcome = await fetchJson(
|
|
290
|
-
asFetchFn(this.fetchFn),
|
|
291
|
-
url,
|
|
292
|
-
{ method: "GET", headers: { accept: "application/json" } },
|
|
293
|
-
AUTH_REQUEST_TIMEOUT_MS,
|
|
294
|
-
"Cursor device polling",
|
|
295
|
-
);
|
|
296
|
-
if (!outcome.ok) {
|
|
297
|
-
if (outcome.status === 404) return { status: "pending" };
|
|
298
|
-
if (outcome.status === 429) {
|
|
299
|
-
return { status: "slow_down", retryAfterSeconds: DEVICE_INTERVAL_SECONDS * 2 };
|
|
300
|
-
}
|
|
301
|
-
return { status: "failed", reason: outcome.message };
|
|
302
|
-
}
|
|
303
|
-
const accessToken = nonEmptyTrimmedString(outcome.body["accessToken"]);
|
|
304
|
-
if (accessToken === undefined) return { status: "pending" };
|
|
305
|
-
const refreshToken = nonEmptyTrimmedString(outcome.body["refreshToken"]) ?? accessToken;
|
|
306
|
-
const expiry = decodeJwtExpiryMs(accessToken);
|
|
307
|
-
const identity = cursorAccountIdentity(accessToken);
|
|
308
|
-
return {
|
|
309
|
-
status: "complete",
|
|
310
|
-
result: {
|
|
311
|
-
access: accessToken,
|
|
312
|
-
refresh: refreshToken,
|
|
313
|
-
expiresAt: new Date(expiry ?? Date.now() + EXPIRY_FALLBACK_MS),
|
|
314
|
-
...(identity === undefined ? {} : { accountLabel: identity }),
|
|
315
|
-
// `method` distinguishes a browser session (renewed by grant) from an
|
|
316
|
-
// imported key (renewed by re-exchange). It carries no secret.
|
|
317
|
-
auth_state: { method: "login", ...(identity === undefined ? {} : { subject: identity }) },
|
|
318
|
-
},
|
|
319
|
-
};
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* Imports a raw Cursor API key.
|
|
324
|
-
*
|
|
325
|
-
* The key is exchanged before it is returned, so an import cannot persist
|
|
326
|
-
* material that never worked. The exchanged JWT becomes the account secret and
|
|
327
|
-
* the raw key rides in the encrypted refresh field: the refresher needs it to
|
|
328
|
-
* renew the login, and that field is the only place it is stored.
|
|
329
|
-
*/
|
|
330
|
-
async importCredential(input: OAuthImportInput): Promise<OAuthExchangeResult> {
|
|
331
|
-
const credential = input.credential.trim();
|
|
332
|
-
if (credential.length === 0) throw new Error("Cursor credential material is required");
|
|
333
|
-
if (!isExchangeableApiKey(credential)) {
|
|
334
|
-
throw new Error(
|
|
335
|
-
"Cursor API keys start with 'crsr_'; sign in with Cursor for a browser session instead.",
|
|
336
|
-
);
|
|
337
|
-
}
|
|
338
|
-
const exchanged = await exchangeApiKey(asFetchFn(this.fetchFn), credential);
|
|
339
|
-
const identity = cursorAccountIdentity(exchanged.accessToken);
|
|
340
|
-
return {
|
|
341
|
-
access: exchanged.accessToken,
|
|
342
|
-
refresh: credential,
|
|
343
|
-
expiresAt: exchanged.expiresAt,
|
|
344
|
-
...(identity === undefined ? {} : { accountLabel: identity }),
|
|
345
|
-
auth_state: { method: "api_key", ...(identity === undefined ? {} : { subject: identity }) },
|
|
346
|
-
};
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* Renews one Cursor account credential.
|
|
351
|
-
*
|
|
352
|
-
* An API-key account re-exchanges its raw key (the refresh field holds it); a
|
|
353
|
-
* browser session posts the IDE's refresh grant. A sign-in policy block and a
|
|
354
|
-
* `shouldLogout` answer are terminal for this credential, while a network or
|
|
355
|
-
* 5xx failure is transient and worth retrying.
|
|
356
|
-
*/
|
|
357
|
-
override async refresh(
|
|
358
|
-
refreshToken: string,
|
|
359
|
-
signal?: AbortSignal,
|
|
360
|
-
context?: OAuthRefreshContext,
|
|
361
|
-
): Promise<OAuthTokenRefreshResult> {
|
|
362
|
-
const method = nonEmptyTrimmedString(context?.auth_state?.["method"]);
|
|
363
|
-
if (isExchangeableApiKey(refreshToken)) {
|
|
364
|
-
const exchanged = await exchangeApiKey(asFetchFn(this.fetchFn), refreshToken);
|
|
365
|
-
return {
|
|
366
|
-
access: exchanged.accessToken,
|
|
367
|
-
refresh: refreshToken,
|
|
368
|
-
expiresAt: exchanged.expiresAt,
|
|
369
|
-
auth_state: { method: method ?? "api_key" },
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
if (refreshToken.length === 0) throw new Error("Cursor refresh token is missing");
|
|
373
|
-
const outcome = await fetchJson(
|
|
374
|
-
asFetchFn(this.fetchFn),
|
|
375
|
-
`${CURSOR_API_BASE_URL}/oauth/token`,
|
|
376
|
-
{
|
|
377
|
-
method: "POST",
|
|
378
|
-
headers: { "content-type": "application/json", "x-cursor-client-type": "cli" },
|
|
379
|
-
body: JSON.stringify({
|
|
380
|
-
grant_type: "refresh_token",
|
|
381
|
-
client_id: CURSOR_OAUTH_CLIENT_ID,
|
|
382
|
-
refresh_token: refreshToken,
|
|
383
|
-
}),
|
|
384
|
-
...(signal === undefined ? {} : { signal }),
|
|
385
|
-
},
|
|
386
|
-
SESSION_REFRESH_TIMEOUT_MS,
|
|
387
|
-
"Cursor session refresh",
|
|
388
|
-
);
|
|
389
|
-
if (!outcome.ok) {
|
|
390
|
-
// A bare transport failure is transient; a refusal is not.
|
|
391
|
-
if (outcome.status === 401 || outcome.status === 403) {
|
|
392
|
-
throw new Error(`Cursor session refresh was rejected (${outcome.status})`);
|
|
393
|
-
}
|
|
394
|
-
throw new Error(outcome.message);
|
|
395
|
-
}
|
|
396
|
-
if (outcome.body["shouldLogout"] === true) {
|
|
397
|
-
const code = outcome.body["error"];
|
|
398
|
-
throw new Error(
|
|
399
|
-
code === SIGN_IN_POLICY_VIOLATION
|
|
400
|
-
? `Cursor session blocked by sign-in policy (${SIGN_IN_POLICY_VIOLATION})`
|
|
401
|
-
: "Cursor ended the session (shouldLogout) — sign in again",
|
|
402
|
-
);
|
|
403
|
-
}
|
|
404
|
-
const access = nonEmptyTrimmedString(outcome.body["access_token"]);
|
|
405
|
-
if (access === undefined) throw new Error("Cursor session refresh returned no access token");
|
|
406
|
-
const expiry = decodeJwtExpiryMs(access);
|
|
407
|
-
return {
|
|
408
|
-
access,
|
|
409
|
-
// The grant issues no new refresh token: the IDE stores the access token
|
|
410
|
-
// as both, and so does this refresher.
|
|
411
|
-
refresh: refreshToken,
|
|
412
|
-
expiresAt: new Date(expiry ?? Date.now() + EXPIRY_FALLBACK_MS),
|
|
413
|
-
auth_state: { method: method ?? "login" },
|
|
414
|
-
};
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
export const cursorOAuthClient = new CursorOAuthClient();
|
|
1
|
+
/**
|
|
2
|
+
* Cursor OAuth client and token refresher.
|
|
3
|
+
*
|
|
4
|
+
* Cursor's login is its own PKCE device flow, not RFC 8628: the verifier and a
|
|
5
|
+
* UUID are generated locally, the operator opens a `loginDeepControl` URL in a
|
|
6
|
+
* browser, and the gateway polls `/auth/poll` until the tokens land. There is no
|
|
7
|
+
* user code — approval is bound to the browser session — so `userCode` is empty
|
|
8
|
+
* and the private verifier rides in `providerState`, which the console persists
|
|
9
|
+
* server-side and never returns to the browser.
|
|
10
|
+
*
|
|
11
|
+
* Two credential families reach dispatch:
|
|
12
|
+
*
|
|
13
|
+
* - **Browser session** (`/oauth/token` refresh grant, as Cursor's IDE performs
|
|
14
|
+
* it). A rejected token is not an HTTP error: Cursor answers 200 with
|
|
15
|
+
* `{"access_token":"","shouldLogout":true}`, so the body decides the outcome.
|
|
16
|
+
* - **Raw API key** (`crsr_`, exchanged at `/auth/exchange_user_api_key`). The
|
|
17
|
+
* exchanged JWT is the dispatch secret; the raw key is kept in the encrypted
|
|
18
|
+
* refresh field so the refresher can re-exchange it, which is how Cursor CLI
|
|
19
|
+
* renews an API-key login.
|
|
20
|
+
*
|
|
21
|
+
* A raw key never reaches `auth_state` and is never logged: `auth_state` is
|
|
22
|
+
* operator-visible in the console.
|
|
23
|
+
*/
|
|
24
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
25
|
+
import { OAuthDeviceFlow } from "../../authentication/oauth-device-flow";
|
|
26
|
+
import type {
|
|
27
|
+
OAuthDeviceFlowContext,
|
|
28
|
+
OAuthDevicePollResult,
|
|
29
|
+
OAuthDeviceStartResult,
|
|
30
|
+
OAuthExchangeResult,
|
|
31
|
+
OAuthImportInput,
|
|
32
|
+
OAuthLoginField,
|
|
33
|
+
} from "../../authentication/oauth-flow-store";
|
|
34
|
+
import { base64UrlEncode, nonEmptyTrimmedString, record } from "../../authentication/oauth-flow-store";
|
|
35
|
+
import type { FetchLike } from "../../authentication/oauth-client";
|
|
36
|
+
import type { OAuthRefreshContext, OAuthTokenRefreshResult } from "../../authentication/oauth-refresh-service";
|
|
37
|
+
import {
|
|
38
|
+
AUTH_REQUEST_TIMEOUT_MS,
|
|
39
|
+
CURSOR_API_BASE_URL,
|
|
40
|
+
CURSOR_OAUTH_CLIENT_ID,
|
|
41
|
+
CURSOR_WEBSITE_URL,
|
|
42
|
+
SESSION_REFRESH_TIMEOUT_MS,
|
|
43
|
+
SIGN_IN_POLICY_VIOLATION,
|
|
44
|
+
TOKEN_EXPIRY_THRESHOLD_S,
|
|
45
|
+
isExchangeableApiKey,
|
|
46
|
+
} from "./cursor-shared";
|
|
47
|
+
|
|
48
|
+
/** Device authorizations live 5 minutes before the flow is abandoned. */
|
|
49
|
+
const DEVICE_EXPIRY_SECONDS = 300;
|
|
50
|
+
/** Dashboard poll cadence for the device authorization. */
|
|
51
|
+
const DEVICE_INTERVAL_SECONDS = 5;
|
|
52
|
+
/** Token lifetime assumed when the upstream omits every expiry hint. */
|
|
53
|
+
const EXPIRY_FALLBACK_MS = 30 * 24 * 60 * 60 * 1000;
|
|
54
|
+
|
|
55
|
+
/** Fields the import flow collects: one raw Cursor API key. */
|
|
56
|
+
const IMPORT_FIELDS: readonly OAuthLoginField[] = [
|
|
57
|
+
{
|
|
58
|
+
key: "credential",
|
|
59
|
+
label: "Cursor API key",
|
|
60
|
+
placeholder: "crsr_…",
|
|
61
|
+
secret: true,
|
|
62
|
+
required: true,
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
function base64urlDecode(segment: string): string | undefined {
|
|
67
|
+
try {
|
|
68
|
+
return Buffer.from(segment, "base64url").toString("utf8");
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Decodes a JWT payload without verifying; the trust boundary stays at TLS. */
|
|
75
|
+
export function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
|
|
76
|
+
const segment = token.split(".")[1];
|
|
77
|
+
if (segment === undefined) return undefined;
|
|
78
|
+
const json = base64urlDecode(segment);
|
|
79
|
+
if (json === undefined) return undefined;
|
|
80
|
+
try {
|
|
81
|
+
return record(JSON.parse(json) as unknown);
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** JWT `exp` claim as epoch milliseconds, or `undefined` when absent/malformed. */
|
|
88
|
+
export function decodeJwtExpiryMs(token: string): number | undefined {
|
|
89
|
+
const payload = decodeJwtPayload(token);
|
|
90
|
+
const exp = payload?.["exp"];
|
|
91
|
+
if (typeof exp !== "number" || !Number.isFinite(exp)) return undefined;
|
|
92
|
+
return exp * 1000;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** True when a JWT expires within the refresh threshold. */
|
|
96
|
+
export function isExpiringSoon(token: string, thresholdSeconds = TOKEN_EXPIRY_THRESHOLD_S): boolean {
|
|
97
|
+
const expiry = decodeJwtExpiryMs(token);
|
|
98
|
+
if (expiry === undefined) return false;
|
|
99
|
+
return expiry - Date.now() < thresholdSeconds * 1000;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The account identity Cursor itself asserts, from the access token's `sub`.
|
|
104
|
+
*
|
|
105
|
+
* Cursor publishes no email on this grant, so falling back to the provider id
|
|
106
|
+
* makes every account of the provider look identical — adding a second one
|
|
107
|
+
* replaces the first. `sub` (`auth0|user_01…`) is stable across every login of
|
|
108
|
+
* one account and differs between accounts, which is exactly what an account
|
|
109
|
+
* identity has to do.
|
|
110
|
+
*/
|
|
111
|
+
export function cursorAccountIdentity(token: string): string | undefined {
|
|
112
|
+
const payload = decodeJwtPayload(token);
|
|
113
|
+
const subject = payload?.["sub"];
|
|
114
|
+
return typeof subject === "string" && subject.trim().length > 0 ? subject.trim() : undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Private device-flow state. Never returned to the browser. */
|
|
118
|
+
interface CursorDeviceState {
|
|
119
|
+
readonly verifier: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseDeviceState(value: string | undefined): CursorDeviceState | undefined {
|
|
123
|
+
if (value === undefined || value.length === 0) return undefined;
|
|
124
|
+
let parsed: unknown;
|
|
125
|
+
try {
|
|
126
|
+
parsed = JSON.parse(value) as unknown;
|
|
127
|
+
} catch {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
const root = record(parsed);
|
|
131
|
+
const verifier = root === undefined ? undefined : nonEmptyTrimmedString(root["verifier"]);
|
|
132
|
+
return verifier === undefined ? undefined : { verifier };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Minimal fetch surface accepted by the token request helpers. */
|
|
136
|
+
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
137
|
+
|
|
138
|
+
/** The base OAuth client's fetch surface; widened to `FetchFn` at each call. */
|
|
139
|
+
function asFetchFn(fetchFn: FetchLike): FetchFn {
|
|
140
|
+
// Structural identity: the base class's `FetchLike` is the same signature as
|
|
141
|
+
// `FetchFn`, so this only reconciles the two declarations.
|
|
142
|
+
return fetchFn as FetchFn;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
type JsonOutcome =
|
|
146
|
+
| { ok: true; body: Record<string, unknown>; status: number }
|
|
147
|
+
| { ok: false; status?: number; message: string };
|
|
148
|
+
|
|
149
|
+
async function fetchJson(
|
|
150
|
+
fetchFn: FetchFn,
|
|
151
|
+
url: string,
|
|
152
|
+
init: RequestInit,
|
|
153
|
+
timeoutMs: number,
|
|
154
|
+
label: string,
|
|
155
|
+
): Promise<JsonOutcome> {
|
|
156
|
+
let response: Response;
|
|
157
|
+
try {
|
|
158
|
+
response = await fetchFn(url, {
|
|
159
|
+
...init,
|
|
160
|
+
signal: signalOrTimeout(init.signal, timeoutMs),
|
|
161
|
+
});
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
message: `${label} request failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const text = await response.text().catch(() => "");
|
|
169
|
+
let body: Record<string, unknown> | undefined;
|
|
170
|
+
try {
|
|
171
|
+
body = record(JSON.parse(text) as unknown) ?? undefined;
|
|
172
|
+
} catch {
|
|
173
|
+
body = undefined;
|
|
174
|
+
}
|
|
175
|
+
if (!response.ok) {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
status: response.status,
|
|
179
|
+
message: `${label} failed (${response.status}): ${text.slice(0, 200)}`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (body === undefined) {
|
|
183
|
+
return { ok: false, status: response.status, message: `${label} returned malformed JSON` };
|
|
184
|
+
}
|
|
185
|
+
return { ok: true, body, status: response.status };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Composes an outer signal with a bounded timeout; absent signal → timeout alone. */
|
|
189
|
+
function signalOrTimeout(signal: AbortSignal | null | undefined, timeoutMs: number): AbortSignal {
|
|
190
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
191
|
+
return signal === null || signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Exchanges a raw `crsr_` key for a short-lived JWT.
|
|
196
|
+
*
|
|
197
|
+
* Also how an API-key login is renewed: Cursor CLI re-runs the exchange and
|
|
198
|
+
* never uses the refresh token the response carries.
|
|
199
|
+
*/
|
|
200
|
+
async function exchangeApiKey(
|
|
201
|
+
fetchFn: FetchFn,
|
|
202
|
+
apiKey: string,
|
|
203
|
+
): Promise<{ accessToken: string; refreshToken: string; expiresAt: Date }> {
|
|
204
|
+
const outcome = await fetchJson(
|
|
205
|
+
fetchFn,
|
|
206
|
+
`${CURSOR_API_BASE_URL}/auth/exchange_user_api_key`,
|
|
207
|
+
{
|
|
208
|
+
method: "POST",
|
|
209
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
210
|
+
body: "{}",
|
|
211
|
+
},
|
|
212
|
+
AUTH_REQUEST_TIMEOUT_MS,
|
|
213
|
+
"Cursor API key exchange",
|
|
214
|
+
);
|
|
215
|
+
if (!outcome.ok) throw new Error(outcome.message);
|
|
216
|
+
const accessToken = nonEmptyTrimmedString(outcome.body["accessToken"]);
|
|
217
|
+
if (accessToken === undefined) throw new Error("Cursor API key exchange returned no access token");
|
|
218
|
+
const refreshToken = nonEmptyTrimmedString(outcome.body["refreshToken"]) ?? apiKey;
|
|
219
|
+
const expiry = decodeJwtExpiryMs(accessToken);
|
|
220
|
+
return {
|
|
221
|
+
accessToken,
|
|
222
|
+
refreshToken,
|
|
223
|
+
expiresAt: new Date(expiry ?? Date.now() + EXPIRY_FALLBACK_MS),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export class CursorOAuthClient extends OAuthDeviceFlow {
|
|
228
|
+
override readonly supportsDeviceCode = true;
|
|
229
|
+
override readonly supportsBrowserCode = false;
|
|
230
|
+
readonly importFields = IMPORT_FIELDS;
|
|
231
|
+
|
|
232
|
+
protected override readonly providerLabel = "Cursor";
|
|
233
|
+
// Cursor's poll endpoint takes no client id; the abstract field stays empty
|
|
234
|
+
// rather than inventing one, and the poll request omits it.
|
|
235
|
+
protected override readonly clientId = "";
|
|
236
|
+
protected override readonly tokenUrl = `${CURSOR_API_BASE_URL}/oauth/token`;
|
|
237
|
+
protected override readonly scopes = "";
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Starts the browser login.
|
|
241
|
+
*
|
|
242
|
+
* The PKCE verifier is the flow's private half and is returned only as
|
|
243
|
+
* `providerState` — the console persists it in Redis, and the browser sees the
|
|
244
|
+
* URL and the UUID alone. Publishing it would let anyone who can read the
|
|
245
|
+
* start response mint the tokens the operator is about to authorize.
|
|
246
|
+
*/
|
|
247
|
+
override async startDeviceAuth(
|
|
248
|
+
_context?: OAuthDeviceFlowContext,
|
|
249
|
+
): Promise<OAuthDeviceStartResult> {
|
|
250
|
+
const verifier = base64UrlEncode(randomBytes(32));
|
|
251
|
+
const challenge = base64UrlEncode(createHash("sha256").update(verifier).digest());
|
|
252
|
+
const uuid = randomUUID();
|
|
253
|
+
const params = new URLSearchParams({
|
|
254
|
+
challenge,
|
|
255
|
+
uuid,
|
|
256
|
+
mode: "login",
|
|
257
|
+
redirectTarget: "cli",
|
|
258
|
+
});
|
|
259
|
+
return {
|
|
260
|
+
verificationUri: `${CURSOR_WEBSITE_URL}/loginDeepControl?${params.toString()}`,
|
|
261
|
+
// No user code: approval happens against the browser session.
|
|
262
|
+
userCode: "",
|
|
263
|
+
deviceAuthId: uuid,
|
|
264
|
+
intervalSeconds: DEVICE_INTERVAL_SECONDS,
|
|
265
|
+
expiresInSeconds: DEVICE_EXPIRY_SECONDS,
|
|
266
|
+
providerState: JSON.stringify({ verifier } satisfies CursorDeviceState),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Polls `/auth/poll` with the private verifier.
|
|
272
|
+
*
|
|
273
|
+
* 404 is Cursor's "not approved yet" answer, not a failure. A 429 is a
|
|
274
|
+
* backoff request rather than a dead credential, so the dashboard widens its
|
|
275
|
+
* cadence and keeps waiting. A 200 without tokens is still pending: the
|
|
276
|
+
* operator may not have finished approving, and ending the flow there would
|
|
277
|
+
* abandon a login in progress (a genuinely changed shape surfaces as the
|
|
278
|
+
* flow's own expiry).
|
|
279
|
+
*/
|
|
280
|
+
override async pollDeviceAuth(
|
|
281
|
+
deviceAuthId: string,
|
|
282
|
+
context?: OAuthDeviceFlowContext,
|
|
283
|
+
): Promise<OAuthDevicePollResult> {
|
|
284
|
+
const state = parseDeviceState(context?.providerState);
|
|
285
|
+
if (state === undefined) return { status: "failed", reason: "unknown Cursor device authorization" };
|
|
286
|
+
const url =
|
|
287
|
+
`${CURSOR_API_BASE_URL}/auth/poll?uuid=${encodeURIComponent(deviceAuthId)}` +
|
|
288
|
+
`&verifier=${encodeURIComponent(state.verifier)}`;
|
|
289
|
+
const outcome = await fetchJson(
|
|
290
|
+
asFetchFn(this.fetchFn),
|
|
291
|
+
url,
|
|
292
|
+
{ method: "GET", headers: { accept: "application/json" } },
|
|
293
|
+
AUTH_REQUEST_TIMEOUT_MS,
|
|
294
|
+
"Cursor device polling",
|
|
295
|
+
);
|
|
296
|
+
if (!outcome.ok) {
|
|
297
|
+
if (outcome.status === 404) return { status: "pending" };
|
|
298
|
+
if (outcome.status === 429) {
|
|
299
|
+
return { status: "slow_down", retryAfterSeconds: DEVICE_INTERVAL_SECONDS * 2 };
|
|
300
|
+
}
|
|
301
|
+
return { status: "failed", reason: outcome.message };
|
|
302
|
+
}
|
|
303
|
+
const accessToken = nonEmptyTrimmedString(outcome.body["accessToken"]);
|
|
304
|
+
if (accessToken === undefined) return { status: "pending" };
|
|
305
|
+
const refreshToken = nonEmptyTrimmedString(outcome.body["refreshToken"]) ?? accessToken;
|
|
306
|
+
const expiry = decodeJwtExpiryMs(accessToken);
|
|
307
|
+
const identity = cursorAccountIdentity(accessToken);
|
|
308
|
+
return {
|
|
309
|
+
status: "complete",
|
|
310
|
+
result: {
|
|
311
|
+
access: accessToken,
|
|
312
|
+
refresh: refreshToken,
|
|
313
|
+
expiresAt: new Date(expiry ?? Date.now() + EXPIRY_FALLBACK_MS),
|
|
314
|
+
...(identity === undefined ? {} : { accountLabel: identity }),
|
|
315
|
+
// `method` distinguishes a browser session (renewed by grant) from an
|
|
316
|
+
// imported key (renewed by re-exchange). It carries no secret.
|
|
317
|
+
auth_state: { method: "login", ...(identity === undefined ? {} : { subject: identity }) },
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Imports a raw Cursor API key.
|
|
324
|
+
*
|
|
325
|
+
* The key is exchanged before it is returned, so an import cannot persist
|
|
326
|
+
* material that never worked. The exchanged JWT becomes the account secret and
|
|
327
|
+
* the raw key rides in the encrypted refresh field: the refresher needs it to
|
|
328
|
+
* renew the login, and that field is the only place it is stored.
|
|
329
|
+
*/
|
|
330
|
+
async importCredential(input: OAuthImportInput): Promise<OAuthExchangeResult> {
|
|
331
|
+
const credential = input.credential.trim();
|
|
332
|
+
if (credential.length === 0) throw new Error("Cursor credential material is required");
|
|
333
|
+
if (!isExchangeableApiKey(credential)) {
|
|
334
|
+
throw new Error(
|
|
335
|
+
"Cursor API keys start with 'crsr_'; sign in with Cursor for a browser session instead.",
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
const exchanged = await exchangeApiKey(asFetchFn(this.fetchFn), credential);
|
|
339
|
+
const identity = cursorAccountIdentity(exchanged.accessToken);
|
|
340
|
+
return {
|
|
341
|
+
access: exchanged.accessToken,
|
|
342
|
+
refresh: credential,
|
|
343
|
+
expiresAt: exchanged.expiresAt,
|
|
344
|
+
...(identity === undefined ? {} : { accountLabel: identity }),
|
|
345
|
+
auth_state: { method: "api_key", ...(identity === undefined ? {} : { subject: identity }) },
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Renews one Cursor account credential.
|
|
351
|
+
*
|
|
352
|
+
* An API-key account re-exchanges its raw key (the refresh field holds it); a
|
|
353
|
+
* browser session posts the IDE's refresh grant. A sign-in policy block and a
|
|
354
|
+
* `shouldLogout` answer are terminal for this credential, while a network or
|
|
355
|
+
* 5xx failure is transient and worth retrying.
|
|
356
|
+
*/
|
|
357
|
+
override async refresh(
|
|
358
|
+
refreshToken: string,
|
|
359
|
+
signal?: AbortSignal,
|
|
360
|
+
context?: OAuthRefreshContext,
|
|
361
|
+
): Promise<OAuthTokenRefreshResult> {
|
|
362
|
+
const method = nonEmptyTrimmedString(context?.auth_state?.["method"]);
|
|
363
|
+
if (isExchangeableApiKey(refreshToken)) {
|
|
364
|
+
const exchanged = await exchangeApiKey(asFetchFn(this.fetchFn), refreshToken);
|
|
365
|
+
return {
|
|
366
|
+
access: exchanged.accessToken,
|
|
367
|
+
refresh: refreshToken,
|
|
368
|
+
expiresAt: exchanged.expiresAt,
|
|
369
|
+
auth_state: { method: method ?? "api_key" },
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
if (refreshToken.length === 0) throw new Error("Cursor refresh token is missing");
|
|
373
|
+
const outcome = await fetchJson(
|
|
374
|
+
asFetchFn(this.fetchFn),
|
|
375
|
+
`${CURSOR_API_BASE_URL}/oauth/token`,
|
|
376
|
+
{
|
|
377
|
+
method: "POST",
|
|
378
|
+
headers: { "content-type": "application/json", "x-cursor-client-type": "cli" },
|
|
379
|
+
body: JSON.stringify({
|
|
380
|
+
grant_type: "refresh_token",
|
|
381
|
+
client_id: CURSOR_OAUTH_CLIENT_ID,
|
|
382
|
+
refresh_token: refreshToken,
|
|
383
|
+
}),
|
|
384
|
+
...(signal === undefined ? {} : { signal }),
|
|
385
|
+
},
|
|
386
|
+
SESSION_REFRESH_TIMEOUT_MS,
|
|
387
|
+
"Cursor session refresh",
|
|
388
|
+
);
|
|
389
|
+
if (!outcome.ok) {
|
|
390
|
+
// A bare transport failure is transient; a refusal is not.
|
|
391
|
+
if (outcome.status === 401 || outcome.status === 403) {
|
|
392
|
+
throw new Error(`Cursor session refresh was rejected (${outcome.status})`);
|
|
393
|
+
}
|
|
394
|
+
throw new Error(outcome.message);
|
|
395
|
+
}
|
|
396
|
+
if (outcome.body["shouldLogout"] === true) {
|
|
397
|
+
const code = outcome.body["error"];
|
|
398
|
+
throw new Error(
|
|
399
|
+
code === SIGN_IN_POLICY_VIOLATION
|
|
400
|
+
? `Cursor session blocked by sign-in policy (${SIGN_IN_POLICY_VIOLATION})`
|
|
401
|
+
: "Cursor ended the session (shouldLogout) — sign in again",
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
const access = nonEmptyTrimmedString(outcome.body["access_token"]);
|
|
405
|
+
if (access === undefined) throw new Error("Cursor session refresh returned no access token");
|
|
406
|
+
const expiry = decodeJwtExpiryMs(access);
|
|
407
|
+
return {
|
|
408
|
+
access,
|
|
409
|
+
// The grant issues no new refresh token: the IDE stores the access token
|
|
410
|
+
// as both, and so does this refresher.
|
|
411
|
+
refresh: refreshToken,
|
|
412
|
+
expiresAt: new Date(expiry ?? Date.now() + EXPIRY_FALLBACK_MS),
|
|
413
|
+
auth_state: { method: method ?? "login" },
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export const cursorOAuthClient = new CursorOAuthClient();
|