cartethyia 2.7.2 → 2.7.3

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 (249) hide show
  1. package/.env.example +2 -0
  2. package/dist/dashboard/assets/{BannedPage-XaUmiBrC.js → BannedPage-2TJD37Hb.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-2TJD37Hb.js.gz +0 -0
  4. package/dist/dashboard/assets/CliToolDetailPage-xefJNRmJ.js +1 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-xefJNRmJ.js.gz +0 -0
  6. package/dist/dashboard/assets/CliToolsPage-Rj9iHQ3F.js +1 -0
  7. package/dist/dashboard/assets/CliToolsPage-Rj9iHQ3F.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-CXGS_Ct-.js → CombosPage-JEbYJFxw.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-JEbYJFxw.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-DGnGNYnM.js → ConsoleLogPage-C3xx_inM.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-C3xx_inM.js.gz +0 -0
  12. package/dist/dashboard/assets/CustomizationPage-Dw306gJU.js +1 -0
  13. package/dist/dashboard/assets/CustomizationPage-Dw306gJU.js.gz +0 -0
  14. package/dist/dashboard/assets/HealthEventsModal-jaBo1TWV.js +1 -0
  15. package/dist/dashboard/assets/HealthEventsModal-jaBo1TWV.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-BdmRLnq2.js → LoginPage-C4w7vea-.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-C4w7vea-.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-BH8Tehdi.js → ModelPicker-BtPcAfot.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-BtPcAfot.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-CGC5gNs1.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-CGC5gNs1.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-DQcthNGt.js +4 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-DQcthNGt.js.gz +0 -0
  24. package/dist/dashboard/assets/ProvidersPage-0OsXRGXe.js +1 -0
  25. package/dist/dashboard/assets/ProvidersPage-0OsXRGXe.js.gz +0 -0
  26. package/dist/dashboard/assets/ProxyPage-DdPc8slb.js +9 -0
  27. package/dist/dashboard/assets/ProxyPage-DdPc8slb.js.gz +0 -0
  28. package/dist/dashboard/assets/{QuotaPage-7P2n--Fv.js → QuotaPage-Ci5O4BnH.js} +1 -1
  29. package/dist/dashboard/assets/QuotaPage-Ci5O4BnH.js.gz +0 -0
  30. package/dist/dashboard/assets/{SettingsPage-CxZ1fTkN.js → SettingsPage-CDZjTt87.js} +1 -1
  31. package/dist/dashboard/assets/SettingsPage-CDZjTt87.js.gz +0 -0
  32. package/dist/dashboard/assets/SetupPage-BQS0Rx_u.js +1 -0
  33. package/dist/dashboard/assets/SetupPage-BQS0Rx_u.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-Dqf4zpS-.js → SortableList-BAL5npBC.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-BAL5npBC.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-Byj2lAEm.js → StudioPage-TP7voAQ0.js} +3 -3
  37. package/dist/dashboard/assets/StudioPage-TP7voAQ0.js.gz +0 -0
  38. package/dist/dashboard/assets/{UsagePage-CTkLAuRc.js → UsagePage-Cj_fEzhg.js} +13 -13
  39. package/dist/dashboard/assets/UsagePage-Cj_fEzhg.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-CUi6UXsR.js → api-keys-MbI7e7rt.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-MbI7e7rt.js.gz +0 -0
  42. package/dist/dashboard/assets/app-B2vyPoII.css +2 -0
  43. package/dist/dashboard/assets/app-B2vyPoII.css.gz +0 -0
  44. package/dist/dashboard/assets/arrow-up-59ms1iiT.js +1 -0
  45. package/dist/dashboard/assets/arrow-up-59ms1iiT.js.gz +0 -0
  46. package/dist/dashboard/assets/{assert-67xXH5rf.js → assert-C0YYuZJ4.js} +1 -1
  47. package/dist/dashboard/assets/assert-C0YYuZJ4.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-DC2L43Mj.js → badge-B_DMpMJN.js} +1 -1
  49. package/dist/dashboard/assets/badge-B_DMpMJN.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-BBVcb0J3.js → banner-DMrMPmth.js} +1 -1
  51. package/dist/dashboard/assets/banner-DMrMPmth.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-Cca1XC6G.js → card-Be5Uzamx.js} +1 -1
  53. package/dist/dashboard/assets/card-Be5Uzamx.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-KvsWa1Jl.js → cli-tools-muFRphDu.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-muFRphDu.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-DUzEbynq.js → clipboard-button-DOfrVmsi.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-DOfrVmsi.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-DFvWhwTa.js → dialog-C9omdjQt.js} +1 -1
  59. package/dist/dashboard/assets/dialog-C9omdjQt.js.gz +0 -0
  60. package/dist/dashboard/assets/drawer-Bm8WKaGY.js +1 -0
  61. package/dist/dashboard/assets/drawer-Bm8WKaGY.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-_xHo5x6r.js → entry-DS90gUks.js} +1 -1
  63. package/dist/dashboard/assets/entry-DS90gUks.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-ClCMCWWQ.js → entry-j40LJotO.js} +3 -3
  65. package/dist/dashboard/assets/entry-j40LJotO.js.gz +0 -0
  66. package/dist/dashboard/assets/{entry-Bqy8RFvM.js → entry-q5HwZ8nN.js} +1 -1
  67. package/dist/dashboard/assets/entry-q5HwZ8nN.js.gz +0 -0
  68. package/dist/dashboard/assets/{concurrency-BDF0ok7y.js → fingerprint-pattern-BWuHtbW4.js} +1 -1
  69. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  70. package/dist/dashboard/assets/{formatters-CWWZ-uCZ.js → formatters-BTScrQpe.js} +1 -1
  71. package/dist/dashboard/assets/formatters-BTScrQpe.js.gz +0 -0
  72. package/dist/dashboard/assets/{github-badge-C9e8Ts7e.js → github-badge-Orjuzemr.js} +1 -1
  73. package/dist/dashboard/assets/github-badge-Orjuzemr.js.gz +0 -0
  74. package/dist/dashboard/assets/{index-DFVFrnWW.js → index-cPrX5vn6.js} +2 -2
  75. package/dist/dashboard/assets/index-cPrX5vn6.js.gz +0 -0
  76. package/dist/dashboard/assets/{inline-BFUcvllj.js → inline-Dvux8w-0.js} +1 -1
  77. package/dist/dashboard/assets/inline-Dvux8w-0.js.gz +0 -0
  78. package/dist/dashboard/assets/{input-C_ILAvBb.js → input-CU1Rgf3p.js} +1 -1
  79. package/dist/dashboard/assets/input-CU1Rgf3p.js.gz +0 -0
  80. package/dist/dashboard/assets/{layout-CBIyT6Rt.js → layout-C_7A6H55.js} +1 -1
  81. package/dist/dashboard/assets/layout-C_7A6H55.js.gz +0 -0
  82. package/dist/dashboard/assets/{list-row-ebJ_-Nsb.js → list-row-CzFvogbj.js} +1 -1
  83. package/dist/dashboard/assets/list-row-CzFvogbj.js.gz +0 -0
  84. package/dist/dashboard/assets/{live-D0mm--ME.js → live-KaG15HLQ.js} +1 -1
  85. package/dist/dashboard/assets/live-KaG15HLQ.js.gz +0 -0
  86. package/dist/dashboard/assets/{mutation-sMD965oO.js → mutation-JJGQm8TD.js} +1 -1
  87. package/dist/dashboard/assets/mutation-JJGQm8TD.js.gz +0 -0
  88. package/dist/dashboard/assets/{numbers-BO9dKaFr.js → numbers-BNDZnsUE.js} +1 -1
  89. package/dist/dashboard/assets/numbers-BNDZnsUE.js.gz +0 -0
  90. package/dist/dashboard/assets/{query-keys-CfL_Zdoh.js → query-keys-CLaNz8Xz.js} +1 -1
  91. package/dist/dashboard/assets/query-keys-CLaNz8Xz.js.gz +0 -0
  92. package/dist/dashboard/assets/{routing-BnIpi1IJ.js → routing-BEH32QOC.js} +1 -1
  93. package/dist/dashboard/assets/routing-BEH32QOC.js.gz +0 -0
  94. package/dist/dashboard/assets/{select-DBNVLdUu.js → select-B3iTetHN.js} +1 -1
  95. package/dist/dashboard/assets/select-B3iTetHN.js.gz +0 -0
  96. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js +1 -0
  97. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js.gz +0 -0
  98. package/dist/dashboard/assets/{settings-BAFhDgX8.js → settings-Dt3Eqbnq.js} +1 -1
  99. package/dist/dashboard/assets/settings-Dt3Eqbnq.js.gz +0 -0
  100. package/dist/dashboard/assets/{stack-EMrOMt4I.js → stack-KrOrm7JZ.js} +1 -1
  101. package/dist/dashboard/assets/stack-KrOrm7JZ.js.gz +0 -0
  102. package/dist/dashboard/assets/{state-CWoo7ty5.js → state-DLS7IDMy.js} +1 -1
  103. package/dist/dashboard/assets/state-DLS7IDMy.js.gz +0 -0
  104. package/dist/dashboard/assets/{switch-CCjT6IkF.js → switch-42ItvbAs.js} +1 -1
  105. package/dist/dashboard/assets/switch-42ItvbAs.js.gz +0 -0
  106. package/dist/dashboard/assets/{useMutation-DjkLHUcE.js → useMutation-Dmnfh1Ue.js} +1 -1
  107. package/dist/dashboard/assets/useMutation-Dmnfh1Ue.js.gz +0 -0
  108. package/dist/dashboard/index.html +3 -2
  109. package/dist/dashboard/index.html.gz +0 -0
  110. package/dist/dashboard/precompressed-assets.json +117 -115
  111. package/migrations/full-postgres/0026_add_qoder_native_identity.sql +20 -0
  112. package/migrations/full-postgres/0027_qoder_identity_genuine_seed.sql +22 -0
  113. package/migrations/full-postgres/0028_add_family_template_key_telemetry.sql +40 -0
  114. package/migrations/full-postgres/0029_null_share_child_policy_columns.sql +38 -0
  115. package/migrations/full-postgres/0030_add_api_key_max_children.sql +10 -0
  116. package/migrations/full-postgres/0031_tenant_system_prompts.sql +16 -0
  117. package/migrations/lite-sqlite3/0026_add_qoder_native_identity.sql +15 -0
  118. package/migrations/lite-sqlite3/0027_qoder_identity_genuine_seed.sql +22 -0
  119. package/migrations/lite-sqlite3/0028_add_family_template_key_telemetry.sql +40 -0
  120. package/migrations/lite-sqlite3/0029_null_share_child_policy_columns.sql +38 -0
  121. package/migrations/lite-sqlite3/0030_add_api_key_max_children.sql +10 -0
  122. package/migrations/lite-sqlite3/0031_tenant_system_prompts.sql +10 -0
  123. package/package.json +1 -1
  124. package/src/config/network.ts +1 -1
  125. package/src/console/api-keys/bucket-spend.ts +7 -7
  126. package/src/console/api-keys/contracts.ts +423 -410
  127. package/src/console/api-keys/routes.ts +118 -19
  128. package/src/console/backup/export-endpoint.ts +8 -11
  129. package/src/console/backup/routes.ts +40 -8
  130. package/src/console/backup/service.ts +11 -6
  131. package/src/console/backup/store.ts +384 -88
  132. package/src/console/dashboard-assets.ts +16 -1
  133. package/src/console/domain-registration.ts +2 -0
  134. package/src/console/quota/account-quota-tenant.ts +8 -1
  135. package/src/console/quota/account-quota.ts +4 -1
  136. package/src/console/quota/native-identity.ts +189 -0
  137. package/src/console/remote-support/contracts.ts +12 -6
  138. package/src/console/remote-support/service.ts +2 -2
  139. package/src/console/share/share-router.ts +39 -6
  140. package/src/console/share/share-stats.ts +64 -25
  141. package/src/console/share/share-usage.ts +24 -7
  142. package/src/console/system-prompt/contracts.ts +102 -0
  143. package/src/console/system-prompt/routes.ts +165 -0
  144. package/src/main.ts +11 -0
  145. package/src/observability/event-query.ts +118 -7
  146. package/src/persistence/db-pg.ts +42 -0
  147. package/src/persistence/schema.ts +102 -0
  148. package/src/persistence/stores/api-key-store.ts +753 -581
  149. package/src/persistence/stores/share-store.ts +65 -13
  150. package/src/persistence/stores/telemetry-store.ts +57 -1
  151. package/src/persistence/system-prompt-store.ts +104 -0
  152. package/src/protocol/reasoning.ts +65 -1
  153. package/src/protocol/request/chat.ts +1 -0
  154. package/src/providers/integrations/antigravity/antigravity.ts +20 -23
  155. package/src/providers/integrations/buddy/buddy-chat-shared.ts +30 -2
  156. package/src/providers/integrations/buddy/buddy-media.ts +5 -1
  157. package/src/providers/integrations/buddy/codebuddy/provider.ts +5 -1
  158. package/src/providers/integrations/buddy/workbuddy/provider.ts +5 -1
  159. package/src/providers/integrations/cline/cline.ts +7 -0
  160. package/src/providers/integrations/github/github.ts +20 -1
  161. package/src/providers/integrations/hermes.ts +5 -1
  162. package/src/providers/integrations/kilo/kilo.ts +4 -0
  163. package/src/providers/integrations/openrouter/provider.ts +4 -0
  164. package/src/providers/integrations/qoder/qoder-checkin.ts +224 -51
  165. package/src/providers/integrations/qoder/qoder-native-identity-store.ts +156 -0
  166. package/src/providers/integrations/qoder/qoder-oauth.ts +12 -3
  167. package/src/providers/integrations/qoder/qoder-umid.ts +288 -0
  168. package/src/providers/integrations/qoder/qoder.ts +5 -3
  169. package/src/providers/operations/account-error-classification.ts +6 -8
  170. package/src/providers/operations/account-health-service.ts +14 -16
  171. package/src/providers/operations/client-versions.ts +37 -22
  172. package/src/providers/provider-contracts.ts +8 -0
  173. package/src/security/access-control.ts +19 -0
  174. package/src/security/api-key-auth.ts +14 -1
  175. package/src/transport/dispatch/proxy-request.ts +31 -2
  176. package/src/transport/dispatch/streaming/stream.ts +16 -1
  177. package/src/transport/dispatch/upstream.ts +10 -0
  178. package/src/transport/request/preparer.ts +17 -1
  179. package/src/transport/request/system-prompt-injection.ts +89 -0
  180. package/src/transport/request/tenant-preferences.ts +43 -0
  181. package/src/transport/routing/admission.ts +13 -3
  182. package/src/transport/routing/route-catalog.ts +7 -0
  183. package/src/transport/routing/route-model.ts +53 -0
  184. package/src/transport/routing/router.ts +306 -256
  185. package/src/workers/daily-checkin.ts +2 -0
  186. package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
  187. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js +0 -1
  188. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
  189. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js +0 -1
  190. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
  191. package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
  192. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
  193. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js +0 -1
  194. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
  195. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js +0 -1
  196. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
  197. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js.gz +0 -0
  198. package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
  199. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js +0 -1
  200. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
  201. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +0 -4
  202. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js.gz +0 -0
  203. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js +0 -1
  204. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
  205. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js +0 -9
  206. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js.gz +0 -0
  207. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
  208. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
  209. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js +0 -1
  210. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
  211. package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
  212. package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
  213. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js.gz +0 -0
  214. package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
  215. package/dist/dashboard/assets/app-KvsUJUMF.css +0 -2
  216. package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
  217. package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
  218. package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
  219. package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
  220. package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
  221. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
  222. package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
  223. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  224. package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
  225. package/dist/dashboard/assets/drawer-CFM2sMX1.js +0 -1
  226. package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
  227. package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
  228. package/dist/dashboard/assets/entry-ClCMCWWQ.js.gz +0 -0
  229. package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
  230. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js.gz +0 -0
  231. package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
  232. package/dist/dashboard/assets/index-DFVFrnWW.js.gz +0 -0
  233. package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
  234. package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
  235. package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
  236. package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
  237. package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
  238. package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
  239. package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
  240. package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
  241. package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
  242. package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
  243. package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
  244. package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
  245. package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
  246. package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
  247. package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
  248. /package/dist/dashboard/assets/{app-BaWd0puM.js → app-wvwCi4Eh.js} +0 -0
  249. /package/dist/dashboard/assets/{app-BaWd0puM.js.gz → app-wvwCi4Eh.js.gz} +0 -0
