cartethyia 2.8.0 → 2.8.1

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 (348) hide show
  1. package/.env.example +3 -3
  2. package/dist/dashboard/assets/{BannedPage-Cc0vKpMG.js → BannedPage-Dq530awD.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-Dq530awD.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-Bqj2aa1T.js → CliToolDetailPage-DZzaS-7S.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-DZzaS-7S.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-CXsaOAO2.js → CliToolsPage-DaZZ3cqP.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-DaZZ3cqP.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-BeLpJ4ma.js → CombosPage-BlC_xdkX.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-BlC_xdkX.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-DnV0uZ3m.js → ConsoleLogPage-D-rqeVtG.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-D-rqeVtG.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-CBxAjo63.js → CustomizationPage-7xT5yg2E.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-7xT5yg2E.js.gz +0 -0
  14. package/dist/dashboard/assets/{HealthEventsModal-B5yEE0k6.js → HealthEventsModal-Clq8E9cW.js} +1 -1
  15. package/dist/dashboard/assets/HealthEventsModal-Clq8E9cW.js.gz +0 -0
  16. package/dist/dashboard/assets/{InjectSystemPromptPanel-BWFLECmz.js → InjectSystemPromptPanel-6Teoq9Se.js} +1 -1
  17. package/dist/dashboard/assets/InjectSystemPromptPanel-6Teoq9Se.js.gz +0 -0
  18. package/dist/dashboard/assets/{LoginPage-CgoPc5B9.js → LoginPage-CZuI0MAR.js} +1 -1
  19. package/dist/dashboard/assets/LoginPage-CZuI0MAR.js.gz +0 -0
  20. package/dist/dashboard/assets/{ModelPicker-D9bFQern.js → ModelPicker-C1hNTlkr.js} +1 -1
  21. package/dist/dashboard/assets/ModelPicker-C1hNTlkr.js.gz +0 -0
  22. package/dist/dashboard/assets/{OverviewPage-B_p4tL8k.js → OverviewPage-0yfhSI1C.js} +1 -1
  23. package/dist/dashboard/assets/OverviewPage-0yfhSI1C.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-xqTxdQ4c.js +8 -0
  25. package/dist/dashboard/assets/ProviderDetailPage-xqTxdQ4c.js.gz +0 -0
  26. package/dist/dashboard/assets/{ProviderIcon-D_0PiZtg.js → ProviderIcon-Ddhm1InL.js} +1 -1
  27. package/dist/dashboard/assets/ProviderIcon-Ddhm1InL.js.gz +0 -0
  28. package/dist/dashboard/assets/{ProvidersPage-CLI2bJTK.js → ProvidersPage-C3fYuu6i.js} +1 -1
  29. package/dist/dashboard/assets/ProvidersPage-C3fYuu6i.js.gz +0 -0
  30. package/dist/dashboard/assets/ProxyPage-CHtJtZ3u.js +9 -0
  31. package/dist/dashboard/assets/ProxyPage-CHtJtZ3u.js.gz +0 -0
  32. package/dist/dashboard/assets/{QuotaPage-DA-a1iSN.js → QuotaPage-D1RV_2BX.js} +1 -1
  33. package/dist/dashboard/assets/QuotaPage-D1RV_2BX.js.gz +0 -0
  34. package/dist/dashboard/assets/{SettingsPage-CctTga2C.js → SettingsPage-BzZAvYSX.js} +1 -1
  35. package/dist/dashboard/assets/SettingsPage-BzZAvYSX.js.gz +0 -0
  36. package/dist/dashboard/assets/{SetupPage-febLU4r1.js → SetupPage--s-EfEIL.js} +1 -1
  37. package/dist/dashboard/assets/SetupPage--s-EfEIL.js.gz +0 -0
  38. package/dist/dashboard/assets/{StudioPage-M6fvR5Wz.js → StudioPage-BjXFGSK4.js} +1 -1
  39. package/dist/dashboard/assets/StudioPage-BjXFGSK4.js.gz +0 -0
  40. package/dist/dashboard/assets/{UsagePage-nyeuRqz9.js → UsagePage-CP8F8BN7.js} +1 -1
  41. package/dist/dashboard/assets/UsagePage-CP8F8BN7.js.gz +0 -0
  42. package/dist/dashboard/assets/{api-keys-BFOlvrJC.js → api-keys-2hc12Uz2.js} +1 -1
  43. package/dist/dashboard/assets/api-keys-2hc12Uz2.js.gz +0 -0
  44. package/dist/dashboard/assets/app-DGXbNfAR.css +2 -0
  45. package/dist/dashboard/assets/app-DGXbNfAR.css.gz +0 -0
  46. package/dist/dashboard/assets/{assert-CqTLw5e7.js → assert-BMcj1xZE.js} +1 -1
  47. package/dist/dashboard/assets/assert-BMcj1xZE.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-C83EqLB0.js → badge-DkRUuVfz.js} +1 -1
  49. package/dist/dashboard/assets/badge-DkRUuVfz.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-D1xn7pIe.js → banner-CDjW841Q.js} +1 -1
  51. package/dist/dashboard/assets/banner-CDjW841Q.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-DU3Lq5Lm.js → card-Bto5UlL9.js} +1 -1
  53. package/dist/dashboard/assets/card-Bto5UlL9.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-Dw1U_drK.js → cli-tools-DaH6g9hL.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-DaH6g9hL.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-CEL5Sg_J.js → clipboard-button-BYKBCzjk.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-BYKBCzjk.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-BjFR5UHy.js → dialog-DL0Zftwv.js} +1 -1
  59. package/dist/dashboard/assets/dialog-DL0Zftwv.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-HvBW0AjI.js → drawer-Dpx_UXaS.js} +1 -1
  61. package/dist/dashboard/assets/drawer-Dpx_UXaS.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-CECVELyy.js → entry-BV7-1NoM.js} +1 -1
  63. package/dist/dashboard/assets/entry-BV7-1NoM.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-DYYofQ7W.js → entry-CGvdNU6r.js} +1 -1
  65. package/dist/dashboard/assets/entry-CGvdNU6r.js.gz +0 -0
  66. package/dist/dashboard/assets/{entry-BSu3gFlt.js → entry-DScKYvZj.js} +3 -3
  67. package/dist/dashboard/assets/entry-DScKYvZj.js.gz +0 -0
  68. package/dist/dashboard/assets/{formatters-DLW4i8zK.js → formatters-BS3DZlZj.js} +1 -1
  69. package/dist/dashboard/assets/formatters-BS3DZlZj.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-1-fCqe64.js → index-SQnvvTWX.js} +2 -2
  71. package/dist/dashboard/assets/index-SQnvvTWX.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-DGvU2oLK.js → inline-Dl-cuLK0.js} +1 -1
  73. package/dist/dashboard/assets/inline-Dl-cuLK0.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-iNnO1X2K.js → input-Bfx0EzRc.js} +1 -1
  75. package/dist/dashboard/assets/input-Bfx0EzRc.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-0q8UGypb.js → layout-BxhbQ_JS.js} +1 -1
  77. package/dist/dashboard/assets/layout-BxhbQ_JS.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-C0w2bQjJ.js → list-row-D49Y4ptn.js} +1 -1
  79. package/dist/dashboard/assets/list-row-D49Y4ptn.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-M5aJ_xCa.js → live-CbVTdX9V.js} +1 -1
  81. package/dist/dashboard/assets/live-CbVTdX9V.js.gz +0 -0
  82. package/dist/dashboard/assets/{network-BKeQGT3K.js → network-JGvDveof.js} +1 -1
  83. package/dist/dashboard/assets/network-JGvDveof.js.gz +0 -0
  84. package/dist/dashboard/assets/{numbers-CivAk3da.js → numbers-Dw6kiew-.js} +1 -1
  85. package/dist/dashboard/assets/numbers-Dw6kiew-.js.gz +0 -0
  86. package/dist/dashboard/assets/{pagination-CpzvI5ep.js → pagination-iUoI8GeE.js} +1 -1
  87. package/dist/dashboard/assets/pagination-iUoI8GeE.js.gz +0 -0
  88. package/dist/dashboard/assets/providers/sensenova-intl.png +0 -0
  89. package/dist/dashboard/assets/providers/sensenova.png +0 -0
  90. package/dist/dashboard/assets/{routing-BFQX2VBc.js → routing-8jZr-3WE.js} +1 -1
  91. package/dist/dashboard/assets/routing-8jZr-3WE.js.gz +0 -0
  92. package/dist/dashboard/assets/{select-CXbck0jG.js → select-oO1FxMVb.js} +1 -1
  93. package/dist/dashboard/assets/select-oO1FxMVb.js.gz +0 -0
  94. package/dist/dashboard/assets/{settings-C_XvMHh9.js → settings-CLr3kdIx.js} +1 -1
  95. package/dist/dashboard/assets/settings-CLr3kdIx.js.gz +0 -0
  96. package/dist/dashboard/assets/{stack-DggRiCxE.js → stack-Y3pDteZr.js} +1 -1
  97. package/dist/dashboard/assets/stack-Y3pDteZr.js.gz +0 -0
  98. package/dist/dashboard/assets/{state-ClFzfbYp.js → state-CArX_Iak.js} +1 -1
  99. package/dist/dashboard/assets/state-CArX_Iak.js.gz +0 -0
  100. package/dist/dashboard/assets/{switch-CTIFxKqS.js → switch-DwlwmDO-.js} +1 -1
  101. package/dist/dashboard/assets/switch-DwlwmDO-.js.gz +0 -0
  102. package/dist/dashboard/assets/{useMutation-BClG3yWj.js → useMutation-DJ9GYEgf.js} +1 -1
  103. package/dist/dashboard/assets/useMutation-DJ9GYEgf.js.gz +0 -0
  104. package/dist/dashboard/index.html +2 -2
  105. package/dist/dashboard/index.html.gz +0 -0
  106. package/dist/dashboard/precompressed-assets.json +51 -51
  107. package/migrations/full-postgres/0047_add_provider_device_flow_proxy.sql +17 -0
  108. package/migrations/lite-sqlite3/0047_add_provider_device_flow_proxy.sql +19 -0
  109. package/package.json +1 -1
  110. package/src/boot/app.ts +1 -1
  111. package/src/boot/dependencies.ts +4 -6
  112. package/src/config/providers.ts +6 -5
  113. package/src/config/spec.ts +8 -8
  114. package/src/console/backup/nine-router.ts +71 -10
  115. package/src/console/backup/service.ts +8 -1
  116. package/src/console/console-router.ts +1 -1
  117. package/src/console/domain-registration.ts +7 -2
  118. package/src/console/inferhub/client.ts +1 -1
  119. package/src/console/observability/live.ts +2 -2
  120. package/src/console/providers/catalog/contracts.ts +9 -3
  121. package/src/console/providers/catalog/model-operations.ts +2 -3
  122. package/src/console/providers/catalog/provider-operations.ts +1 -1
  123. package/src/console/providers/catalog/routes.ts +2 -2
  124. package/src/console/providers/catalog/store.ts +1914 -1907
  125. package/src/console/providers/detail/routes.ts +1 -0
  126. package/src/console/providers/detail/store.ts +4 -0
  127. package/src/console/providers/oauth/callback-listener.ts +30 -11
  128. package/src/console/providers/oauth/device-egress.ts +123 -0
  129. package/src/console/providers/oauth/routes.ts +123 -16
  130. package/src/console/quota/account-quota-tenant.ts +5 -5
  131. package/src/console/quota/account-test-egress.ts +2 -2
  132. package/src/console/quota/daily-growth-job.ts +1 -1
  133. package/src/console/routing/pools/contracts.ts +1 -1
  134. package/src/console/routing/pools/operations.ts +1 -1
  135. package/src/console/routing/pools/probe-verdict.ts +2 -2
  136. package/src/console/routing/pools/relay-deploy.ts +1 -1
  137. package/src/console/routing/pools/store.ts +4 -4
  138. package/src/network/client.ts +67 -0
  139. package/src/network/egress/bidi-session.ts +322 -0
  140. package/src/network/egress/dial.ts +201 -0
  141. package/src/network/egress/factory.ts +191 -0
  142. package/src/network/{outbound-fetch.ts → egress/fetch.ts} +195 -29
  143. package/src/network/egress/tls-profile.ts +123 -0
  144. package/src/network/egress/ws.ts +175 -0
  145. package/src/network/{types.ts → pool/agent-config.ts} +1 -1
  146. package/src/network/pool/agent.ts +2 -2
  147. package/src/network/{pool-cooldown-store.ts → pool/cooldown-store.ts} +7 -7
  148. package/src/network/{pool-health-machine.ts → pool/health.ts} +205 -6
  149. package/src/network/pool/resolver.ts +10 -849
  150. package/src/network/pool/selector.ts +2 -2
  151. package/src/network/ssrf.ts +1 -1
  152. package/src/network/{retry.ts → util/retry.ts} +1 -1
  153. package/src/observability/telemetry-buffer.ts +1 -1
  154. package/src/persistence/schema.ts +9 -0
  155. package/src/persistence/ttl-cache.ts +1 -1
  156. package/src/protocol/codecs.ts +58 -1
  157. package/src/protocol/request/messages.ts +31 -18
  158. package/src/protocol/response/messages.ts +34 -7
  159. package/src/protocol/translation/quirks.ts +15 -0
  160. package/src/protocol/upstream/send-claude-messages.ts +2 -2
  161. package/src/providers/adapters/compatible-adapter.ts +5 -0
  162. package/src/providers/authentication/oauth-client.ts +40 -6
  163. package/src/providers/authentication/oauth-device-flow.ts +7 -3
  164. package/src/providers/authentication/oauth-flow-store.ts +82 -1
  165. package/src/providers/authentication/oauth-refresh-service.ts +766 -739
  166. package/src/providers/default-registry.ts +76 -0
  167. package/src/providers/discovery/discovery-types.ts +7 -0
  168. package/src/providers/discovery/probe-phases.ts +6 -2
  169. package/src/providers/discovery/probing-service.ts +7 -0
  170. package/src/providers/integrations/antigravity/antigravity-protocol.ts +11 -0
  171. package/src/providers/integrations/buddy/account.ts +969 -0
  172. package/src/providers/integrations/buddy/capabilities.ts +10 -10
  173. package/src/providers/integrations/buddy/{buddy-catalog-shared.ts → catalog.ts} +198 -136
  174. package/src/providers/integrations/buddy/{buddy-chat-shared.ts → chat.ts} +5 -5
  175. package/src/providers/integrations/buddy/codebuddy/index.ts +429 -0
  176. package/src/providers/integrations/buddy/codebuddy/shared.ts +4 -4
  177. package/src/providers/integrations/buddy/{buddy-identity.ts → headers.ts} +138 -140
  178. package/src/providers/integrations/buddy/lifecycle.ts +1018 -0
  179. package/src/providers/integrations/buddy/{buddy-media.ts → media.ts} +1 -1
  180. package/src/providers/integrations/cline/cline-oauth.ts +10 -5
  181. package/src/providers/integrations/cline/cline.ts +3 -4
  182. package/src/providers/integrations/clixad/clixad-oauth.ts +98 -0
  183. package/src/providers/integrations/clixad/clixad-quota.ts +132 -0
  184. package/src/providers/integrations/clixad/provider.ts +145 -0
  185. package/src/providers/integrations/codex/codex-oauth.ts +8 -5
  186. package/src/providers/integrations/cursor/cursor-auth.ts +1 -1
  187. package/src/providers/integrations/cursor/cursor-hardening.ts +279 -0
  188. package/src/providers/integrations/cursor/cursor-http2.ts +605 -6
  189. package/src/providers/integrations/cursor/cursor-shared.ts +1 -1
  190. package/src/providers/integrations/cursor/cursor.ts +485 -70
  191. package/src/providers/integrations/deepai/deepai-catalog.ts +149 -0
  192. package/src/providers/integrations/deepai/deepai-common.ts +145 -0
  193. package/src/providers/integrations/deepai/deepai-oauth.ts +129 -0
  194. package/src/providers/integrations/deepai/deepai-quota.ts +97 -0
  195. package/src/providers/integrations/deepai/provider.ts +42 -0
  196. package/src/providers/integrations/devin/devin-auth.ts +7 -0
  197. package/src/providers/integrations/factory/factory-oauth.ts +16 -7
  198. package/src/providers/integrations/freebuff/freebuff.ts +2 -2
  199. package/src/providers/integrations/freepi/freepi-catalog.ts +5 -0
  200. package/src/providers/integrations/freepi/freepi-discovery.ts +2 -2
  201. package/src/providers/integrations/freepi/freepi-headers.ts +2 -2
  202. package/src/providers/integrations/freepi/freepi-protocol.ts +11 -2
  203. package/src/providers/integrations/freepi/freepi-realm.ts +0 -3
  204. package/src/providers/integrations/freepi/freepi-session.ts +158 -0
  205. package/src/providers/integrations/freepi/freepi.ts +80 -54
  206. package/src/providers/integrations/github/github-discovery.ts +30 -13
  207. package/src/providers/integrations/github/github-oauth.ts +217 -44
  208. package/src/providers/integrations/github/github-quota.ts +22 -5
  209. package/src/providers/integrations/github/github.ts +40 -32
  210. package/src/providers/integrations/grok/grok-oauth.ts +4 -4
  211. package/src/providers/integrations/hermes/hermes-oauth.ts +3 -3
  212. package/src/providers/integrations/hermes/hermes.ts +34 -5
  213. package/src/providers/integrations/kilo/kilo-oauth.ts +4 -4
  214. package/src/providers/integrations/kilo/kilo.ts +1 -1
  215. package/src/providers/integrations/kimi/kimi-oauth.ts +3 -3
  216. package/src/providers/integrations/kiro/kiro-oauth.ts +2 -2
  217. package/src/providers/integrations/muse/muse-oauth.ts +5 -5
  218. package/src/providers/integrations/opencode/fingerprint.ts +1 -1
  219. package/src/providers/integrations/openrouter/provider.ts +1 -1
  220. package/src/providers/integrations/perch/perch-protocol.ts +0 -3
  221. package/src/providers/integrations/perch/perch.ts +3 -3
  222. package/src/providers/integrations/qoder/claim-qoder-credits.ts +1 -1
  223. package/src/providers/integrations/qoder/qoder-adapter.ts +19 -113
  224. package/src/providers/integrations/qoder/qoder-auth.ts +8 -1
  225. package/src/providers/integrations/qoder/qoder-catalog.ts +1 -1
  226. package/src/providers/integrations/qoder/qoder-chat.ts +25 -8
  227. package/src/providers/integrations/qoder/qoder-checkin.ts +1 -1
  228. package/src/providers/integrations/qoder/qoder-cosy.ts +37 -9
  229. package/src/providers/integrations/qoder/qoder-oauth.ts +9 -2
  230. package/src/providers/integrations/qoder/qoder-realm.ts +13 -36
  231. package/src/providers/integrations/qoder/qoder-sse.ts +447 -68
  232. package/src/providers/integrations/qoder/qoder-types.ts +9 -0
  233. package/src/providers/integrations/qoder/qoder.ts +3 -25
  234. package/src/providers/integrations/sensenova/provider.ts +144 -0
  235. package/src/providers/integrations/trae/trae-quota.ts +15 -1
  236. package/src/providers/integrations/vercel/provider.ts +117 -0
  237. package/src/providers/integrations/vercel/vercel-quota.ts +96 -0
  238. package/src/providers/integrations/xai/xai-oauth.ts +5 -5
  239. package/src/providers/integrations/zcode-start-plan/gifts.ts +1 -1
  240. package/src/providers/integrations/zcode-start-plan/oauth.ts +3 -3
  241. package/src/providers/integrations/zro/zro.ts +2 -1
  242. package/src/providers/operations/account-refresh-status.ts +14 -0
  243. package/src/providers/operations/client-versions.ts +95 -30
  244. package/src/providers/operations/oauth-refresh-lead.ts +46 -22
  245. package/src/providers/operations/provider-caches.ts +1 -1
  246. package/src/providers/operations/provider-credential-service.ts +22 -25
  247. package/src/providers/operations/session-resolution.ts +17 -1
  248. package/src/providers/provider-contracts.ts +2 -2
  249. package/src/providers/provider-metadata.ts +30 -0
  250. package/src/providers/quota/quota-contracts.ts +2 -0
  251. package/src/providers/quota/refresh.ts +25 -3
  252. package/src/security/ip-boundary.ts +1 -1
  253. package/src/transport/dispatch/attempt-finalize.ts +1 -1
  254. package/src/transport/dispatch/attempt-loop.ts +3 -3
  255. package/src/transport/dispatch/handler-deps.ts +1 -1
  256. package/src/transport/dispatch/leases.ts +1 -1
  257. package/src/transport/dispatch/retry-policy.ts +1 -1
  258. package/src/transport/dispatch/streaming/finalize.ts +2 -2
  259. package/src/transport/dispatch/streaming/stream.ts +1 -1
  260. package/src/transport/dispatch/upstream.ts +1 -1
  261. package/src/transport/shutdown-notice.ts +1 -1
  262. package/src/workers/buddy-growth-tasks.ts +8 -8
  263. package/src/workers/daily-checkin.ts +4 -4
  264. package/src/workers/quota-refresh-sweep.ts +1 -1
  265. package/src/workers/zcode-gift-claim.ts +1 -1
  266. package/dist/dashboard/assets/BannedPage-Cc0vKpMG.js.gz +0 -0
  267. package/dist/dashboard/assets/CliToolDetailPage-Bqj2aa1T.js.gz +0 -0
  268. package/dist/dashboard/assets/CliToolsPage-CXsaOAO2.js.gz +0 -0
  269. package/dist/dashboard/assets/CombosPage-BeLpJ4ma.js.gz +0 -0
  270. package/dist/dashboard/assets/ConsoleLogPage-DnV0uZ3m.js.gz +0 -0
  271. package/dist/dashboard/assets/CustomizationPage-CBxAjo63.js.gz +0 -0
  272. package/dist/dashboard/assets/HealthEventsModal-B5yEE0k6.js.gz +0 -0
  273. package/dist/dashboard/assets/InjectSystemPromptPanel-BWFLECmz.js.gz +0 -0
  274. package/dist/dashboard/assets/LoginPage-CgoPc5B9.js.gz +0 -0
  275. package/dist/dashboard/assets/ModelPicker-D9bFQern.js.gz +0 -0
  276. package/dist/dashboard/assets/OverviewPage-B_p4tL8k.js.gz +0 -0
  277. package/dist/dashboard/assets/ProviderDetailPage-rs5E2rxv.js +0 -8
  278. package/dist/dashboard/assets/ProviderDetailPage-rs5E2rxv.js.gz +0 -0
  279. package/dist/dashboard/assets/ProviderIcon-D_0PiZtg.js.gz +0 -0
  280. package/dist/dashboard/assets/ProvidersPage-CLI2bJTK.js.gz +0 -0
  281. package/dist/dashboard/assets/ProxyPage-BwNC_-Cx.js +0 -9
  282. package/dist/dashboard/assets/ProxyPage-BwNC_-Cx.js.gz +0 -0
  283. package/dist/dashboard/assets/QuotaPage-DA-a1iSN.js.gz +0 -0
  284. package/dist/dashboard/assets/SettingsPage-CctTga2C.js.gz +0 -0
  285. package/dist/dashboard/assets/SetupPage-febLU4r1.js.gz +0 -0
  286. package/dist/dashboard/assets/StudioPage-M6fvR5Wz.js.gz +0 -0
  287. package/dist/dashboard/assets/UsagePage-nyeuRqz9.js.gz +0 -0
  288. package/dist/dashboard/assets/api-keys-BFOlvrJC.js.gz +0 -0
  289. package/dist/dashboard/assets/app-Dx2QgsNj.css +0 -2
  290. package/dist/dashboard/assets/app-Dx2QgsNj.css.gz +0 -0
  291. package/dist/dashboard/assets/assert-CqTLw5e7.js.gz +0 -0
  292. package/dist/dashboard/assets/badge-C83EqLB0.js.gz +0 -0
  293. package/dist/dashboard/assets/banner-D1xn7pIe.js.gz +0 -0
  294. package/dist/dashboard/assets/card-DU3Lq5Lm.js.gz +0 -0
  295. package/dist/dashboard/assets/cli-tools-Dw1U_drK.js.gz +0 -0
  296. package/dist/dashboard/assets/clipboard-button-CEL5Sg_J.js.gz +0 -0
  297. package/dist/dashboard/assets/dialog-BjFR5UHy.js.gz +0 -0
  298. package/dist/dashboard/assets/drawer-HvBW0AjI.js.gz +0 -0
  299. package/dist/dashboard/assets/entry-BSu3gFlt.js.gz +0 -0
  300. package/dist/dashboard/assets/entry-CECVELyy.js.gz +0 -0
  301. package/dist/dashboard/assets/entry-DYYofQ7W.js.gz +0 -0
  302. package/dist/dashboard/assets/formatters-DLW4i8zK.js.gz +0 -0
  303. package/dist/dashboard/assets/index-1-fCqe64.js.gz +0 -0
  304. package/dist/dashboard/assets/inline-DGvU2oLK.js.gz +0 -0
  305. package/dist/dashboard/assets/input-iNnO1X2K.js.gz +0 -0
  306. package/dist/dashboard/assets/layout-0q8UGypb.js.gz +0 -0
  307. package/dist/dashboard/assets/list-row-C0w2bQjJ.js.gz +0 -0
  308. package/dist/dashboard/assets/live-M5aJ_xCa.js.gz +0 -0
  309. package/dist/dashboard/assets/network-BKeQGT3K.js.gz +0 -0
  310. package/dist/dashboard/assets/numbers-CivAk3da.js.gz +0 -0
  311. package/dist/dashboard/assets/pagination-CpzvI5ep.js.gz +0 -0
  312. package/dist/dashboard/assets/routing-BFQX2VBc.js.gz +0 -0
  313. package/dist/dashboard/assets/select-CXbck0jG.js.gz +0 -0
  314. package/dist/dashboard/assets/settings-C_XvMHh9.js.gz +0 -0
  315. package/dist/dashboard/assets/stack-DggRiCxE.js.gz +0 -0
  316. package/dist/dashboard/assets/state-ClFzfbYp.js.gz +0 -0
  317. package/dist/dashboard/assets/switch-CTIFxKqS.js.gz +0 -0
  318. package/dist/dashboard/assets/useMutation-BClG3yWj.js.gz +0 -0
  319. package/src/console/providers/catalog/discover-constants.ts +0 -6
  320. package/src/network/pool-health.ts +0 -198
  321. package/src/providers/integrations/buddy/buddy-checkin.ts +0 -521
  322. package/src/providers/integrations/buddy/buddy-cli-headers.ts +0 -76
  323. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -130
  324. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +0 -234
  325. package/src/providers/integrations/buddy/buddy-growth-shared.ts +0 -496
  326. package/src/providers/integrations/buddy/buddy-oauth-shared.ts +0 -341
  327. package/src/providers/integrations/buddy/buddy-quota-shared.ts +0 -417
  328. package/src/providers/integrations/buddy/buddy-server-tools.ts +0 -240
  329. package/src/providers/integrations/buddy/buddy-usage-shared.ts +0 -221
  330. package/src/providers/integrations/buddy/buddy-usage-store.ts +0 -181
  331. package/src/providers/integrations/buddy/codebuddy/cn-catalog.ts +0 -37
  332. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +0 -188
  333. package/src/providers/integrations/buddy/codebuddy/oauth.ts +0 -47
  334. package/src/providers/integrations/buddy/codebuddy/provider.ts +0 -179
  335. package/src/providers/integrations/buddy/codebuddy/quota.ts +0 -154
  336. package/src/providers/integrations/qoder/chat-proto.ts +0 -328
  337. package/src/providers/integrations/qoder/qoder-grpc.ts +0 -870
  338. package/src/providers/integrations/qoder/qoder-ide.ts +0 -481
  339. /package/dist/dashboard/assets/{app-DIXLjojg.js → app-C0gTXEoN.js} +0 -0
  340. /package/dist/dashboard/assets/{app-DIXLjojg.js.gz → app-C0gTXEoN.js.gz} +0 -0
  341. /package/src/network/{http2-bidi.ts → bidi.ts} +0 -0
  342. /package/src/network/pool/{byte-accounting.ts → accounting.ts} +0 -0
  343. /package/src/network/pool/{pool-scripts.ts → scripts.ts} +0 -0
  344. /package/src/network/pool/{pool-state.ts → state.ts} +0 -0
  345. /package/src/network/{backoff.ts → util/backoff.ts} +0 -0
  346. /package/src/network/{deduplication.ts → util/dedupe.ts} +0 -0
  347. /package/src/network/{ip-utils.ts → util/ip.ts} +0 -0
  348. /package/src/network/{rate-limit-verdict.ts → verdict.ts} +0 -0
