cartethyia 2.5.4 → 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.
Files changed (395) hide show
  1. package/.env.example +13 -4
  2. package/README.md +2 -2
  3. package/dist/dashboard/assets/{BannedPage-C2orBsI2.js → BannedPage-CADDq4IU.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-CADDq4IU.js.gz +0 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js +1 -0
  6. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-BV6pxVQo.js → CliToolsPage-DuO1Z7dq.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-DuO1Z7dq.js.gz +0 -0
  9. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js +1 -0
  10. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-CW-wPmJ6.js → ConsoleLogPage-BVdpfiz1.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-BVdpfiz1.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-0p8uX7vB.js → CustomizationPage-BR5HHvbi.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-BR5HHvbi.js.gz +0 -0
  15. package/dist/dashboard/assets/{HealthEventsModal-BjqSMfko.js → HealthEventsModal-mUfcknqm.js} +1 -1
  16. package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-BvzCx8Kr.js → LoginPage-V5faV9V2.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-V5faV9V2.js.gz +0 -0
  19. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
  20. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-Js9jUPSb.js → ProvidersPage-uPiGVTTK.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js.gz +0 -0
  27. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js +7 -0
  28. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-DDbMQsoo.js → SetupPage-UB690SE2.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-UB690SE2.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-CB3Z7VTf.js → SortableList-BLujdX7N.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-BLujdX7N.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-D95UCPHI.js → StudioPage-D5C2raAc.js} +3 -3
  38. package/dist/dashboard/assets/StudioPage-D5C2raAc.js.gz +0 -0
  39. package/dist/dashboard/assets/UsagePage-8uIcxa1W.js +56 -0
  40. package/dist/dashboard/assets/UsagePage-8uIcxa1W.js.gz +0 -0
  41. package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
  42. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  43. package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
  44. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  45. package/dist/dashboard/assets/{assert-Chi7pCnt.js → assert-C9FD_JwJ.js} +1 -1
  46. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  47. package/dist/dashboard/assets/{badge-D4pQapKj.js → badge-CR72dasT.js} +1 -1
  48. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  49. package/dist/dashboard/assets/{banner-DH6cg-_B.js → banner-CI9_eSfO.js} +1 -1
  50. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  51. package/dist/dashboard/assets/{card-BKqlUOe4.js → card-DS7CQEsO.js} +1 -1
  52. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  53. package/dist/dashboard/assets/{cli-tools-fTgljTGA.js → cli-tools-CNqzrfdb.js} +1 -1
  54. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  55. package/dist/dashboard/assets/{clipboard-button-D5Ys_sM0.js → clipboard-button-611pSsaX.js} +1 -1
  56. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  57. package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
  58. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  59. package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
  60. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  61. package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
  62. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CQBr6U9c.js → entry-B9Xvtnfj.js} +1 -1
  64. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
  66. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-DxBHnauy.js +4 -0
  68. package/dist/dashboard/assets/entry-DxBHnauy.js.gz +0 -0
  69. package/dist/dashboard/assets/{github-badge-BlptL8Uf.js → github-badge-sltk1qUi.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-js6TJvdr.js → index-CmxZr14F.js} +2 -2
  72. package/dist/dashboard/assets/index-CmxZr14F.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-DUWDSmDV.js → inline-BhY2sSnO.js} +1 -1
  74. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-LVjgZq4f.js → input-V1umFiBk.js} +1 -1
  76. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  77. package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
  78. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-DupvLfDx.js → list-row-1zzZYM0B.js} +1 -1
  80. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-C4pEJUqI.js → live-BivGfuUs.js} +1 -1
  82. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-BRiuufkB.js → model-bans-CmffjLuG.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-B4ROwk8L.js → mutation-46P0oWMe.js} +1 -1
  86. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  87. package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
  88. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-gmjzC3RS.js → query-keys-BFhGpwR1.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-C7tBT5o-.js → routing-CAb4F37R.js} +1 -1
  92. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-WUCXorXw.js → select-BYR_5K2c.js} +1 -1
  94. package/dist/dashboard/assets/select-BYR_5K2c.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-C_zKl1GS.js → settings-DNX9sydi.js} +1 -1
  96. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-_3O0F8Xn.js → stack-BHTfXdz_.js} +1 -1
  98. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-BgTxHcJS.js → state-mPDRlQkl.js} +1 -1
  100. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DEjTtOWf.js → switch-DOmJxg7H.js} +1 -1
  102. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  103. package/dist/dashboard/assets/tokens-COzario9.js +1 -0
  104. package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
  105. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
  106. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
  107. package/dist/dashboard/assets/{useMutation-BWSC0UAS.js → useMutation-Dem0lXQj.js} +1 -1
  108. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  109. package/dist/dashboard/index.html +1 -1
  110. package/dist/dashboard/index.html.gz +0 -0
  111. package/dist/dashboard/precompressed-assets.json +54 -54
  112. package/migrations/README.md +52 -52
  113. package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
  114. package/package.json +96 -95
  115. package/scripts/cli/admin.ts +125 -0
  116. package/scripts/cli/{ui/background.ts → background.ts} +1 -1
  117. package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -244
  118. package/scripts/cli/doctor.ts +37 -0
  119. package/scripts/cli/{support/env.ts → env.ts} +99 -99
  120. package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
  121. package/scripts/cli/instance.ts +224 -0
  122. package/scripts/cli/main.ts +61 -18
  123. package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
  124. package/scripts/cli/port.ts +100 -0
  125. package/scripts/cli/{support/services.ts → services.ts} +88 -88
  126. package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
  127. package/scripts/cli/{command/start.ts → start.ts} +10 -5
  128. package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
  129. package/scripts/cli/tray/index.ts +62 -12
  130. package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
  131. package/scripts/cli/{command/update.ts → update.ts} +39 -39
  132. package/scripts/cli/version.ts +67 -0
  133. package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
  134. package/src/boot/app.ts +697 -693
  135. package/src/boot/dependencies.ts +647 -646
  136. package/src/boot/timeout.ts +2 -13
  137. package/src/config/cooldowns.ts +186 -138
  138. package/src/config/network.ts +8 -14
  139. package/src/config/providers.ts +7 -10
  140. package/src/config/runtime.ts +76 -76
  141. package/src/config/security.ts +15 -0
  142. package/src/config/spec.ts +20 -30
  143. package/src/config/telemetry.ts +0 -20
  144. package/src/config/timeouts.ts +0 -6
  145. package/src/console/admin/model-abuse/contracts.ts +28 -6
  146. package/src/console/admin/model-abuse/routes.ts +56 -3
  147. package/src/console/api-keys/contracts.ts +410 -414
  148. package/src/console/api-keys/routes.ts +881 -880
  149. package/src/console/auth/credentials.ts +3 -3
  150. package/src/console/auth/setup-service.ts +2 -2
  151. package/src/console/backup/export-endpoint.ts +6 -2
  152. package/src/console/backup/nine-router.ts +2 -5
  153. package/src/console/backup/service.ts +4 -0
  154. package/src/console/backup/store.ts +192 -29
  155. package/src/console/backup/validate.ts +3 -45
  156. package/src/console/cli-tools/fs-ops.ts +487 -487
  157. package/src/console/console-router-guards.ts +1 -1
  158. package/src/console/data-management/contracts.ts +13 -6
  159. package/src/console/data-management/operations.ts +23 -18
  160. package/src/console/data-management/routes.ts +0 -1
  161. package/src/console/domain-registration.ts +10 -5
  162. package/src/console/observability/live.ts +22 -0
  163. package/src/console/observability/store.ts +39 -16
  164. package/src/console/observability/usage-dimensions.ts +4 -4
  165. package/src/console/providers/catalog/catalog-projections.ts +6 -7
  166. package/src/console/providers/catalog/model-operations.ts +5 -2
  167. package/src/console/providers/catalog/provider-operations.ts +4 -4
  168. package/src/console/providers/catalog/public-model-store.ts +8 -11
  169. package/src/console/providers/catalog/store.ts +1553 -1553
  170. package/src/console/providers/oauth/routes.ts +8 -12
  171. package/src/console/quota/account-quota-tenant.ts +6 -6
  172. package/src/console/quota/quota-view.ts +1 -1
  173. package/src/console/remote-support/contracts.ts +61 -0
  174. package/src/console/remote-support/routes.ts +65 -0
  175. package/src/console/remote-support/service.ts +207 -0
  176. package/src/console/response-compression.ts +0 -3
  177. package/src/console/routing/pools/probe-result.ts +3 -2
  178. package/src/console/routing/pools/probe-verdict.ts +132 -132
  179. package/src/console/routing/pools/store.ts +8 -8
  180. package/src/console/settings/contracts.ts +58 -2
  181. package/src/console/settings/store.ts +30 -3
  182. package/src/console/shared/cursor.ts +8 -8
  183. package/src/console/shared/errors.ts +1 -3
  184. package/src/network/http2-bidi.ts +2 -4
  185. package/src/network/pool/agent.ts +478 -478
  186. package/src/network/pool/selector.ts +5 -4
  187. package/src/network/retry.ts +1 -2
  188. package/src/network/ssrf.ts +5 -4
  189. package/src/observability/event-compaction.ts +80 -78
  190. package/src/observability/event-day-index.ts +13 -15
  191. package/src/observability/event-log.ts +4 -4
  192. package/src/observability/logger.ts +6 -6
  193. package/src/observability/payload-capture.ts +151 -33
  194. package/src/observability/payload-store.ts +397 -440
  195. package/src/observability/redaction.ts +5 -6
  196. package/src/observability/request-notify.ts +43 -0
  197. package/src/observability/telemetry-status.ts +8 -9
  198. package/src/observability/token-speed.ts +5 -4
  199. package/src/persistence/db-mode.ts +7 -7
  200. package/src/persistence/readiness.ts +1 -1
  201. package/src/persistence/schema.ts +1406 -1402
  202. package/src/persistence/shared-cache.ts +1 -2
  203. package/src/persistence/stores/api-key-store.ts +18 -6
  204. package/src/persistence/stores/share-store.ts +4 -3
  205. package/src/persistence/stores/telemetry-store.ts +236 -237
  206. package/src/protocol/canonical-model.ts +11 -3
  207. package/src/protocol/codecs.ts +138 -4
  208. package/src/protocol/gateway-error.ts +6 -4
  209. package/src/protocol/request/chat.ts +21 -1
  210. package/src/protocol/request/codex.ts +10 -1
  211. package/src/protocol/request/messages.ts +4 -5
  212. package/src/protocol/request/responses.ts +9 -1
  213. package/src/protocol/response/chat.ts +34 -4
  214. package/src/protocol/response/codex.ts +7 -4
  215. package/src/protocol/response/dsml.ts +140 -28
  216. package/src/protocol/response/responses.ts +11 -5
  217. package/src/protocol/stream-error-frames.ts +4 -5
  218. package/src/providers/authentication/jwt-validator.ts +9 -10
  219. package/src/providers/authentication/oauth-refresh-service.ts +9 -11
  220. package/src/providers/configured-openai-providers.ts +134 -136
  221. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  222. package/src/providers/discovery/catalog/catalog-format.ts +3 -6
  223. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  224. package/src/providers/discovery/probe-phases.ts +4 -4
  225. package/src/providers/discovery/probe-wire.ts +6 -13
  226. package/src/providers/discovery/probing-service.ts +1117 -1045
  227. package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
  228. package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
  231. package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
  232. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
  233. package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
  234. package/src/providers/integrations/buddy/buddy-media.ts +2 -2
  235. package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
  236. package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
  237. package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
  238. package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
  239. package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
  240. package/src/providers/integrations/claude/claude.ts +608 -608
  241. package/src/providers/integrations/cline/cline-quota.ts +132 -132
  242. package/src/providers/integrations/cline/cline.ts +537 -539
  243. package/src/providers/integrations/codex/codex-oauth.ts +1 -1
  244. package/src/providers/integrations/commandcode/commandcode.ts +674 -675
  245. package/src/providers/integrations/commandcode/discovery.ts +117 -116
  246. package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
  247. package/src/providers/integrations/cursor/cursor-quota.ts +309 -310
  248. package/src/providers/integrations/cursor/cursor.ts +11 -163
  249. package/src/providers/integrations/factory/factory-models.ts +3 -5
  250. package/src/providers/integrations/grok/grok-install-id.ts +2 -3
  251. package/src/providers/integrations/grok/grok.ts +6 -7
  252. package/src/providers/integrations/hermes.ts +14 -18
  253. package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
  254. package/src/providers/integrations/kiro/kiro.ts +1 -1
  255. package/src/providers/integrations/mistral.ts +6 -6
  256. package/src/providers/integrations/opencode/fingerprint.ts +3 -3
  257. package/src/providers/integrations/opencode/provider.ts +8 -8
  258. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
  259. package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
  260. package/src/providers/integrations/qoder/qoder.ts +884 -865
  261. package/src/providers/integrations/shared/connect.ts +6 -6
  262. package/src/providers/integrations/trae/trae-catalog.ts +1 -2
  263. package/src/providers/integrations/trae/trae.ts +2 -2
  264. package/src/providers/integrations/zcode/oauth.ts +6 -1
  265. package/src/providers/operations/account-health-service.ts +1528 -1523
  266. package/src/providers/operations/account-reset-service.ts +1 -1
  267. package/src/providers/operations/client-versions.ts +6 -6
  268. package/src/providers/operations/custom-cli-headers.ts +3 -4
  269. package/src/providers/operations/provider-catalog-seeder.ts +95 -95
  270. package/src/providers/operations/provider-catalog-service.ts +3 -4
  271. package/src/providers/provider-metadata.ts +4 -8
  272. package/src/providers/provider-registry.ts +6 -7
  273. package/src/providers/provider-usage.ts +1 -1
  274. package/src/security/abuse.ts +15 -10
  275. package/src/security/access-control.ts +144 -146
  276. package/src/security/admission/contracts.ts +11 -0
  277. package/src/security/admission/in-memory-store.ts +4 -3
  278. package/src/security/admission/lease-sweep.ts +6 -6
  279. package/src/security/admission/reasons.ts +5 -1
  280. package/src/security/admission/redis-store.ts +211 -210
  281. package/src/security/admission/service.ts +4 -0
  282. package/src/security/api-key-auth.ts +504 -504
  283. package/src/security/ip-boundary.ts +155 -15
  284. package/src/security/model-abuse.ts +366 -94
  285. package/src/security/outbound-headers.ts +3 -4
  286. package/src/shared/sleep.ts +4 -0
  287. package/src/transport/dispatch/attempt-finalize.ts +47 -5
  288. package/src/transport/dispatch/attempt-loop.ts +531 -530
  289. package/src/transport/dispatch/stream-bridge.ts +1 -3
  290. package/src/transport/dispatch/streaming/finalize.ts +1 -1
  291. package/src/transport/dispatch/streaming/stream.ts +5 -6
  292. package/src/transport/failure-policy.ts +162 -8
  293. package/src/transport/guards/auth-guard.ts +7 -4
  294. package/src/transport/guards/concurrency-guard.ts +0 -5
  295. package/src/transport/guards/error-lifecycle.ts +13 -6
  296. package/src/transport/guards/pipeline.ts +15 -3
  297. package/src/transport/guards/request-context.ts +8 -2
  298. package/src/transport/request/inflight.ts +5 -6
  299. package/src/transport/request/preparer.ts +2 -2
  300. package/src/transport/request/state.ts +12 -8
  301. package/src/transport/routing/admission.ts +4 -0
  302. package/src/transport/routing/route-catalog.ts +8 -1
  303. package/src/transport/routing/route-model.ts +46 -14
  304. package/src/transport/routing/router.ts +21 -14
  305. package/src/transport/surface/completion.ts +2 -3
  306. package/src/transport/surface/dialects.ts +5 -6
  307. package/src/transport/surface/responses/encode.ts +38 -0
  308. package/src/transport/surface/responses/parse.ts +4 -0
  309. package/src/transport/translation/capabilities.ts +2 -3
  310. package/src/transport/translation/quirks.ts +3 -3
  311. package/src/workers/buddy-growth-tasks.ts +5 -10
  312. package/src/workers/daily-checkin.ts +17 -23
  313. package/src/workers/quota-refresh-sweep.ts +26 -26
  314. package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
  315. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +0 -1
  316. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
  317. package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
  318. package/dist/dashboard/assets/CombosPage-Bsan4679.js +0 -1
  319. package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
  320. package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
  321. package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
  322. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
  323. package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
  324. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js +0 -1
  325. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
  326. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +0 -1
  327. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
  328. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +0 -3
  329. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
  330. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
  331. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +0 -7
  332. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
  333. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +0 -1
  334. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
  335. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +0 -1
  336. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
  337. package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
  338. package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
  339. package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
  340. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js +0 -56
  341. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
  342. package/dist/dashboard/assets/api-keys-VY68Mhd1.js +0 -1
  343. package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
  344. package/dist/dashboard/assets/app-COlK95oi.css +0 -2
  345. package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
  346. package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
  347. package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
  348. package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
  349. package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
  350. package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
  351. package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
  352. package/dist/dashboard/assets/dialog-BYj-8WuK.js +0 -1
  353. package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
  354. package/dist/dashboard/assets/drawer-CmXIZ6H1.js +0 -1
  355. package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
  356. package/dist/dashboard/assets/entry-CBg5zeu8.js +0 -1
  357. package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
  358. package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-CgdSAzEV.js +0 -4
  360. package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
  361. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
  363. package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
  365. package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-CPfxpsfy.js +0 -1
  367. package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
  368. package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
  369. package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
  370. package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
  371. package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
  372. package/dist/dashboard/assets/numbers-YNcHvRY7.js +0 -2
  373. package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
  374. package/dist/dashboard/assets/query-keys-gmjzC3RS.js.gz +0 -0
  375. package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
  376. package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
  377. package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
  378. package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
  379. package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
  380. package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
  381. package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
  382. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  383. package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
  384. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  385. package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
  386. package/scripts/cli/command/admin.ts +0 -65
  387. package/scripts/cli/command/doctor.ts +0 -257
  388. package/scripts/cli/gateway/drain-token.ts +0 -78
  389. package/scripts/cli/gateway/instance-lock.ts +0 -105
  390. package/scripts/cli/gateway/stop.ts +0 -51
  391. package/scripts/cli/support/bun.ts +0 -27
  392. package/scripts/cli/support/version.ts +0 -37
  393. /package/dist/dashboard/assets/{app-DCQZRmHY.js → app-BIt446yE.js} +0 -0
  394. /package/dist/dashboard/assets/{app-DCQZRmHY.js.gz → app-BIt446yE.js.gz} +0 -0
  395. /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 the login used to fall back to the
106
- * provider id — every account of the provider then looked identical, and adding
107
- * a second one replaced the first. `sub` (`auth0|user_01…`) is stable across
108
- * every login of one account and differs between accounts, which is exactly
109
- * what an account 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();
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();