@@ -5,6 +5,7 @@ import { providerBaseUrl } from "../../provider-metadata";
5
5
  import type { DiscoveryInput } from "../../discovery/discovery-types";
6
6
  import { defineModel } from "../../provider-model-definition";
7
7
  import { openrouterFreeRoster } from "../../discovery/openrouter-free-roster";
8
+ import { clineReasoningReplayPrepares } from "../buddy/buddy-chat-shared";
8
9
 
9
10
  const OPENROUTER_PROVIDER_ID = "openrouter" as const;
10
11
 
@@ -112,6 +113,9 @@ export const OPENROUTER_SPEC: ApiKeyProviderSpec = {
112
113
  provider_id: OPENROUTER_PROVIDER_ID,
113
114
  endpoint_paths_by_wire_family: {},
114
115
  extra_headers: OPENROUTER_REQUIRED_HEADERS,
116
+ prePayload: (payload) => {
117
+ for (const prepare of clineReasoningReplayPrepares) prepare(payload);
118
+ },
115
119
  };
116
120
 
117
121
  export async function discoverOpenrouterModels(
@@ -17,6 +17,18 @@
17
17
  * the console surface treat Qoder like any other daily-grant provider.
18
18
  */
19
19
  import { QODER_REALMS, qoderRealmForProvider } from "./qoder-realm";
20
+ import { getQoderVersion } from "../../operations/client-versions";
21
+ import {
22
+ qoderMachineHeaders,
23
+ type QoderNativeIdentity,
24
+ } from "./qoder-umid";
25
+ import {
26
+ allocateQoderNativeIdentity,
27
+ ensureQoderNativeIdentity,
28
+ type QoderNativeIdentityStore,
29
+ } from "./qoder-native-identity-store";
30
+ import type { NativeIdentityRecord } from "../../../persistence/schema";
31
+ import { log } from "../../../observability/logger";
20
32
 
21
33
  /** Structural fetch bound; Bun's own fetch type carries extra host props. */
22
34
  type Fetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
@@ -37,6 +49,36 @@ const CHECKIN_CLAIM_PATH = "/sash/api/v1/me/daily-check-in/claim";
37
49
  const CAMPAIGNS_PATH = "/sash/api/v1/me/campaigns";
38
50
  const CAMPAIGN_CLAIM_PATH = "/sash/api/v1/me/campaigns/%s/claim";
39
51
 
52
+ /**
53
+ * Headers the official desktop client sends to the `/sash` campaign surface.
54
+ * Without these — notably `cosy-clienttype`, `cosy-version`, and the `Qoder`
55
+ * user agent — the upstream hides the campaign list entirely
56
+ * (`showCampaign:false`), so the daily grant never appears. Verified live:
57
+ * the bare Authorization+Accept request returns an empty list, the desktop
58
+ * header set returns `showCampaign:true` with the campaigns populated.
59
+ *
60
+ * `native` optionally carries the UMID machine headers (see `qoder-umid`);
61
+ * when present they unlock the daily 100-credit campaign, which the server
62
+ * otherwise silently filters out.
63
+ */
64
+ export function buildCampaignHeaders(
65
+ credential: string,
66
+ native?: Record<string, string>,
67
+ ): Record<string, string> {
68
+ return {
69
+ Authorization: `Bearer ${credential}`,
70
+ Accept: "application/json",
71
+ "User-Agent": "Qoder",
72
+ "Cosy-ClientType": "10",
73
+ "Cosy-Version": getQoderVersion(),
74
+ ...(native ?? {}),
75
+ };
76
+ }
77
+
78
+ function campaignHeaders(credential: string): Record<string, string> {
79
+ return buildCampaignHeaders(credential);
80
+ }
81
+
40
82
  function asRecord(value: unknown): Record<string, unknown> | null {
41
83
  if (typeof value !== "object" || value === null) return null;
42
84
  return value as Record<string, unknown>;
@@ -80,19 +122,32 @@ function asState(state: string, today: string, lastClaimedAt: unknown): { active
80
122
  return { active, todayCheckedIn: claimedToday };
81
123
  }
82
124
 
83
- /** GET campaign list; returns `null` when the surface is unreachable/unauthorized. */
84
- async function fetchCampaigns(
125
+ /**
126
+ * Normalizes one upstream campaign row to the snake_case shape the claim loop
127
+ * consumes. The API returns camelCase keys (`campaignId`, `actionType`,
128
+ * `claimStatus`); the fixtures and the donor hub use snake_case — accept both.
129
+ */
130
+ function normalizeCampaign(raw: Record<string, unknown>): Record<string, unknown> {
131
+ const benefit = asRecord(raw["benefit"]);
132
+ return {
133
+ campaign_id: String(raw["campaignId"] ?? raw["campaign_id"] ?? ""),
134
+ campaign_key: String(raw["campaignKey"] ?? raw["campaign_key"] ?? ""),
135
+ action_type: String(raw["actionType"] ?? raw["action_type"] ?? ""),
136
+ claim_status: String(raw["claimStatus"] ?? raw["claim_status"] ?? ""),
137
+ ...(benefit !== null ? { benefit } : {}),
138
+ };
139
+ }
140
+
141
+ /** GET campaign list once with given headers; `null` when unreachable/unauthorized. */
142
+ async function getCampaigns(
85
143
  base: string,
86
- credential: string,
144
+ headers: Record<string, string>,
87
145
  fetcher: Fetcher,
88
146
  ): Promise<Record<string, unknown>[] | null> {
89
147
  try {
90
148
  const response = await fetcher(`${base}${CAMPAIGNS_PATH}`, {
91
149
  method: "GET",
92
- headers: {
93
- Authorization: `Bearer ${credential}`,
94
- Accept: "application/json",
95
- },
150
+ headers,
96
151
  signal: AbortSignal.timeout(TIMEOUT_MS),
97
152
  });
98
153
  if (response.status === 401 || response.status === 403) return null;
@@ -100,12 +155,94 @@ async function fetchCampaigns(
100
155
  const payload: unknown = await response.json().catch(() => null);
101
156
  const rec = asRecord(payload);
102
157
  const list = Array.isArray(rec?.["campaigns"]) ? rec!["campaigns"] : Array.isArray(payload) ? payload : null;
103
- return Array.isArray(list) ? (list as Record<string, unknown>[]) : null;
158
+ if (!Array.isArray(list)) return null;
159
+ return (list as Record<string, unknown>[]).map(normalizeCampaign);
104
160
  } catch {
105
161
  return null;
106
162
  }
107
163
  }
108
164
 
165
+ /**
166
+ * One attempted claim across a header set, returning the same result shape as
167
+ * `claimCampaign` (already-claimed grants resolve to `already_claimed`).
168
+ */
169
+ async function tryClaimDaily(
170
+ base: string,
171
+ headers: Record<string, string>,
172
+ fetcher: Fetcher,
173
+ ): Promise<{ state: DailyCheckinState; amount: number; error: string | null }> {
174
+ const campaigns = await getCampaigns(base, headers, fetcher);
175
+ if (campaigns === null) {
176
+ return { state: "error", amount: 0, error: "Campaign list unavailable." };
177
+ }
178
+ for (const campaign of campaigns) {
179
+ const benefit = asRecord(campaign["benefit"]);
180
+ const kind = String(benefit?.["kind"] ?? "").toUpperCase();
181
+ if (kind !== "CREDITS" && kind !== "") continue;
182
+ const actionType = String(campaign["action_type"] ?? "");
183
+ if (actionType !== "" && actionType !== "CLAIM_BENEFIT") continue;
184
+ const claimStatus = String(campaign["claim_status"] ?? "").toUpperCase();
185
+ if (claimStatus === "CLAIMED") return { state: "already_claimed", amount: 0, error: null };
186
+ if (claimStatus !== "CLAIMABLE") continue;
187
+ const campaignId = String(campaign["campaign_id"] ?? "");
188
+ if (!campaignId) continue;
189
+ const claim = await claimWithHeaders(base, campaignId, headers, fetcher);
190
+ if (claim.state === "claimed") {
191
+ const amount = asNumber(benefit?.["amount"]) ?? claim.amount;
192
+ return { state: "claimed", amount, error: null };
193
+ }
194
+ if (claim.state === "already_claimed") return { state: "already_claimed", amount: 0, error: null };
195
+ if (claim.state === "not_eligible") return { state: "not_eligible", amount: 0, error: null };
196
+ return { state: "error", amount: 0, error: "Campaign claim failed." };
197
+ }
198
+ return { state: "unavailable", amount: 0, error: "No daily CREDITS campaign claimable." };
199
+ }
200
+
201
+ /**
202
+ * Probes the daily CREDITS grant under a native device identity.
203
+ *
204
+ * The server binds the grant to the device identity and hides it once a
205
+ * device claimed today, so an account claims under its own persisted device
206
+ * (minted seed). A bound identity that resolves to `claimed`/`already_claimed`
207
+ * is terminal; one that resolves to `unavailable`/`not_eligible` is honest
208
+ * (the device is spent for the day or the person is blocked) and is also
209
+ * terminal — the account must not move to a different device, because that is
210
+ * how devices are re-spun. Only an *error* (stored identity stopped working)
211
+ * falls back to a freshly minted identity.
212
+ *
213
+ * Returns `{ result, identity }` where `identity` is the device that produced
214
+ * a terminal `claimed`/`already_claimed` outcome (or `null`).
215
+ */
216
+ async function probeDailyClaim(
217
+ base: string,
218
+ credential: string,
219
+ fetcher: Fetcher,
220
+ options: {
221
+ readonly bound?: QoderNativeIdentity | null;
222
+ readonly alt?: QoderNativeIdentity | null;
223
+ } = {},
224
+ ): Promise<{ result: { state: DailyCheckinState; amount: number; error: string | null }; identity: QoderNativeIdentity | null }> {
225
+ const basic = await tryClaimDaily(base, campaignHeaders(credential), fetcher);
226
+ if (basic.state === "claimed" || basic.state === "already_claimed") {
227
+ return { result: basic, identity: null };
228
+ }
229
+ if (basic.state === "unavailable") {
230
+ const identities = [options.bound, options.alt].filter((i): i is QoderNativeIdentity => i !== null && i !== undefined);
231
+ for (const identity of identities) {
232
+ const headers = { ...campaignHeaders(credential), ...qoderMachineHeaders(identity) };
233
+ const result = await tryClaimDaily(base, headers, fetcher);
234
+ if (result.state === "claimed" || result.state === "already_claimed") {
235
+ return { result, identity };
236
+ }
237
+ // Only an error falls through to the next identity (e.g. the bound one
238
+ // stopped working). Any honest outcome (unavailable/not_eligible) is
239
+ // terminal.
240
+ if (result.state !== "error") return { result, identity };
241
+ }
242
+ }
243
+ return { result: basic, identity: null };
244
+ }
245
+
109
246
  /**
110
247
  * Claims one campaign and returns the resulting state. Mirrors the donor's
111
248
  * `claim_campaign` idempotence handling: a 409 or `ALREADY_CLAIMED` body is
@@ -113,10 +250,10 @@ async function fetchCampaigns(
113
250
  * same-person block (not a failure of this account's day), and anything else
114
251
  * is an error.
115
252
  */
116
- async function claimCampaign(
253
+ async function claimWithHeaders(
117
254
  base: string,
118
255
  campaignId: string,
119
- credential: string,
256
+ headers: Record<string, string>,
120
257
  fetcher: Fetcher,
121
258
  ): Promise<{ state: DailyCheckinState; amount: number }> {
122
259
  const url = base + CAMPAIGN_CLAIM_PATH.replace("%s", encodeURIComponent(campaignId));
@@ -124,11 +261,7 @@ async function claimCampaign(
124
261
  try {
125
262
  response = await fetcher(url, {
126
263
  method: "POST",
127
- headers: {
128
- Authorization: `Bearer ${credential}`,
129
- "Content-Type": "application/json",
130
- Accept: "application/json",
131
- },
264
+ headers: { ...headers, "Content-Type": "application/json" },
132
265
  body: "{}",
133
266
  signal: AbortSignal.timeout(TIMEOUT_MS),
134
267
  });
@@ -233,6 +366,16 @@ export async function fetchQoderDailyCheckin(args: {
233
366
  readonly credential: string;
234
367
  readonly fetcher: Fetcher;
235
368
  readonly now?: () => Date;
369
+ /**
370
+ * The account's persisted native device identity. When absent and
371
+ * `nativeIdentityStore` is provided, the first check-in mints one and
372
+ * persists it (see `allocateQoderNativeIdentity`). When the store is absent
373
+ * too (tests), `identities` supplies the identities to probe.
374
+ */
375
+ readonly nativeIdentity?: NativeIdentityRecord | null;
376
+ readonly nativeIdentityStore?: QoderNativeIdentityStore;
377
+ /** Test-only: override the identity list (defaults to bridge-minted). */
378
+ readonly identities?: readonly QoderNativeIdentity[];
236
379
  }): Promise<DailyCheckinResult> {
237
380
  const { providerId, accountId, credential, fetcher } = args;
238
381
  const baseResult = baseOutcome(providerId, accountId);
@@ -258,46 +401,76 @@ export async function fetchQoderDailyCheckin(args: {
258
401
  };
259
402
  }
260
403
 
261
- // Legacy surface absent: campaign fallback.
262
- const campaigns = await fetchCampaigns(base, credential, fetcher);
263
- if (campaigns === null) {
264
- return { ...baseResult, state: "error", error: "Check-in surface unavailable (legacy 404 and campaign list failed)." };
404
+ // Legacy surface absent: campaign fallback. Prefer the account's persisted
405
+ // device (loaded from the store); on first check-in with no stored device,
406
+ // mint one before probing so the account claims under the same identity
407
+ // every day.
408
+ const store = args.nativeIdentityStore;
409
+ let bound: QoderNativeIdentity | null = null;
410
+ let boundRecord: NativeIdentityRecord | null = args.nativeIdentity ?? null;
411
+ if (store) {
412
+ boundRecord = (await store.get(providerId, accountId)) ?? boundRecord;
265
413
  }
266
- let alreadySeen = false;
267
- for (const campaign of campaigns) {
268
- const rec = asRecord(campaign);
269
- if (!rec) continue;
270
- const benefit = asRecord(rec["benefit"]);
271
- const kind = String(benefit?.["kind"] ?? "").toUpperCase();
272
- if (kind !== "CREDITS" && kind !== "") continue;
273
- const actionType = String(rec["action_type"] ?? "");
274
- if (actionType !== "" && actionType !== "CLAIM_BENEFIT") continue;
275
- const claimStatus = String(rec["claim_status"] ?? "").toUpperCase();
276
- if (claimStatus === "CLAIMED") {
277
- alreadySeen = true;
278
- continue;
414
+ if (boundRecord) {
415
+ // Ensure the stored device is active on this host (replay its seed).
416
+ const active = store ? await ensureQoderNativeIdentity(store, providerId, accountId) : true;
417
+ if (active) {
418
+ bound = {
419
+ machineToken: boundRecord.machineToken,
420
+ machineType: boundRecord.machineType,
421
+ machineCode: boundRecord.machineCode,
422
+ isVm: false,
423
+ source: "runtime-info",
424
+ };
279
425
  }
280
- if (claimStatus !== "CLAIMABLE") continue;
281
- const campaignId = String(rec["campaign_id"] ?? "");
282
- if (!campaignId) continue;
283
- const result = await claimCampaign(base, campaignId, credential, fetcher);
284
- if (result.state === "claimed") {
285
- const amount = asNumber(benefit?.["amount"]) ?? result.amount;
286
- return { ...baseResult, state: "claimed", credit: amount, streakDays: null };
287
- }
288
- if (result.state === "already_claimed") {
289
- alreadySeen = true;
290
- continue;
426
+ }
427
+ if (!bound && store) {
428
+ const minted = await allocateQoderNativeIdentity(store, providerId, accountId);
429
+ if (minted) {
430
+ boundRecord = minted;
431
+ bound = {
432
+ machineToken: minted.machineToken,
433
+ machineType: minted.machineType,
434
+ machineCode: minted.machineCode,
435
+ isVm: false,
436
+ source: "runtime-info",
437
+ };
291
438
  }
292
- if (result.state === "error") {
293
- return { ...baseResult, state: "error", error: "Campaign claim failed." };
439
+ }
440
+
441
+ const alt = args.identities?.[0] ?? null;
442
+ const probe = await probeDailyClaim(base, credential, fetcher, {
443
+ bound,
444
+ alt,
445
+ });
446
+ const probed = probe.result;
447
+
448
+ // If a non-bound identity won (the bound one errored), persist it as the
449
+ // account's device — but only when its token is actually free, so the
450
+ // one-device-per-account invariant holds.
451
+ if (store && probe.identity !== null && probe.identity.machineToken !== bound?.machineToken) {
452
+ const occupied = await store.occupied(accountId);
453
+ if (!occupied.has(probe.identity.machineToken)) {
454
+ try {
455
+ await store.bind(providerId, accountId, {
456
+ machineToken: probe.identity.machineToken,
457
+ machineType: probe.identity.machineType,
458
+ machineCode: probe.identity.machineCode,
459
+ });
460
+ } catch (error) {
461
+ log.warn(`[qoder] could not bind native identity for account=${accountId}`, error as Error);
462
+ }
294
463
  }
295
- // not_eligible (same-person block): stop trying further campaigns.
464
+ }
465
+
466
+ if (probed.state === "claimed") {
467
+ return { ...baseResult, state: "claimed", credit: probed.amount, streakDays: null };
468
+ }
469
+ if (probed.state === "already_claimed") {
470
+ return { ...baseResult, state: "already_claimed", streakDays: null };
471
+ }
472
+ if (probed.state === "not_eligible") {
296
473
  return { ...baseResult, state: "not_eligible", error: "Same-person claim already used today." };
297
474
  }
298
- return {
299
- ...baseResult,
300
- state: alreadySeen ? "already_claimed" : "unavailable",
301
- error: alreadySeen ? null : "No daily CREDITS campaign claimable.",
302
- };
475
+ return { ...baseResult, state: probed.state, error: probed.error };
303
476
  }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Persistence for the Qoder native machine identity (UMID) bound to an
3
+ * account.
4
+ *
5
+ * The daily 100-credit check-in grant is device-scoped upstream, and the
6
+ * server accepts only genuine, non-VM device identities. A device identity is
7
+ * a deterministic function of a seed file (`~/.config/.locale_cfg`), so each
8
+ * account gets its own minted seed — its own "device". The bound record lives
9
+ * in `provider_accounts.native_identity` (plain JSON, not a secret).
10
+ *
11
+ * Uniqueness of the device is enforced by the partial unique index on the
12
+ * extracted `machineToken`; this store keeps the JS side of that invariant
13
+ * and surfaces the DB constraint as the backstop.
14
+ */
15
+ import { and, eq, isNotNull, ne } from "drizzle-orm";
16
+ import type { CartethyiaDatabase } from "../../../persistence/db-handle";
17
+ import { providerAccounts, type NativeIdentityRecord } from "../../../persistence/schema";
18
+ import {
19
+ mintQoderNativeIdentity,
20
+ replayQoderNativeIdentity,
21
+ type QoderNativeIdentity,
22
+ } from "./qoder-umid";
23
+
24
+ /** Stateful read/write boundary for bound native identities. */
25
+ export interface QoderNativeIdentityStore {
26
+ /**
27
+ * The identity bound to one account, or `undefined` when the account has
28
+ * no identity yet.
29
+ */
30
+ get(providerId: string, accountId: string): Promise<NativeIdentityRecord | undefined>;
31
+ /**
32
+ * Machine tokens already bound to **other** accounts, so a caller can mint
33
+ * a fresh device without colliding. A bound token is a physical device, and
34
+ * the unique index that backs this spans all `provider_accounts` rows. The
35
+ * account's own bound token is not "occupied" — reusing one's own device is
36
+ * the point.
37
+ */
38
+ occupied(accountId: string): Promise<ReadonlySet<string>>;
39
+ /** Binds an identity to an account (upsert). Throws on a device collision. */
40
+ bind(providerId: string, accountId: string, record: NativeIdentityRecord): Promise<void>;
41
+ }
42
+
43
+ export class DrizzleQoderNativeIdentityStore implements QoderNativeIdentityStore {
44
+ constructor(private readonly db: CartethyiaDatabase) {}
45
+
46
+ async get(providerId: string, accountId: string): Promise<NativeIdentityRecord | undefined> {
47
+ const rows = await this.db
48
+ .select({ nativeIdentity: providerAccounts.nativeIdentity })
49
+ .from(providerAccounts)
50
+ .where(and(eq(providerAccounts.id, accountId), eq(providerAccounts.providerId, providerId)))
51
+ .limit(1);
52
+ const row = rows[0];
53
+ return row?.nativeIdentity ?? undefined;
54
+ }
55
+
56
+ async occupied(accountId: string): Promise<ReadonlySet<string>> {
57
+ const rows = await this.db
58
+ .select({ nativeIdentity: providerAccounts.nativeIdentity })
59
+ .from(providerAccounts)
60
+ .where(
61
+ accountId
62
+ ? and(isNotNull(providerAccounts.nativeIdentity), ne(providerAccounts.id, accountId))
63
+ : isNotNull(providerAccounts.nativeIdentity),
64
+ );
65
+ const occupied = new Set<string>();
66
+ for (const row of rows) {
67
+ const token = row.nativeIdentity?.machineToken;
68
+ if (token !== undefined && token !== "") occupied.add(token);
69
+ }
70
+ return occupied;
71
+ }
72
+
73
+ async bind(providerId: string, accountId: string, record: NativeIdentityRecord): Promise<void> {
74
+ await this.db
75
+ .update(providerAccounts)
76
+ .set({ nativeIdentity: record })
77
+ .where(and(eq(providerAccounts.id, accountId), eq(providerAccounts.providerId, providerId)));
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Serializes identity minting across accounts in this process, so two
83
+ * concurrent allocations cannot interleave seed swaps.
84
+ */
85
+ let allocationQueue: Promise<unknown> = Promise.resolve();
86
+ function withAllocationLock<T>(work: () => Promise<T>): Promise<T> {
87
+ const next = allocationQueue.then(work, work);
88
+ allocationQueue = next.catch(() => undefined);
89
+ return next;
90
+ }
91
+
92
+ /** Identity minting injected for tests; defaults to the real bridge. */
93
+ export type NativeIdentityMinter = (accountId: string) => { identity: QoderNativeIdentity; seed: string } | null;
94
+ export type NativeIdentityReplayer = (seed: string, accountId: string) => QoderNativeIdentity | null;
95
+
96
+ const defaultMinter: NativeIdentityMinter = (accountId) => mintQoderNativeIdentity(accountId);
97
+ const defaultReplayer: NativeIdentityReplayer = (seed, accountId) => replayQoderNativeIdentity(seed, accountId);
98
+
99
+ /**
100
+ * Mints and binds a fresh device identity for an account: runs the bridge
101
+ * with a cleared seed, captures the new seed, and persists both. Returns the
102
+ * bound record, or `null` when the bridge fails.
103
+ *
104
+ * `avoidToken` optionally excludes the account's own current token so a
105
+ * regenerate moves to a *different* device.
106
+ */
107
+ export function allocateQoderNativeIdentity(
108
+ store: QoderNativeIdentityStore,
109
+ providerId: string,
110
+ accountId: string,
111
+ options: {
112
+ readonly avoidToken?: string;
113
+ readonly mint?: NativeIdentityMinter;
114
+ readonly replayer?: NativeIdentityReplayer;
115
+ } = {},
116
+ ): Promise<NativeIdentityRecord | null> {
117
+ const mint = options.mint ?? defaultMinter;
118
+ return withAllocationLock(async () => {
119
+ const occupied = await store.occupied(accountId);
120
+ for (let i = 0; i < 6; i += 1) {
121
+ const minted = mint(accountId);
122
+ if (!minted) return null;
123
+ const token = minted.identity.machineToken;
124
+ if (occupied.has(token)) continue; // collision (rare) → mint again
125
+ if (token === options.avoidToken) continue;
126
+ const record: NativeIdentityRecord = {
127
+ machineToken: token,
128
+ machineType: minted.identity.machineType,
129
+ machineCode: minted.identity.machineCode,
130
+ ...(minted.seed ? { seed: minted.seed } : {}),
131
+ source: minted.identity.source,
132
+ };
133
+ await store.bind(providerId, accountId, record);
134
+ return record;
135
+ }
136
+ return null;
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Ensures the bound identity is currently active on this host: replays the
142
+ * stored seed so the bridge yields the same token. Returns `false` when the
143
+ * account has no bound identity or replay failed.
144
+ */
145
+ export async function ensureQoderNativeIdentity(
146
+ store: QoderNativeIdentityStore,
147
+ providerId: string,
148
+ accountId: string,
149
+ replayer: NativeIdentityReplayer = defaultReplayer,
150
+ ): Promise<boolean> {
151
+ const record = await store.get(providerId, accountId);
152
+ if (!record) return false;
153
+ if (!record.seed) return true; // no seed captured → assume already active
154
+ const identity = replayer(record.seed, accountId);
155
+ return identity !== null && identity.machineToken === record.machineToken;
156
+ }
@@ -335,10 +335,19 @@ export class QoderOAuthClient extends OAuthDeviceFlow {
335
335
  const expireTime = parseQoderExpiry(body?.expires_at, body?.expires_in);
336
336
  const info = await this.fetchUserInfo(accessToken);
337
337
  const label = info.name || info.email || undefined;
338
- // The token response does not always name the user id; the profile
339
- // endpoint does, so it is the fallback before giving up on the identity
340
- // dispatch needs for COSY signing.
338
+ // The poll body carries user_id; the profile endpoint is only a fallback.
339
+ // Dispatch needs the id for COSY signing, and an empty one would store an
340
+ // account that 401s on every request ("missing its user identity") — so a
341
+ // login that cannot resolve the id fails loudly here instead, while the
342
+ // user is still in front of the approval screen and can just retry.
341
343
  const resolvedUserId = nonEmptyTrimmedString(body?.user_id) ?? info.id;
344
+ if (resolvedUserId === "") {
345
+ return {
346
+ status: "failed",
347
+ reason:
348
+ "Qoder login could not resolve the user id (poll had no user_id and the profile lookup returned none); try again",
349
+ };
350
+ }
342
351
  const result: OAuthExchangeResult = {
343
352
  access: accessToken,
344
353
  // No refresh grant exists, so the upstream refresh token (when present)