@@ -1,739 +1,766 @@
1
- /**
2
- * OAuth token refresh orchestration: in-process single-flight plus a
3
- * Postgres lease-fenced compare-and-swap.
4
- *
5
- * Two layers of coordination:
6
- * 1. In-process: a `Map<accountId, Promise>` so concurrent callers in the
7
- * same process share one upstream refresh call instead of racing.
8
- * 2. Cross-process: a lease (`provider_oauth_states.lease_owner`/
9
- * `lease_expires_at`) acquired via a conditional `UPDATE`, and every
10
- * persisted mutation is fenced on `lease_owner = <this process's lease
11
- * id>` so a process whose lease expired mid-refresh cannot clobber a
12
- * peer that took over — its fenced write simply matches zero rows.
13
- *
14
- * Refresh token, expiry, and lease live in `provider_oauth_states`
15
- * (), a 1:0/1 table keyed by `provider_account_id` — split out
16
- * of `provider_accounts` because the lease is a sparse, cross-process
17
- * coordination concern that stayed null on every non-OAuth (`api_key`)
18
- * account row. Credential ciphertext and health-machine fields stay on
19
- * `provider_accounts`; `persistRefreshed`/`disableAccount` update both
20
- * tables inside one transaction, fenced first on the lease table so a
21
- * losing process's write touches zero rows in either table.
22
- */
23
- import { randomUUID } from "node:crypto";
24
- import { and, eq, inArray, isNull, lt, or, sql } from "drizzle-orm";
25
- import type { CartethyiaDatabase } from "../../persistence/db-handle";
26
- import { sleep } from "../../shared/sleep";
27
- import { providerAccounts, providerOauthStates } from "../../persistence/schema";
28
- import { decryptCredentialToString, encryptCredential } from "../../security/crypto";
29
- import { invalidateCredentialCache, loadAccountWithOauthState } from "../operations/provider-credential-service";
30
- import { applyAccountVerdict } from "../operations/account-health-service";
31
- import { resolveAccountDisabledRecoveryMs } from "../../config/cooldowns";
32
- import { asJsonbRecord } from "../../shared/coerce";
33
- import {
34
- loadAccountWithFreshness,
35
- type AccountWithFreshnessRow,
36
- } from "../operations/provider-credential-service";
37
- import { refreshLeadMs } from "../operations/oauth-refresh-lead";
38
- import { classifyAccessTokenUsability } from "./static-token-detection";
39
- import { pushStructuredConsoleLog } from "../../observability/log-ring";
40
-
41
- /**
42
- * Classifies an OAuth token-refresh failure as definitive (the credential is
43
- * permanently revoked/expired and must stop being retried) or transient
44
- * (network blip, upstream 5xx/429, temporary outage — safe to retry later).
45
- *
46
- * Uses a regex over the error's message/body, not a bare HTTP-status
47
- * allowlist, since the same status code (401) can mean either "token is
48
- * dead" or "clock skew, try again" depending on the error body's actual
49
- * wording.
50
- */
51
-
52
- const DEFINITIVE_PATTERN =
53
- /invalid_grant|invalid_token|unauthorized_client|revoked|refresh_token.*expired/i;
54
-
55
- interface OAuthRefreshFailure {
56
- readonly status?: number | undefined;
57
- readonly message: string;
58
- }
59
-
60
- type OAuthFailureClassification = "definitive" | "transient";
61
-
62
- /**
63
- * A bare 401 with no body match for the definitive pattern is still treated
64
- * as definitive (an unrecognized-but-clearly-"unauthorized" response from a
65
- * refresh endpoint almost never self-heals); everything else — timeouts,
66
- * 5xx, 429, 403/"forbidden", "temporarily unavailable" — is transient.
67
- */
68
- export function classifyOAuthRefreshFailure(
69
- failure: OAuthRefreshFailure,
70
- ): OAuthFailureClassification {
71
- if (DEFINITIVE_PATTERN.test(failure.message)) return "definitive";
72
- if (failure.status === 401) return "definitive";
73
- return "transient";
74
- }
75
-
76
- /** How long a lease is valid for before another process may reclaim it. */
77
- const LEASE_TTL_MS = 15_000;
78
-
79
- /**
80
- * Bound on a single token-endpoint call. Neither the reactive path nor the
81
- * sweep passes a caller signal, and a hung endpoint must not block either
82
- * indefinitely — timeouts classify as transient failures.
83
- */
84
- const REFRESH_HTTP_TIMEOUT_MS = 30_000;
85
-
86
- export interface OAuthTokenRefreshResult {
87
- readonly access: string;
88
- /** Omitted when the provider keeps the existing refresh token valid. */
89
- readonly refresh?: string;
90
- readonly expiresAt: Date;
91
- /**
92
- * Auth configuration the refresh corrected or discovered — most often the
93
- * upstream profile the account resolves to, which some providers only report
94
- * beside a fresh access token. Omitted means the stored state stands.
95
- */
96
- readonly auth_state?: Readonly<Record<string, unknown>>;
97
- /**
98
- * Display identity the refresh learned (email/username from the fresh token
99
- * or token response). Omitted when the provider reports nothing new.
100
- */
101
- readonly accountLabel?: string;
102
- }
103
-
104
- /**
105
- * What a refresher is told about the account it is refreshing.
106
- *
107
- * A refresh is not authorized by the refresh token alone everywhere: one flow
108
- * replays the client credentials its login registered, another posts to a
109
- * per-account token endpoint. Both belong to the account rather than to the
110
- * token, so they are handed in here instead of being re-read from storage
111
- * inside every provider client.
112
- */
113
- export interface OAuthRefreshContext {
114
- readonly account_id: string;
115
- readonly auth_state?: Readonly<Record<string, unknown>>;
116
- /** Current access token for endpoints that authenticate refresh requests with both tokens. */
117
- readonly access_token?: string;
118
- /** Decrypted companion secret for flows whose login registered a client secret. */
119
- readonly client_secret?: string;
120
- }
121
-
122
- /** Provider-specific token-endpoint client. Implemented per provider in later phases. */
123
- export interface OAuthTokenRefresher {
124
- /** Requests the current access token in refresh context only when required by the endpoint. */
125
- readonly requiresAccessToken?: boolean;
126
- refresh(
127
- refreshToken: string,
128
- signal?: AbortSignal,
129
- context?: OAuthRefreshContext,
130
- ): Promise<OAuthTokenRefreshResult>;
131
- }
132
-
133
- type AccountRow = AccountWithFreshnessRow;
134
-
135
- /** Acquires the refresh lease for `accountId`, or returns false if another live lease holds it. */
136
- async function acquireLease(
137
- db: CartethyiaDatabase,
138
- accountId: string,
139
- owner: string,
140
- ): Promise<boolean> {
141
- const now = new Date();
142
- const expiresAt = new Date(now.getTime() + LEASE_TTL_MS);
143
- const result = await db
144
- .update(providerOauthStates)
145
- .set({ leaseOwner: owner, leaseExpiresAt: expiresAt })
146
- .where(
147
- and(
148
- eq(providerOauthStates.providerAccountId, accountId),
149
- or(isNull(providerOauthStates.leaseOwner), lt(providerOauthStates.leaseExpiresAt, now)),
150
- ),
151
- )
152
- .returning({ id: providerOauthStates.providerAccountId });
153
- return result.length > 0;
154
- }
155
-
156
- /** Releases the lease without mutating credential state (used for transient failures). */
157
- async function releaseLease(
158
- db: CartethyiaDatabase,
159
- accountId: string,
160
- owner: string,
161
- ): Promise<void> {
162
- await db
163
- .update(providerOauthStates)
164
- .set({ leaseOwner: null, leaseExpiresAt: null })
165
- .where(
166
- and(
167
- eq(providerOauthStates.providerAccountId, accountId),
168
- eq(providerOauthStates.leaseOwner, owner),
169
- ),
170
- );
171
- }
172
-
173
- /**
174
- * D21 invariant guard: a refresh is not a health verdict, so mutating the
175
- * account's `status` here would silently re-enable an account a refresh was
176
- * never meant to resurrect. The `.set()` calls in `persistRefreshed` accept
177
- * `status` at the type level (it is a column on `provider_accounts`), so the
178
- * guard is a runtime assertion that fails loudly in development instead of
179
- * letting a future edit change health state from the refresh path.
180
- */
181
- function assertRefreshNeverMutatesStatus(set: Record<string, unknown>): void {
182
- if ("status" in set) {
183
- throw new Error(
184
- "persistRefreshed must never write `status`; a refresh is not a health verdict",
185
- );
186
- }
187
- }
188
-
189
- /** Persists a successful refresh and releases the lease, fenced on still holding it. */
190
- async function persistRefreshed(
191
- db: CartethyiaDatabase,
192
- accountId: string,
193
- owner: string,
194
- result: OAuthTokenRefreshResult,
195
- ): Promise<boolean> {
196
- return db.transaction(async (tx) => {
197
- // This function must never write `status`; a refresh is not a health
198
- // verdict. The guard below makes an accidental status write a loud dev
199
- // failure rather than a silent re-enable.
200
- const oauthSet = {
201
- ...(result.refresh === undefined
202
- ? {}
203
- : { refreshCiphertext: encryptCredential(result.refresh) }),
204
- expiresAt: result.expiresAt,
205
- leaseOwner: null,
206
- leaseExpiresAt: null,
207
- };
208
- assertRefreshNeverMutatesStatus(oauthSet);
209
- const leaseRows = await tx
210
- .update(providerOauthStates)
211
- .set(oauthSet)
212
- .where(
213
- and(
214
- eq(providerOauthStates.providerAccountId, accountId),
215
- eq(providerOauthStates.leaseOwner, owner),
216
- ),
217
- )
218
- .returning({ id: providerOauthStates.providerAccountId });
219
- if (leaseRows.length === 0) return false;
220
- const accountSet = {
221
- credentialCiphertext: encryptCredential(result.access),
222
- // Only what the refresh reported: a refresher that learned nothing new
223
- // leaves the stored configuration alone rather than blanking it.
224
- // A learned identity only replaces a default label — an operator's
225
- // custom rename is never overwritten. The CASE keeps the read and the
226
- // write in one statement, so a concurrent rename cannot lose to the
227
- // refresh's learned identity.
228
- ...(result.accountLabel === undefined
229
- ? {}
230
- : {
231
- label: sql`CASE WHEN lower(trim(${providerAccounts.label})) = lower(trim(${providerAccounts.providerId})) THEN ${result.accountLabel} ELSE ${providerAccounts.label} END`,
232
- }),
233
- ...(result.auth_state === undefined ? {} : { authState: result.auth_state }),
234
- };
235
- // The second half of the D21 invariant: the credential write must never
236
- // touch the account's health state either.
237
- assertRefreshNeverMutatesStatus(accountSet);
238
- await tx
239
- .update(providerAccounts)
240
- .set(accountSet)
241
- .where(eq(providerAccounts.id, accountId));
242
- invalidateCredentialCache(accountId);
243
- return true;
244
- });
245
- }
246
-
247
- /**
248
- * Packages the account-side facts a refresh needs but the refresh token does
249
- * not carry. The companion secret is decrypted here, once, so provider clients
250
- * never reach into the database.
251
- */
252
- function buildRefreshContext(row: AccountRow, includeAccessToken: boolean): OAuthRefreshContext {
253
- const authState = asJsonbRecord(row.authState);
254
- return {
255
- account_id: row.id,
256
- ...(authState === undefined ? {} : { auth_state: authState }),
257
- ...(includeAccessToken && row.credentialCiphertext
258
- ? { access_token: decryptCredentialToString(row.credentialCiphertext) }
259
- : {}),
260
- ...(row.clientSecretCiphertext
261
- ? { client_secret: decryptCredentialToString(row.clientSecretCiphertext) }
262
- : {}),
263
- };
264
- }
265
-
266
- /**
267
- * Marks an account permanently unusable after a definitive refresh failure,
268
- * fenced on the lease.
269
- *
270
- * @param preserveOperatorDisable When true, an account that is already
271
- * `disabled` with no `cooldownUntil` — the operator's own disable — keeps its
272
- * lack of a recovery deadline (D23). A machine disable (this function being
273
- * the machine) always writes a deadline for itself, but it must not convert
274
- * an operator's terminal decision into a recoverable one: adding a deadline
275
- * to an operator-disabled row is what made the Recover button appear on an
276
- * account the operator disabled on purpose.
277
- */
278
- async function disableAccount(
279
- db: CartethyiaDatabase,
280
- accountId: string,
281
- owner: string,
282
- message: string,
283
- options: { readonly preserveOperatorDisable?: boolean } = {},
284
- ): Promise<void> {
285
- await db.transaction(async (tx) => {
286
- const leaseRows = await tx
287
- .update(providerOauthStates)
288
- .set({ leaseOwner: null, leaseExpiresAt: null })
289
- .where(
290
- and(
291
- eq(providerOauthStates.providerAccountId, accountId),
292
- eq(providerOauthStates.leaseOwner, owner),
293
- ),
294
- )
295
- .returning({ id: providerOauthStates.providerAccountId });
296
- if (leaseRows.length === 0) return;
297
- const disabledRecoveryMs = resolveAccountDisabledRecoveryMs();
298
- // Converge on the health application seam (proposal §3.5): a definitive
299
- // OAuth refresh failure is a producer into `applyAccountVerdict`, not a
300
- // separate state machine. The seam preserves D23 (operator-disable rows
301
- // keep their `null` recovery deadline) via `preserveOperatorDisable`.
302
- const applied = await applyAccountVerdict(tx, {
303
- accountId,
304
- status: "disabled",
305
- // `auth_invalidated` is the category the console reads to render the
306
- // "Re-login required" pill, and the one the quota sweep excludes so it
307
- // stops re-probing a credential that cannot repair itself. The refresh
308
- // grant was definitively rejected and the stored access token could not
309
- // be shown to still work, so this is exactly that state.
310
- reason: message,
311
- category: "auth_invalidated",
312
- // Carries a recovery deadline so `sweepExpiredCooldowns` can bring this
313
- // account back once it elapses; without one, one definitive rejection
314
- // parks the credential for good. The deadline is also what distinguishes
315
- // this (machine-disabled) from an operator's own disable, which leaves
316
- // `cooldownUntil` null and is therefore never swept (D23).
317
- cooldownUntil:
318
- disabledRecoveryMs > 0 ? new Date(Date.now() + disabledRecoveryMs) : null,
319
- ...(options.preserveOperatorDisable === true
320
- ? { preserveOperatorDisable: true }
321
- : {}),
322
- });
323
- if (applied) invalidateCredentialCache(accountId);
324
- });
325
- }
326
-
327
- /**
328
- * Marks an account as carrying a static bearer token: used exactly as issued,
329
- * never refreshed. Called when the refresh path finds no refresh token on
330
- * record, so the account converges to the same state the console's explicit
331
- * "static token" toggle sets — the sweep skips it and its status stops reading
332
- * as a broken/re-auth account. Not lease-fenced: a non-destructive annotation
333
- * that any process may write, and the credential is not touched.
334
- *
335
- * This never changes `status`: a `disabled` account stays `disabled` (only an
336
- * operator or an explicit recovery may re-enable it), while an `active` one
337
- * stays `active`. The static marker and the error clearing are orthogonal to
338
- * the account's health state.
339
- *
340
- * D24: for a `disabled` row the error fields are deliberately kept. Once the
341
- * account is parked, `lastErrorCategory` is the only record of *why* (e.g.
342
- * `auth_invalidated` → "Re-login required"); wiping it on the static
343
- * annotation would leave a disabled account with no diagnosable reason. An
344
- * `active` row still clears them — there the error is stale by definition.
345
- */
346
- async function markStaticToken(db: CartethyiaDatabase, accountId: string): Promise<void> {
347
- const rows = await db
348
- .select({ status: providerAccounts.status })
349
- .from(providerAccounts)
350
- .where(eq(providerAccounts.id, accountId))
351
- .limit(1);
352
- const isDisabled = rows[0]?.status === "disabled";
353
- await db
354
- .update(providerAccounts)
355
- .set({
356
- staticToken: true,
357
- cooldownUntil: null,
358
- // A static token is a normal, usable credential, not an error state: any
359
- // error left by the failed refresh would otherwise keep the row reading
360
- // "Re-login required" while it is in fact serving. Mirrors the console's
361
- // static-token toggle. For a `disabled` row the category is preserved
362
- // (D24), because there it is the diagnosis, not a stale artifact.
363
- ...(isDisabled
364
- ? {}
365
- : { lastError: null, lastErrorCategory: null, lastErrorAt: null }),
366
- })
367
- .where(eq(providerAccounts.id, accountId));
368
- }
369
-
370
- function failureMessage(error: unknown): string {
371
- if (error instanceof Error) return error.message;
372
- return String(error);
373
- }
374
-
375
- /**
376
- * Decrypts a stored credential, returning `undefined` when the ciphertext
377
- * cannot be decrypted. A corrupt row is not evidence about token expiry, so the
378
- * caller treats it the same as a missing token rather than throwing inside the
379
- * refresh failure path.
380
- */
381
- function safeDecryptCredential(ciphertext: Buffer): string | undefined {
382
- try {
383
- return decryptCredentialToString(ciphertext);
384
- } catch {
385
- return undefined;
386
- }
387
- }
388
-
389
- /** Best-effort HTTP status extraction from an OAuth refresh error. Most
390
- * providers surface `error.status` or embed the status in the message; we
391
- * accept either so the classifier can honor "bare 401 -> definitive". */
392
- function failureStatus(error: unknown): number | undefined {
393
- if (error !== null && typeof error === "object" && "status" in error) {
394
- const raw = error.status;
395
- if (typeof raw === "number" && Number.isFinite(raw)) return raw;
396
- }
397
- const msg = failureMessage(error);
398
- const match = /\b(\d{3})\b/.exec(msg);
399
- if (match) {
400
- const status = Number(match[1]);
401
- if (status >= 400 && status < 600) return status;
402
- }
403
- return undefined;
404
- }
405
-
406
- /**
407
- * Boot-time convergence for OAuth accounts already parked by an earlier
408
- * refresh failure.
409
- *
410
- * Before automatic detection existed, a definitive refresh failure disabled the
411
- * account outright (`oauth_revoked`) even when the stored access token was
412
- * still perfectly usable. This pass re-reads those rows once at startup and
413
- * moves each to where it now belongs:
414
- *
415
- * - access token still usable ⇒ static token; the account stays `disabled`;
416
- * only an operator can re-enable it;
417
- * - otherwise ⇒ the `auth_invalidated` category the console renders as
418
- * "Re-login required" and the quota sweep excludes.
419
- *
420
- * Idempotent and cheap: it only inspects OAuth rows that are `disabled` with a
421
- * credential-failure category, so a healthy deployment scans nothing. The
422
- * credential is decoded, never verified — the same structural-only trust the
423
- * dispatch path already places in the stored token.
424
- */
425
- export async function reconcileStaticTokenAccounts(db: CartethyiaDatabase): Promise<number> {
426
- const candidates = await db
427
- .select({
428
- id: providerAccounts.id,
429
- credentialCiphertext: providerAccounts.credentialCiphertext,
430
- staticToken: providerAccounts.staticToken,
431
- })
432
- .from(providerAccounts)
433
- .where(
434
- and(
435
- eq(providerAccounts.credentialKind, "oauth"),
436
- eq(providerAccounts.status, "disabled"),
437
- or(
438
- eq(providerAccounts.lastErrorCategory, "oauth_revoked"),
439
- eq(providerAccounts.lastErrorCategory, "auth_invalidated"),
440
- ),
441
- ),
442
- );
443
- let changed = 0;
444
- for (const row of candidates) {
445
- if (row.staticToken) continue;
446
- const usability = classifyAccessTokenUsability(
447
- row.credentialCiphertext ? safeDecryptCredential(row.credentialCiphertext) : undefined,
448
- );
449
- if (usability === "usable") {
450
- await markStaticToken(db, row.id);
451
- invalidateCredentialCache(row.id);
452
- pushStructuredConsoleLog("info", "OAuth account recovered as a static token at boot", {
453
- event: "token_refresh",
454
- accountId: row.id,
455
- errorCode: "static_token",
456
- });
457
- changed += 1;
458
- continue;
459
- }
460
- // Converge the legacy category name so the console and the quota sweep see
461
- // one spelling of "credential rejected, operator must re-login".
462
- await db
463
- .update(providerAccounts)
464
- .set({ lastErrorCategory: "auth_invalidated" })
465
- .where(eq(providerAccounts.id, row.id));
466
- changed += 1;
467
- }
468
- return changed;
469
- }
470
-
471
- /**
472
- * Rows whose OAuth token is due for proactive refresh (used by the sweep worker).
473
- *
474
- * The due test is applied in JS, not as one SQL `expires_at < cutoff`, because
475
- * the lead is per provider (`refreshLeadMs`): Claude re-mints ~4h ahead, Codex
476
- * ~5 days, Antigravity ~5 min, and a single global cutoff cannot express that.
477
- * Accounts with no recorded expiry are always due — an account the sweep cannot
478
- * see is one whose token dies silently, which is the failure this guards.
479
- */
480
- /**
481
- * Rows one due-scan reads. The expiry test runs in JS because the lead is per
482
- * provider, so the bound has to sit on the query itself.
483
- */
484
- const OAUTH_DUE_SCAN_LIMIT = 2_000;
485
-
486
- export async function loadDueOAuthAccounts(
487
- db: CartethyiaDatabase,
488
- skewMs?: number,
489
- ): Promise<readonly { readonly id: string; readonly providerId: string }[]> {
490
- const rows = await db
491
- .select({
492
- id: providerAccounts.id,
493
- providerId: providerAccounts.providerId,
494
- expiresAt: providerOauthStates.expiresAt,
495
- staticToken: providerAccounts.staticToken,
496
- })
497
- .from(providerAccounts)
498
- .innerJoin(providerOauthStates, eq(providerOauthStates.providerAccountId, providerAccounts.id))
499
- .where(
500
- and(
501
- eq(providerAccounts.credentialKind, "oauth"),
502
- // `cooldown` is included: a cooling account's token still expires, and
503
- // a refresh that lands while it is parked is what lets it serve the
504
- // moment the cooldown ends. `disabled` stays out — that is an
505
- // operator decision, and refreshing it would spend a grant on an
506
- // account that must not return to rotation on its own.
507
- inArray(providerAccounts.status, ["active", "cooldown"]),
508
- ),
509
- )
510
- // The due test is applied in JS, so this limit bounds the scan, not the
511
- // result. One pass cannot read the whole table; accounts past the limit
512
- // are reached on a later tick once the ones in front have been refreshed
513
- // and dropped out of the window.
514
- .limit(OAUTH_DUE_SCAN_LIMIT);
515
- const now = Date.now();
516
- return rows
517
- .filter((row) => {
518
- // A static token is used exactly as issued: there is no refresh grant to
519
- // run, so the sweep must not touch it. Refreshing it every pass only
520
- // floods the log with a guaranteed no-op. The operator clears the flag
521
- // when the account has a real refresh token again.
522
- if (row.staticToken) return false;
523
- if (row.expiresAt == null) return true;
524
- const lead = skewMs ?? refreshLeadMs(row.providerId);
525
- return row.expiresAt.getTime() - lead <= now;
526
- })
527
- .map((row) => ({ id: row.id, providerId: row.providerId }));
528
- }
529
-
530
- export class OAuthRefreshService {
531
- /** Bound on concurrently tracked single-flight refreshes; keys are account ids, so the real bound is the account count. */
532
- private static readonly MAX_INFLIGHT = 1_000;
533
-
534
- readonly #db: CartethyiaDatabase;
535
- readonly #inFlight = new Map<string, Promise<string | null>>();
536
-
537
- constructor(db: CartethyiaDatabase) {
538
- this.#db = db;
539
- }
540
-
541
- /**
542
- * Ensures a fresh access token for an OAuth account, refreshing it first
543
- * if it's within `opts.skewMs` of expiry (or already expired). When
544
- * `opts.force` is `true`, the freshness check is bypassed and the
545
- * refresher is always invoked — used by the 401 dispatch retry so a peer
546
- * that just refreshed cannot cause the caller to reuse a dead cached
547
- * secret. Returns the current decrypted access token, or `null` when the
548
- * account isn't OAuth, has no stored refresh token, or refresh failed.
549
- *
550
- * `opts.origin` labels who asked for the refresh (`ui`/`sweep`/`dispatch`)
551
- * and is carried on the `token_refresh` log lines (D26). The origin is
552
- * observational only — it never changes what a refresh does.
553
- */
554
- async ensureFreshAccessToken(
555
- accountId: string,
556
- refresher: OAuthTokenRefresher,
557
- opts: { force?: boolean; skewMs?: number; origin?: "ui" | "sweep" | "dispatch" } = {},
558
- ): Promise<string | null> {
559
- const force = opts.force === true;
560
- // No explicit skew: the per-provider lead is applied at load time.
561
- const skewMs = opts.skewMs;
562
- const origin = opts.origin ?? "sweep";
563
- // Forced refreshes get their own in-flight slot so they don't merge with
564
- // a raced non-force call that would otherwise short-circuit.
565
- const key = force ? `${accountId}:force` : accountId;
566
- const existing = this.#inFlight.get(key);
567
- if (existing) return existing;
568
- // Eviction guard: keys are account ids (bounded by the accounts table),
569
- // but a runaway caller could still fan out unique keys; drop the oldest
570
- // in-flight task rather than grow without bound. An evicted task's
571
- // `.finally` delete is a no-op for a key no longer present, and a racing
572
- // duplicate refresh is fenced cross-process by the lease below.
573
- if (this.#inFlight.size >= OAuthRefreshService.MAX_INFLIGHT) {
574
- const oldestKey = this.#inFlight.keys().next().value;
575
- if (oldestKey !== undefined) this.#inFlight.delete(oldestKey);
576
- }
577
- const task = this.#refreshIfDue(accountId, refresher, skewMs, force, origin).finally(() => {
578
- this.#inFlight.delete(key);
579
- });
580
- this.#inFlight.set(key, task);
581
- return task;
582
- }
583
-
584
- async #refreshIfDue(
585
- accountId: string,
586
- refresher: OAuthTokenRefresher,
587
- skewMs: number | undefined,
588
- force: boolean,
589
- origin: "ui" | "sweep" | "dispatch",
590
- ): Promise<string | null> {
591
- const account = await loadAccountWithFreshness(this.#db, accountId, skewMs);
592
- if (!account || account.row.credentialKind !== "oauth") return null;
593
- const { row, dueAt } = account;
594
- // A static token is used exactly as issued and never refreshed. There is
595
- // nothing to re-mint, so return null — the same "cannot refresh" signal a
596
- // missing refresh token gives — rather than re-issuing the identical
597
- // credential to a forced (401) retry, which would only repeat the same
598
- // request against the same upstream. Dispatch still uses the stored token
599
- // directly (`dueAt` is undefined for a static token, so the caller's
600
- // refresh branch is skipped and the decrypted credential is used as-is).
601
- if (row.staticToken) return null;
602
- if (!force && dueAt !== undefined && dueAt > Date.now()) {
603
- return row.credentialCiphertext
604
- ? decryptCredentialToString(row.credentialCiphertext)
605
- : null;
606
- }
607
- return this.#refreshNow(row, refresher, skewMs, origin);
608
- }
609
-
610
- async #refreshNow(
611
- row: AccountRow,
612
- refresher: OAuthTokenRefresher,
613
- skewMs: number | undefined,
614
- origin: "ui" | "sweep" | "dispatch",
615
- ): Promise<string | null> {
616
- // D26: statusBefore is read once up front (the row projection used by the
617
- // refresh path does not carry status) and statusAfter is re-read after any
618
- // mutation, so the log proves a refresh never flipped the account.
619
- const statusBefore = await readAccountStatus(this.#db, row.id);
620
- pushStructuredConsoleLog("info", "OAuth token refresh started", {
621
- event: "token_refresh",
622
- accountId: row.id,
623
- providerId: row.providerId,
624
- details: { origin, statusBefore },
625
- });
626
- const owner = randomUUID();
627
- const leased = await acquireLease(this.#db, row.id, owner);
628
- if (!leased) {
629
- // A peer holds the lease — briefly wait for it to finish, then read whatever it left.
630
- await sleep(500);
631
- const fresh = await loadAccountWithFreshness(this.#db, row.id, skewMs);
632
- return fresh?.row.credentialCiphertext && fresh.dueAt !== undefined && fresh.dueAt > Date.now()
633
- ? decryptCredentialToString(fresh.row.credentialCiphertext)
634
- : null;
635
- }
636
- if (!row.refreshCiphertext) {
637
- await releaseLease(this.#db, row.id, owner);
638
- // No refresh token on record: the access token is used exactly as issued
639
- // and cannot be re-minted, so every future refresh attempt is a
640
- // guaranteed no-op. Mark the account static — the same state the
641
- // console's toggle sets — so the sweep stops retrying it and the status
642
- // reads as an informational static token rather than a broken account.
643
- await markStaticToken(this.#db, row.id);
644
- pushStructuredConsoleLog("info", "OAuth token refresh skipped: static token", {
645
- event: "token_refresh",
646
- accountId: row.id,
647
- providerId: row.providerId,
648
- errorCode: "static_token",
649
- details: { origin },
650
- });
651
- return null;
652
- }
653
- const refreshToken = decryptCredentialToString(row.refreshCiphertext);
654
- try {
655
- const result = await refresher.refresh(
656
- refreshToken,
657
- AbortSignal.timeout(REFRESH_HTTP_TIMEOUT_MS),
658
- buildRefreshContext(row, refresher.requiresAccessToken === true),
659
- );
660
- const persisted = await persistRefreshed(this.#db, row.id, owner, result);
661
- // D26: `statusAfter` is re-read from the DB rather than assumed — a
662
- // successful refresh never changes `status`, and the log proves it.
663
- const statusAfter = await readAccountStatus(this.#db, row.id);
664
- pushStructuredConsoleLog(persisted ? "info" : "warn", persisted ? "OAuth token refresh completed" : "OAuth token refresh lost lease", {
665
- event: "token_refresh",
666
- accountId: row.id,
667
- providerId: row.providerId,
668
- ...(persisted ? {} : { errorCode: "refresh_lease_lost" }),
669
- details: {
670
- expiresAt: result.expiresAt.toISOString(),
671
- origin,
672
- statusBefore,
673
- statusAfter,
674
- },
675
- });
676
- return persisted ? result.access : null;
677
- } catch (error) {
678
- const classification = classifyOAuthRefreshFailure({
679
- message: failureMessage(error),
680
- status: failureStatus(error),
681
- });
682
- pushStructuredConsoleLog("error", "OAuth token refresh failed", {
683
- event: "token_refresh",
684
- accountId: row.id,
685
- providerId: row.providerId,
686
- errorCode: classification,
687
- details: { origin, statusBefore },
688
- });
689
- if (classification === "definitive") {
690
- // A definitive refresh failure means the refresh grant is dead — but
691
- // the access token already on the row may still be usable as issued.
692
- // That is exactly the static-token case: stop refreshing, keep serving.
693
- // Only when the token cannot be shown to work (expired or opaque) does
694
- // the account need the operator, and it is parked for re-auth.
695
- const accessUsability = classifyAccessTokenUsability(
696
- row.credentialCiphertext
697
- ? safeDecryptCredential(row.credentialCiphertext)
698
- : undefined,
699
- );
700
- if (accessUsability === "usable") {
701
- await releaseLease(this.#db, row.id, owner);
702
- await markStaticToken(this.#db, row.id);
703
- const statusAfter = await readAccountStatus(this.#db, row.id);
704
- pushStructuredConsoleLog("info", "OAuth refresh failed; access token still valid, marking static", {
705
- event: "token_refresh",
706
- accountId: row.id,
707
- providerId: row.providerId,
708
- errorCode: "static_token",
709
- details: { origin, statusBefore, statusAfter },
710
- });
711
- } else {
712
- await disableAccount(this.#db, row.id, owner, failureMessage(error), {
713
- // D23: a definitive refresh failure must not turn an operator's
714
- // disable into a machine-recoverable one. If the row is already
715
- // `disabled` without a deadline, leave it that way.
716
- preserveOperatorDisable: true,
717
- });
718
- const statusAfter = await readAccountStatus(this.#db, row.id);
719
- pushStructuredConsoleLog("warn", "OAuth refresh failed definitively; account disabled for re-auth", {
720
- event: "token_refresh",
721
- accountId: row.id,
722
- providerId: row.providerId,
723
- errorCode: classification,
724
- details: { origin, statusBefore, statusAfter },
725
- });
726
- }
727
- } else {
728
- await releaseLease(this.#db, row.id, owner);
729
- }
730
- return null;
731
- }
732
- }
733
- }
734
-
735
- /** Reads one account's status (used only for logging/observability, D26). */
736
- async function readAccountStatus(db: CartethyiaDatabase, accountId: string): Promise<string | undefined> {
737
- const loaded = await loadAccountWithOauthState(db, accountId, { includeStatus: true });
738
- return loaded?.status;
739
- }
1
+ /**
2
+ * OAuth token refresh orchestration: in-process single-flight plus a
3
+ * Postgres lease-fenced compare-and-swap.
4
+ *
5
+ * Two layers of coordination:
6
+ * 1. In-process: a `Map<accountId, Promise>` so concurrent callers in the
7
+ * same process share one upstream refresh call instead of racing.
8
+ * 2. Cross-process: a lease (`provider_oauth_states.lease_owner`/
9
+ * `lease_expires_at`) acquired via a conditional `UPDATE`, and every
10
+ * persisted mutation is fenced on `lease_owner = <this process's lease
11
+ * id>` so a process whose lease expired mid-refresh cannot clobber a
12
+ * peer that took over — its fenced write simply matches zero rows.
13
+ *
14
+ * Refresh token, expiry, and lease live in `provider_oauth_states`
15
+ * (), a 1:0/1 table keyed by `provider_account_id` — split out
16
+ * of `provider_accounts` because the lease is a sparse, cross-process
17
+ * coordination concern that stayed null on every non-OAuth (`api_key`)
18
+ * account row. Credential ciphertext and health-machine fields stay on
19
+ * `provider_accounts`; `persistRefreshed`/`disableAccount` update both
20
+ * tables inside one transaction, fenced first on the lease table so a
21
+ * losing process's write touches zero rows in either table.
22
+ */
23
+ import { randomUUID } from "node:crypto";
24
+ import { and, eq, inArray, isNull, lt, or, sql } from "drizzle-orm";
25
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
26
+ import { sleep } from "../../shared/sleep";
27
+ import { providerAccounts, providerOauthStates } from "../../persistence/schema";
28
+ import { decryptCredentialToString, encryptCredential } from "../../security/crypto";
29
+ import { invalidateCredentialCache, loadAccountWithOauthState } from "../operations/provider-credential-service";
30
+ import { applyAccountVerdict } from "../operations/account-health-service";
31
+ import { resolveAccountDisabledRecoveryMs } from "../../config/cooldowns";
32
+ import { asJsonbRecord } from "../../shared/coerce";
33
+ import {
34
+ loadAccountWithFreshness,
35
+ type AccountWithFreshnessRow,
36
+ } from "../operations/provider-credential-service";
37
+ import { refreshLeadMs } from "../operations/oauth-refresh-lead";
38
+ import { classifyAccessTokenUsability } from "./static-token-detection";
39
+ import type { OAuthAccountIdentity } from "./oauth-flow-store";
40
+ import { pushStructuredConsoleLog } from "../../observability/log-ring";
41
+
42
+ /**
43
+ * Classifies an OAuth token-refresh failure as definitive (the credential is
44
+ * permanently revoked/expired and must stop being retried) or transient
45
+ * (network blip, upstream 5xx/429, temporary outage — safe to retry later).
46
+ *
47
+ * Uses a regex over the error's message/body, not a bare HTTP-status
48
+ * allowlist, since the same status code (401) can mean either "token is
49
+ * dead" or "clock skew, try again" depending on the error body's actual
50
+ * wording.
51
+ */
52
+
53
+ const DEFINITIVE_PATTERN =
54
+ /invalid_grant|invalid_token|unauthorized_client|revoked|refresh_token.*expired|github_copilot_profile_mismatch/i;
55
+
56
+ interface OAuthRefreshFailure {
57
+ readonly status?: number | undefined;
58
+ readonly message: string;
59
+ }
60
+
61
+ type OAuthFailureClassification = "definitive" | "transient";
62
+
63
+ /**
64
+ * A bare 401 with no body match for the definitive pattern is still treated
65
+ * as definitive (an unrecognized-but-clearly-"unauthorized" response from a
66
+ * refresh endpoint almost never self-heals); everything else — timeouts,
67
+ * 5xx, 429, 403/"forbidden", "temporarily unavailable" — is transient.
68
+ */
69
+ export function classifyOAuthRefreshFailure(
70
+ failure: OAuthRefreshFailure,
71
+ ): OAuthFailureClassification {
72
+ if (DEFINITIVE_PATTERN.test(failure.message)) return "definitive";
73
+ if (failure.status === 401) return "definitive";
74
+ return "transient";
75
+ }
76
+
77
+ /** How long a lease is valid for before another process may reclaim it. */
78
+ const LEASE_TTL_MS = 15_000;
79
+
80
+ /**
81
+ * Bound on a single token-endpoint call. Neither the reactive path nor the
82
+ * sweep passes a caller signal, and a hung endpoint must not block either
83
+ * indefinitely — timeouts classify as transient failures.
84
+ */
85
+ const REFRESH_HTTP_TIMEOUT_MS = 30_000;
86
+
87
+ export interface OAuthTokenRefreshResult extends OAuthAccountIdentity {
88
+ readonly access: string;
89
+ /** Omitted when the provider keeps the existing refresh token valid. */
90
+ readonly refresh?: string;
91
+ readonly expiresAt: Date;
92
+ /**
93
+ * Auth configuration the refresh corrected or discovered — most often the
94
+ * upstream profile the account resolves to, which some providers only report
95
+ * beside a fresh access token. Omitted means the stored state stands.
96
+ */
97
+ readonly auth_state?: Readonly<Record<string, unknown>>;
98
+ /**
99
+ * Display identity the refresh learned (email/username from the fresh token
100
+ * or token response). Omitted when the provider reports nothing new.
101
+ */
102
+ readonly accountLabel?: string;
103
+ }
104
+
105
+ /**
106
+ * What a refresher is told about the account it is refreshing.
107
+ *
108
+ * A refresh is not authorized by the refresh token alone everywhere: one flow
109
+ * replays the client credentials its login registered, another posts to a
110
+ * per-account token endpoint. Both belong to the account rather than to the
111
+ * token, so they are handed in here instead of being re-read from storage
112
+ * inside every provider client.
113
+ */
114
+ export interface OAuthRefreshContext {
115
+ readonly account_id: string;
116
+ readonly auth_state?: Readonly<Record<string, unknown>>;
117
+ /** Current access token for endpoints that authenticate refresh requests with both tokens. */
118
+ readonly access_token?: string;
119
+ /** Decrypted companion secret for flows whose login registered a client secret. */
120
+ readonly client_secret?: string;
121
+ }
122
+
123
+ /** Provider-specific token-endpoint client. Implemented per provider in later phases. */
124
+ export interface OAuthTokenRefresher {
125
+ /** Requests the current access token in refresh context only when required by the endpoint. */
126
+ readonly requiresAccessToken?: boolean;
127
+ refresh(
128
+ refreshToken: string,
129
+ signal?: AbortSignal,
130
+ context?: OAuthRefreshContext,
131
+ ): Promise<OAuthTokenRefreshResult>;
132
+ }
133
+
134
+ type AccountRow = AccountWithFreshnessRow;
135
+
136
+ /** Acquires the refresh lease for `accountId`, or returns false if another live lease holds it. */
137
+ async function acquireLease(
138
+ db: CartethyiaDatabase,
139
+ accountId: string,
140
+ owner: string,
141
+ ): Promise<boolean> {
142
+ const now = new Date();
143
+ const expiresAt = new Date(now.getTime() + LEASE_TTL_MS);
144
+ const result = await db
145
+ .update(providerOauthStates)
146
+ .set({ leaseOwner: owner, leaseExpiresAt: expiresAt })
147
+ .where(
148
+ and(
149
+ eq(providerOauthStates.providerAccountId, accountId),
150
+ or(isNull(providerOauthStates.leaseOwner), lt(providerOauthStates.leaseExpiresAt, now)),
151
+ ),
152
+ )
153
+ .returning({ id: providerOauthStates.providerAccountId });
154
+ return result.length > 0;
155
+ }
156
+
157
+ /** Releases the lease without mutating credential state (used for transient failures). */
158
+ async function releaseLease(
159
+ db: CartethyiaDatabase,
160
+ accountId: string,
161
+ owner: string,
162
+ ): Promise<void> {
163
+ await db
164
+ .update(providerOauthStates)
165
+ .set({ leaseOwner: null, leaseExpiresAt: null })
166
+ .where(
167
+ and(
168
+ eq(providerOauthStates.providerAccountId, accountId),
169
+ eq(providerOauthStates.leaseOwner, owner),
170
+ ),
171
+ );
172
+ }
173
+
174
+ /**
175
+ * D21 invariant guard: a refresh is not a health verdict, so mutating the
176
+ * account's `status` here would silently re-enable an account a refresh was
177
+ * never meant to resurrect. The `.set()` calls in `persistRefreshed` accept
178
+ * `status` at the type level (it is a column on `provider_accounts`), so the
179
+ * guard is a runtime assertion that fails loudly in development instead of
180
+ * letting a future edit change health state from the refresh path.
181
+ */
182
+ function assertRefreshNeverMutatesStatus(set: Record<string, unknown>): void {
183
+ if ("status" in set) {
184
+ throw new Error(
185
+ "persistRefreshed must never write `status`; a refresh is not a health verdict",
186
+ );
187
+ }
188
+ }
189
+
190
+ /** Persists a successful refresh and releases the lease, fenced on still holding it. */
191
+ async function persistRefreshed(
192
+ db: CartethyiaDatabase,
193
+ accountId: string,
194
+ owner: string,
195
+ result: OAuthTokenRefreshResult,
196
+ ): Promise<boolean> {
197
+ return db.transaction(async (tx) => {
198
+ // This function must never write `status`; a refresh is not a health
199
+ // verdict. The guard below makes an accidental status write a loud dev
200
+ // failure rather than a silent re-enable.
201
+ const oauthSet = {
202
+ ...(result.refresh === undefined
203
+ ? {}
204
+ : { refreshCiphertext: encryptCredential(result.refresh) }),
205
+ expiresAt: result.expiresAt,
206
+ leaseOwner: null,
207
+ leaseExpiresAt: null,
208
+ };
209
+ assertRefreshNeverMutatesStatus(oauthSet);
210
+ const leaseRows = await tx
211
+ .update(providerOauthStates)
212
+ .set(oauthSet)
213
+ .where(
214
+ and(
215
+ eq(providerOauthStates.providerAccountId, accountId),
216
+ eq(providerOauthStates.leaseOwner, owner),
217
+ ),
218
+ )
219
+ .returning({ id: providerOauthStates.providerAccountId });
220
+ if (leaseRows.length === 0) return false;
221
+ const learnedLabel = result.accountLabel ?? result.accountEmail ?? result.accountName;
222
+ const accountSet = {
223
+ credentialCiphertext: encryptCredential(result.access),
224
+ // Only what the refresh reported: a refresher that learned nothing new
225
+ // leaves the stored configuration alone rather than blanking it.
226
+ // A learned identity only replaces a default label � an operator's
227
+ // custom rename is never overwritten. The CASE keeps the read and the
228
+ // write in one statement, so a concurrent rename cannot lose to the
229
+ // refresh's learned identity.
230
+ ...(learnedLabel === undefined
231
+ ? {}
232
+ : {
233
+ label: sql`CASE WHEN lower(trim(${providerAccounts.label})) = lower(trim(${providerAccounts.providerId})) OR lower(trim(${providerAccounts.label})) = lower(trim(${learnedLabel})) THEN ${learnedLabel} ELSE ${providerAccounts.label} END`,
234
+ }),
235
+ ...(result.auth_state === undefined && result.accountName === undefined
236
+ ? {}
237
+ : {
238
+ authState: {
239
+ ...(result.auth_state ?? {}),
240
+ ...(result.accountName === undefined ? {} : { accountName: result.accountName }),
241
+ },
242
+ }),
243
+ ...(result.accountEmail === undefined ? {} : { accountEmail: result.accountEmail }),
244
+ ...(result.accountEmail !== undefined || result.accountName !== undefined
245
+ ? { identityCheckedAt: new Date() }
246
+ : {}),
247
+ };
248
+ // The second half of the D21 invariant: the credential write must never
249
+ // touch the account's health state either.
250
+ assertRefreshNeverMutatesStatus(accountSet);
251
+ await tx
252
+ .update(providerAccounts)
253
+ .set(accountSet)
254
+ .where(eq(providerAccounts.id, accountId));
255
+ invalidateCredentialCache(accountId);
256
+ return true;
257
+ });
258
+ }
259
+
260
+ /**
261
+ * Packages the account-side facts a refresh needs but the refresh token does
262
+ * not carry. The companion secret is decrypted here, once, so provider clients
263
+ * never reach into the database.
264
+ */
265
+ function buildRefreshContext(row: AccountRow, includeAccessToken: boolean): OAuthRefreshContext {
266
+ const authState = asJsonbRecord(row.authState);
267
+ return {
268
+ account_id: row.id,
269
+ ...(authState === undefined ? {} : { auth_state: authState }),
270
+ ...(includeAccessToken && row.credentialCiphertext
271
+ ? { access_token: decryptCredentialToString(row.credentialCiphertext) }
272
+ : {}),
273
+ ...(row.clientSecretCiphertext
274
+ ? { client_secret: decryptCredentialToString(row.clientSecretCiphertext) }
275
+ : {}),
276
+ };
277
+ }
278
+
279
+ /**
280
+ * Marks an account permanently unusable after a definitive refresh failure,
281
+ * fenced on the lease.
282
+ *
283
+ * @param preserveOperatorDisable When true, an account that is already
284
+ * `disabled` with no `cooldownUntil` — the operator's own disable — keeps its
285
+ * lack of a recovery deadline (D23). A machine disable (this function being
286
+ * the machine) always writes a deadline for itself, but it must not convert
287
+ * an operator's terminal decision into a recoverable one: adding a deadline
288
+ * to an operator-disabled row is what made the Recover button appear on an
289
+ * account the operator disabled on purpose.
290
+ */
291
+ async function disableAccount(
292
+ db: CartethyiaDatabase,
293
+ accountId: string,
294
+ owner: string,
295
+ message: string,
296
+ options: { readonly preserveOperatorDisable?: boolean } = {},
297
+ ): Promise<void> {
298
+ await db.transaction(async (tx) => {
299
+ const leaseRows = await tx
300
+ .update(providerOauthStates)
301
+ .set({ leaseOwner: null, leaseExpiresAt: null })
302
+ .where(
303
+ and(
304
+ eq(providerOauthStates.providerAccountId, accountId),
305
+ eq(providerOauthStates.leaseOwner, owner),
306
+ ),
307
+ )
308
+ .returning({ id: providerOauthStates.providerAccountId });
309
+ if (leaseRows.length === 0) return;
310
+ const disabledRecoveryMs = resolveAccountDisabledRecoveryMs();
311
+ // Converge on the health application seam (proposal §3.5): a definitive
312
+ // OAuth refresh failure is a producer into `applyAccountVerdict`, not a
313
+ // separate state machine. The seam preserves D23 (operator-disable rows
314
+ // keep their `null` recovery deadline) via `preserveOperatorDisable`.
315
+ const applied = await applyAccountVerdict(tx, {
316
+ accountId,
317
+ status: "disabled",
318
+ // `auth_invalidated` is the category the console reads to render the
319
+ // "Re-login required" pill, and the one the quota sweep excludes so it
320
+ // stops re-probing a credential that cannot repair itself. The refresh
321
+ // grant was definitively rejected and the stored access token could not
322
+ // be shown to still work, so this is exactly that state.
323
+ reason: message,
324
+ category: "auth_invalidated",
325
+ // Carries a recovery deadline so `sweepExpiredCooldowns` can bring this
326
+ // account back once it elapses; without one, one definitive rejection
327
+ // parks the credential for good. The deadline is also what distinguishes
328
+ // this (machine-disabled) from an operator's own disable, which leaves
329
+ // `cooldownUntil` null and is therefore never swept (D23).
330
+ cooldownUntil:
331
+ disabledRecoveryMs > 0 ? new Date(Date.now() + disabledRecoveryMs) : null,
332
+ ...(options.preserveOperatorDisable === true
333
+ ? { preserveOperatorDisable: true }
334
+ : {}),
335
+ });
336
+ if (applied) invalidateCredentialCache(accountId);
337
+ });
338
+ }
339
+
340
+ /**
341
+ * Marks an account as carrying a static bearer token: used exactly as issued,
342
+ * never refreshed. Called when the refresh path finds no refresh token on
343
+ * record, so the account converges to the same state the console's explicit
344
+ * "static token" toggle sets — the sweep skips it and its status stops reading
345
+ * as a broken/re-auth account. Not lease-fenced: a non-destructive annotation
346
+ * that any process may write, and the credential is not touched.
347
+ *
348
+ * This never changes `status`: a `disabled` account stays `disabled` (only an
349
+ * operator or an explicit recovery may re-enable it), while an `active` one
350
+ * stays `active`. The static marker and the error clearing are orthogonal to
351
+ * the account's health state.
352
+ *
353
+ * D24: for a `disabled` row the error fields are deliberately kept. Once the
354
+ * account is parked, `lastErrorCategory` is the only record of *why* (e.g.
355
+ * `auth_invalidated` → "Re-login required"); wiping it on the static
356
+ * annotation would leave a disabled account with no diagnosable reason. An
357
+ * `active` row still clears them — there the error is stale by definition.
358
+ */
359
+ async function markStaticToken(db: CartethyiaDatabase, accountId: string): Promise<void> {
360
+ const rows = await db
361
+ .select({ status: providerAccounts.status })
362
+ .from(providerAccounts)
363
+ .where(eq(providerAccounts.id, accountId))
364
+ .limit(1);
365
+ const isDisabled = rows[0]?.status === "disabled";
366
+ await db
367
+ .update(providerAccounts)
368
+ .set({
369
+ staticToken: true,
370
+ cooldownUntil: null,
371
+ // A static token is a normal, usable credential, not an error state: any
372
+ // error left by the failed refresh would otherwise keep the row reading
373
+ // "Re-login required" while it is in fact serving. Mirrors the console's
374
+ // static-token toggle. For a `disabled` row the category is preserved
375
+ // (D24), because there it is the diagnosis, not a stale artifact.
376
+ ...(isDisabled
377
+ ? {}
378
+ : { lastError: null, lastErrorCategory: null, lastErrorAt: null }),
379
+ })
380
+ .where(eq(providerAccounts.id, accountId));
381
+ }
382
+
383
+ function failureMessage(error: unknown): string {
384
+ if (error instanceof Error) return error.message;
385
+ return String(error);
386
+ }
387
+
388
+ /**
389
+ * Decrypts a stored credential, returning `undefined` when the ciphertext
390
+ * cannot be decrypted. A corrupt row is not evidence about token expiry, so the
391
+ * caller treats it the same as a missing token rather than throwing inside the
392
+ * refresh failure path.
393
+ */
394
+ function safeDecryptCredential(ciphertext: Buffer): string | undefined {
395
+ try {
396
+ return decryptCredentialToString(ciphertext);
397
+ } catch {
398
+ return undefined;
399
+ }
400
+ }
401
+
402
+ /** Best-effort HTTP status extraction from an OAuth refresh error. Most
403
+ * providers surface `error.status` or embed the status in the message; we
404
+ * accept either so the classifier can honor "bare 401 -> definitive". */
405
+ function failureStatus(error: unknown): number | undefined {
406
+ if (error !== null && typeof error === "object" && "status" in error) {
407
+ const raw = error.status;
408
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
409
+ }
410
+ const msg = failureMessage(error);
411
+ const match = /\b(\d{3})\b/.exec(msg);
412
+ if (match) {
413
+ const status = Number(match[1]);
414
+ if (status >= 400 && status < 600) return status;
415
+ }
416
+ return undefined;
417
+ }
418
+
419
+ /**
420
+ * Boot-time convergence for OAuth accounts already parked by an earlier
421
+ * refresh failure.
422
+ *
423
+ * Before automatic detection existed, a definitive refresh failure disabled the
424
+ * account outright (`oauth_revoked`) even when the stored access token was
425
+ * still perfectly usable. This pass re-reads those rows once at startup and
426
+ * moves each to where it now belongs:
427
+ *
428
+ * - access token still usable ⇒ static token; the account stays `disabled`;
429
+ * only an operator can re-enable it;
430
+ * - otherwise ⇒ the `auth_invalidated` category the console renders as
431
+ * "Re-login required" and the quota sweep excludes.
432
+ *
433
+ * Idempotent and cheap: it only inspects OAuth rows that are `disabled` with a
434
+ * credential-failure category, so a healthy deployment scans nothing. The
435
+ * credential is decoded, never verified — the same structural-only trust the
436
+ * dispatch path already places in the stored token.
437
+ */
438
+ export async function reconcileStaticTokenAccounts(db: CartethyiaDatabase): Promise<number> {
439
+ const candidates = await db
440
+ .select({
441
+ id: providerAccounts.id,
442
+ credentialCiphertext: providerAccounts.credentialCiphertext,
443
+ staticToken: providerAccounts.staticToken,
444
+ })
445
+ .from(providerAccounts)
446
+ .where(
447
+ and(
448
+ eq(providerAccounts.credentialKind, "oauth"),
449
+ eq(providerAccounts.status, "disabled"),
450
+ or(
451
+ eq(providerAccounts.lastErrorCategory, "oauth_revoked"),
452
+ eq(providerAccounts.lastErrorCategory, "auth_invalidated"),
453
+ ),
454
+ ),
455
+ );
456
+ let changed = 0;
457
+ for (const row of candidates) {
458
+ if (row.staticToken) continue;
459
+ const usability = classifyAccessTokenUsability(
460
+ row.credentialCiphertext ? safeDecryptCredential(row.credentialCiphertext) : undefined,
461
+ );
462
+ if (usability === "usable") {
463
+ await markStaticToken(db, row.id);
464
+ invalidateCredentialCache(row.id);
465
+ pushStructuredConsoleLog("info", "OAuth account recovered as a static token at boot", {
466
+ event: "token_refresh",
467
+ accountId: row.id,
468
+ errorCode: "static_token",
469
+ });
470
+ changed += 1;
471
+ continue;
472
+ }
473
+ // Converge the legacy category name so the console and the quota sweep see
474
+ // one spelling of "credential rejected, operator must re-login".
475
+ await db
476
+ .update(providerAccounts)
477
+ .set({ lastErrorCategory: "auth_invalidated" })
478
+ .where(eq(providerAccounts.id, row.id));
479
+ changed += 1;
480
+ }
481
+ return changed;
482
+ }
483
+
484
+ /**
485
+ * Rows whose OAuth token is due for proactive refresh (used by the sweep worker).
486
+ *
487
+ * The due test is applied in JS, not as one SQL `expires_at < cutoff`, because
488
+ * the lead is per provider (`refreshLeadMs`): Claude re-mints ~4h ahead, Codex
489
+ * ~5 days, Antigravity ~5 min, and a single global cutoff cannot express that.
490
+ * Accounts with no recorded expiry are always due — an account the sweep cannot
491
+ * see is one whose token dies silently, which is the failure this guards.
492
+ */
493
+ /**
494
+ * Rows one due-scan reads. The expiry test runs in JS because the lead is per
495
+ * provider, so the bound has to sit on the query itself.
496
+ */
497
+ const OAUTH_DUE_SCAN_LIMIT = 2_000;
498
+
499
+ export async function loadDueOAuthAccounts(
500
+ db: CartethyiaDatabase,
501
+ skewMs?: number,
502
+ ): Promise<readonly { readonly id: string; readonly providerId: string }[]> {
503
+ const rows = await db
504
+ .select({
505
+ id: providerAccounts.id,
506
+ providerId: providerAccounts.providerId,
507
+ expiresAt: providerOauthStates.expiresAt,
508
+ staticToken: providerAccounts.staticToken,
509
+ })
510
+ .from(providerAccounts)
511
+ .innerJoin(providerOauthStates, eq(providerOauthStates.providerAccountId, providerAccounts.id))
512
+ .where(
513
+ and(
514
+ eq(providerAccounts.credentialKind, "oauth"),
515
+ // `cooldown` is included: a cooling account's token still expires, and
516
+ // a refresh that lands while it is parked is what lets it serve the
517
+ // moment the cooldown ends. `disabled` stays out — that is an
518
+ // operator decision, and refreshing it would spend a grant on an
519
+ // account that must not return to rotation on its own.
520
+ inArray(providerAccounts.status, ["active", "cooldown"]),
521
+ ),
522
+ )
523
+ // The due test is applied in JS, so this limit bounds the scan, not the
524
+ // result. One pass cannot read the whole table; accounts past the limit
525
+ // are reached on a later tick once the ones in front have been refreshed
526
+ // and dropped out of the window.
527
+ .limit(OAUTH_DUE_SCAN_LIMIT);
528
+ const now = Date.now();
529
+ return rows
530
+ .filter((row) => {
531
+ // A static token is used exactly as issued: there is no refresh grant to
532
+ // run, so the sweep must not touch it. Refreshing it every pass only
533
+ // floods the log with a guaranteed no-op. The operator clears the flag
534
+ // when the account has a real refresh token again.
535
+ if (row.staticToken) return false;
536
+ if (row.expiresAt == null) return true;
537
+ const lead = skewMs ?? refreshLeadMs(row.providerId);
538
+ return row.expiresAt.getTime() - lead <= now;
539
+ })
540
+ .map((row) => ({ id: row.id, providerId: row.providerId }));
541
+ }
542
+
543
+ export class OAuthRefreshService {
544
+ /** Bound on concurrently tracked single-flight refreshes; keys are account ids, so the real bound is the account count. */
545
+ private static readonly MAX_INFLIGHT = 1_000;
546
+
547
+ readonly #db: CartethyiaDatabase;
548
+ readonly #inFlight = new Map<string, Promise<string | null>>();
549
+
550
+ constructor(db: CartethyiaDatabase) {
551
+ this.#db = db;
552
+ }
553
+
554
+ /**
555
+ * Ensures a fresh access token for an OAuth account, refreshing it first
556
+ * if it's within `opts.skewMs` of expiry (or already expired). When
557
+ * `opts.force` is `true`, the freshness check is bypassed and the
558
+ * refresher is always invoked — used by the 401 dispatch retry so a peer
559
+ * that just refreshed cannot cause the caller to reuse a dead cached
560
+ * secret. Returns the current decrypted access token, or `null` when the
561
+ * account isn't OAuth, has no stored refresh token, or refresh failed.
562
+ *
563
+ * `opts.origin` labels who asked for the refresh (`ui`/`sweep`/`dispatch`)
564
+ * and is carried on the `token_refresh` log lines (D26). The origin is
565
+ * observational only — it never changes what a refresh does.
566
+ */
567
+ async ensureFreshAccessToken(
568
+ accountId: string,
569
+ refresher: OAuthTokenRefresher,
570
+ opts: { force?: boolean; skewMs?: number; origin?: "ui" | "sweep" | "dispatch" } = {},
571
+ ): Promise<string | null> {
572
+ const force = opts.force === true;
573
+ // No explicit skew: the per-provider lead is applied at load time.
574
+ const skewMs = opts.skewMs;
575
+ const origin = opts.origin ?? "sweep";
576
+ // Forced refreshes get their own in-flight slot so they don't merge with
577
+ // a raced non-force call that would otherwise short-circuit.
578
+ const key = force ? `${accountId}:force` : accountId;
579
+ const existing = this.#inFlight.get(key);
580
+ if (existing) return existing;
581
+ // Eviction guard: keys are account ids (bounded by the accounts table),
582
+ // but a runaway caller could still fan out unique keys; drop the oldest
583
+ // in-flight task rather than grow without bound. An evicted task's
584
+ // `.finally` delete is a no-op for a key no longer present, and a racing
585
+ // duplicate refresh is fenced cross-process by the lease below.
586
+ if (this.#inFlight.size >= OAuthRefreshService.MAX_INFLIGHT) {
587
+ const oldestKey = this.#inFlight.keys().next().value;
588
+ if (oldestKey !== undefined) this.#inFlight.delete(oldestKey);
589
+ }
590
+ const task = this.#refreshIfDue(accountId, refresher, skewMs, force, origin).finally(() => {
591
+ this.#inFlight.delete(key);
592
+ });
593
+ this.#inFlight.set(key, task);
594
+ return task;
595
+ }
596
+
597
+ async #refreshIfDue(
598
+ accountId: string,
599
+ refresher: OAuthTokenRefresher,
600
+ skewMs: number | undefined,
601
+ force: boolean,
602
+ origin: "ui" | "sweep" | "dispatch",
603
+ ): Promise<string | null> {
604
+ const account = await loadAccountWithFreshness(this.#db, accountId, skewMs);
605
+ if (!account || account.row.credentialKind !== "oauth") return null;
606
+ const health = await loadAccountWithOauthState(this.#db, accountId, {
607
+ includeStatus: true,
608
+ includeLastErrorCategory: true,
609
+ });
610
+ if (health?.status === "disabled" && health.lastErrorCategory === "auth_invalidated") {
611
+ pushStructuredConsoleLog("info", "OAuth token refresh skipped: account auth invalidated", {
612
+ event: "token_refresh",
613
+ accountId,
614
+ providerId: account.row.providerId,
615
+ errorCode: "auth_invalidated",
616
+ details: { origin },
617
+ });
618
+ return null;
619
+ }
620
+ const { row, dueAt } = account;
621
+ // A static token is used exactly as issued and never refreshed. There is
622
+ // nothing to re-mint, so return null — the same "cannot refresh" signal a
623
+ // missing refresh token gives — rather than re-issuing the identical
624
+ // credential to a forced (401) retry, which would only repeat the same
625
+ // request against the same upstream. Dispatch still uses the stored token
626
+ // directly (`dueAt` is undefined for a static token, so the caller's
627
+ // refresh branch is skipped and the decrypted credential is used as-is).
628
+ if (row.staticToken) return null;
629
+ if (!force && dueAt !== undefined && dueAt > Date.now()) {
630
+ return row.credentialCiphertext
631
+ ? decryptCredentialToString(row.credentialCiphertext)
632
+ : null;
633
+ }
634
+ return this.#refreshNow(row, refresher, skewMs, origin);
635
+ }
636
+
637
+ async #refreshNow(
638
+ row: AccountRow,
639
+ refresher: OAuthTokenRefresher,
640
+ skewMs: number | undefined,
641
+ origin: "ui" | "sweep" | "dispatch",
642
+ ): Promise<string | null> {
643
+ // D26: statusBefore is read once up front (the row projection used by the
644
+ // refresh path does not carry status) and statusAfter is re-read after any
645
+ // mutation, so the log proves a refresh never flipped the account.
646
+ const statusBefore = await readAccountStatus(this.#db, row.id);
647
+ pushStructuredConsoleLog("info", "OAuth token refresh started", {
648
+ event: "token_refresh",
649
+ accountId: row.id,
650
+ providerId: row.providerId,
651
+ details: { origin, statusBefore },
652
+ });
653
+ const owner = randomUUID();
654
+ const leased = await acquireLease(this.#db, row.id, owner);
655
+ if (!leased) {
656
+ // A peer holds the lease — briefly wait for it to finish, then read whatever it left.
657
+ await sleep(500);
658
+ const fresh = await loadAccountWithFreshness(this.#db, row.id, skewMs);
659
+ return fresh?.row.credentialCiphertext && fresh.dueAt !== undefined && fresh.dueAt > Date.now()
660
+ ? decryptCredentialToString(fresh.row.credentialCiphertext)
661
+ : null;
662
+ }
663
+ if (!row.refreshCiphertext) {
664
+ await releaseLease(this.#db, row.id, owner);
665
+ // No refresh token on record: the access token is used exactly as issued
666
+ // and cannot be re-minted, so every future refresh attempt is a
667
+ // guaranteed no-op. Mark the account static — the same state the
668
+ // console's toggle sets — so the sweep stops retrying it and the status
669
+ // reads as an informational static token rather than a broken account.
670
+ await markStaticToken(this.#db, row.id);
671
+ pushStructuredConsoleLog("info", "OAuth token refresh skipped: static token", {
672
+ event: "token_refresh",
673
+ accountId: row.id,
674
+ providerId: row.providerId,
675
+ errorCode: "static_token",
676
+ details: { origin },
677
+ });
678
+ return null;
679
+ }
680
+ const refreshToken = decryptCredentialToString(row.refreshCiphertext);
681
+ try {
682
+ const result = await refresher.refresh(
683
+ refreshToken,
684
+ AbortSignal.timeout(REFRESH_HTTP_TIMEOUT_MS),
685
+ buildRefreshContext(row, refresher.requiresAccessToken === true),
686
+ );
687
+ const persisted = await persistRefreshed(this.#db, row.id, owner, result);
688
+ // D26: `statusAfter` is re-read from the DB rather than assumed — a
689
+ // successful refresh never changes `status`, and the log proves it.
690
+ const statusAfter = await readAccountStatus(this.#db, row.id);
691
+ pushStructuredConsoleLog(persisted ? "info" : "warn", persisted ? "OAuth token refresh completed" : "OAuth token refresh lost lease", {
692
+ event: "token_refresh",
693
+ accountId: row.id,
694
+ providerId: row.providerId,
695
+ ...(persisted ? {} : { errorCode: "refresh_lease_lost" }),
696
+ details: {
697
+ expiresAt: result.expiresAt.toISOString(),
698
+ origin,
699
+ statusBefore,
700
+ statusAfter,
701
+ },
702
+ });
703
+ return persisted ? result.access : null;
704
+ } catch (error) {
705
+ const classification = classifyOAuthRefreshFailure({
706
+ message: failureMessage(error),
707
+ status: failureStatus(error),
708
+ });
709
+ pushStructuredConsoleLog("error", "OAuth token refresh failed", {
710
+ event: "token_refresh",
711
+ accountId: row.id,
712
+ providerId: row.providerId,
713
+ errorCode: classification,
714
+ details: { origin, statusBefore },
715
+ });
716
+ if (classification === "definitive") {
717
+ // A definitive refresh failure means the refresh grant is dead — but
718
+ // the access token already on the row may still be usable as issued.
719
+ // That is exactly the static-token case: stop refreshing, keep serving.
720
+ // Only when the token cannot be shown to work (expired or opaque) does
721
+ // the account need the operator, and it is parked for re-auth.
722
+ const accessUsability = classifyAccessTokenUsability(
723
+ row.credentialCiphertext
724
+ ? safeDecryptCredential(row.credentialCiphertext)
725
+ : undefined,
726
+ );
727
+ if (accessUsability === "usable") {
728
+ await releaseLease(this.#db, row.id, owner);
729
+ await markStaticToken(this.#db, row.id);
730
+ const statusAfter = await readAccountStatus(this.#db, row.id);
731
+ pushStructuredConsoleLog("info", "OAuth refresh failed; access token still valid, marking static", {
732
+ event: "token_refresh",
733
+ accountId: row.id,
734
+ providerId: row.providerId,
735
+ errorCode: "static_token",
736
+ details: { origin, statusBefore, statusAfter },
737
+ });
738
+ } else {
739
+ await disableAccount(this.#db, row.id, owner, failureMessage(error), {
740
+ // D23: a definitive refresh failure must not turn an operator's
741
+ // disable into a machine-recoverable one. If the row is already
742
+ // `disabled` without a deadline, leave it that way.
743
+ preserveOperatorDisable: true,
744
+ });
745
+ const statusAfter = await readAccountStatus(this.#db, row.id);
746
+ pushStructuredConsoleLog("warn", "OAuth refresh failed definitively; account disabled for re-auth", {
747
+ event: "token_refresh",
748
+ accountId: row.id,
749
+ providerId: row.providerId,
750
+ errorCode: classification,
751
+ details: { origin, statusBefore, statusAfter },
752
+ });
753
+ }
754
+ } else {
755
+ await releaseLease(this.#db, row.id, owner);
756
+ }
757
+ return null;
758
+ }
759
+ }
760
+ }
761
+
762
+ /** Reads one account's status (used only for logging/observability, D26). */
763
+ async function readAccountStatus(db: CartethyiaDatabase, accountId: string): Promise<string | undefined> {
764
+ const loaded = await loadAccountWithOauthState(db, accountId, { includeStatus: true });
765
+ return loaded?.status;
766
+ }