@opengeni/api-router 3.0.1 → 3.1.2

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 (546) hide show
  1. package/dist/access-grant-rls.d.ts +8 -1
  2. package/dist/admission-trace.d.ts +8 -0
  3. package/dist/app.d.ts +50 -3
  4. package/dist/app.js +10 -1
  5. package/dist/artifact-catalog-cursor.d.ts +1 -0
  6. package/dist/attached-browser-viewer.d.ts +11 -0
  7. package/dist/auth/managed-auth-attempt-context.d.ts +2 -0
  8. package/dist/auth/managed-auth-rate-limits.d.ts +167 -0
  9. package/dist/auth/managed-auth-session-adapter.d.ts +9 -1
  10. package/dist/auth/managed-auth.d.ts +72 -5
  11. package/dist/auth/managed-sign-in-notifications.d.ts +4 -0
  12. package/dist/auth/new-signups-gate.d.ts +23 -0
  13. package/dist/auth/signup-funnel-metrics.d.ts +44 -0
  14. package/dist/azure-live.d.ts +28 -0
  15. package/dist/browser-action-fences.d.ts +13 -0
  16. package/dist/browser-controller-authority.d.ts +1 -61
  17. package/dist/browser-ephemeral.d.ts +5 -0
  18. package/dist/browser-lost-runtime-end.d.ts +24 -0
  19. package/dist/browser-state-authority.d.ts +1 -27
  20. package/dist/chunk-OGQDXVXY.js +18 -0
  21. package/dist/chunk-OGQDXVXY.js.map +1 -0
  22. package/dist/{chunk-AJ62T5QN.js → chunk-OT56OY4V.js} +80503 -39869
  23. package/dist/chunk-OT56OY4V.js.map +1 -0
  24. package/dist/claude-default-models.d.ts +19 -0
  25. package/dist/claude-subscription-core.d.ts +50 -0
  26. package/dist/claude-subscription-oauth.d.ts +36 -0
  27. package/dist/claude-subscription-profile.d.ts +9 -0
  28. package/dist/claude-subscription-usage.d.ts +6 -0
  29. package/dist/claude-workspace-connection.d.ts +15 -0
  30. package/dist/codemode.d.ts +40 -5
  31. package/dist/codex-realtime.d.ts +29 -3
  32. package/dist/codex-session-pointers.d.ts +19 -0
  33. package/dist/connection-ownership.d.ts +6 -3
  34. package/dist/controller-data-plane.d.ts +16 -0
  35. package/dist/credit-promotion-snapshot.d.ts +6 -0
  36. package/dist/editable-artifact-source-session.d.ts +3 -0
  37. package/dist/editable-artifact-websocket.d.ts +5 -3
  38. package/dist/fatal-process-boundary.d.ts +9 -1
  39. package/dist/github-access.d.ts +7 -1
  40. package/dist/github-browser-flow.d.ts +1 -1
  41. package/dist/http/acting-person.d.ts +36 -0
  42. package/dist/http/api-contract-compat.d.ts +19 -0
  43. package/dist/http/api-error.d.ts +50 -1
  44. package/dist/http/auth.d.ts +8 -1
  45. package/dist/http/cors.d.ts +12 -0
  46. package/dist/http/deprecation.d.ts +42 -0
  47. package/dist/http/keyed-admission.d.ts +12 -0
  48. package/dist/http/local-browser-boundary.d.ts +72 -0
  49. package/dist/http/metrics-listener.d.ts +22 -0
  50. package/dist/http/path-identifier.d.ts +17 -0
  51. package/dist/http/registered-route-label.d.ts +16 -0
  52. package/dist/http/rejection-telemetry.d.ts +34 -0
  53. package/dist/http/request-body.d.ts +27 -0
  54. package/dist/http/request-source.d.ts +41 -3
  55. package/dist/http/sse.d.ts +12 -1
  56. package/dist/http/unmatched-route.d.ts +18 -0
  57. package/dist/http/user-content.d.ts +36 -0
  58. package/dist/http/variable-sets-removed.d.ts +41 -0
  59. package/dist/http/websocket-upgrade-dispatch.d.ts +18 -0
  60. package/dist/index.js +1279 -108
  61. package/dist/index.js.map +1 -1
  62. package/dist/integration-connect-metrics.d.ts +35 -0
  63. package/dist/integration-endpoint-test.d.ts +20 -0
  64. package/dist/integrations/api-integrations.d.ts +2 -2
  65. package/dist/integrations/atlassian.d.ts +8 -3
  66. package/dist/integrations/fiken.d.ts +3 -1
  67. package/dist/integrations/github-lens-connect.d.ts +37 -66
  68. package/dist/integrations/google-drive.d.ts +2 -0
  69. package/dist/integrations/integration-commit-authority.d.ts +13 -0
  70. package/dist/integrations/marketplace-plugin.d.ts +43 -43
  71. package/dist/integrations/oauth-client.d.ts +171 -12
  72. package/dist/integrations/oauth-profiles.d.ts +42 -13
  73. package/dist/integrations/onepassword-vault-provider.d.ts +9 -0
  74. package/dist/integrations/slack-bot.d.ts +285 -7
  75. package/dist/integrations/slack-file-upload-flow.d.ts +62 -0
  76. package/dist/integrations/slack-interactions.d.ts +89 -10
  77. package/dist/integrations/slack-mrkdwn.d.ts +41 -0
  78. package/dist/integrations/slack-routing.d.ts +1 -1
  79. package/dist/integrations/slack-session-defaults.d.ts +58 -0
  80. package/dist/integrations/slack-task-file-upload.d.ts +36 -0
  81. package/dist/integrations/slack-tool-review.d.ts +5 -0
  82. package/dist/interaction-agent-access.d.ts +13 -8
  83. package/dist/interaction-agent-version.d.ts +21 -0
  84. package/dist/interaction-holder-heartbeat.d.ts +5 -0
  85. package/dist/interaction-metrics.d.ts +27 -2
  86. package/dist/interaction-route-stages.d.ts +25 -0
  87. package/dist/interaction-secrets-vault.d.ts +20 -0
  88. package/dist/interaction-secrets.d.ts +103 -0
  89. package/dist/mcp/action-catalog-types.d.ts +14 -0
  90. package/dist/mcp/action-catalog.gen.d.ts +2 -0
  91. package/dist/mcp/action-descriptions.d.ts +4 -0
  92. package/dist/mcp/action-schema-imports.gen.d.ts +1 -0
  93. package/dist/mcp/capability-account-readiness.d.ts +7 -0
  94. package/dist/mcp/child-read-evidence.d.ts +9 -0
  95. package/dist/mcp/company-profile-agent-admin.d.ts +12 -0
  96. package/dist/mcp/contract-input.d.ts +13 -0
  97. package/dist/mcp/documents.d.ts +2 -0
  98. package/dist/mcp/first-party-tool-permissions.d.ts +3 -606
  99. package/dist/mcp/notification-tools.d.ts +26 -0
  100. package/dist/mcp/orchestration-failure-diagnostic.d.ts +43 -0
  101. package/dist/mcp/scheduled-task-input.d.ts +1309 -0
  102. package/dist/mcp/scheduled-task-view.d.ts +217 -3
  103. package/dist/mcp/secret-tools.d.ts +14 -0
  104. package/dist/mcp/server.d.ts +108 -5
  105. package/dist/mcp/session-admin-access-tools.d.ts +33 -0
  106. package/dist/mcp/session-event-errors.d.ts +28 -0
  107. package/dist/mcp/session-event-view.d.ts +62 -0
  108. package/dist/mcp/session-view.d.ts +15 -0
  109. package/dist/mcp/session-wait.d.ts +18 -0
  110. package/dist/mcp/site-agent-clamp-tools.d.ts +38 -0
  111. package/dist/mcp/site-data-tools.d.ts +22 -0
  112. package/dist/mcp/site-publication-tools.d.ts +19 -0
  113. package/dist/mcp/site-sql-tools.d.ts +27 -0
  114. package/dist/mcp-oauth.d.ts +37 -4
  115. package/dist/model-calls/chat-completions.d.ts +87 -0
  116. package/dist/model-calls/pending-usage-settlements.d.ts +57 -0
  117. package/dist/model-calls/service.d.ts +36 -0
  118. package/dist/model-catalog.d.ts +9 -3
  119. package/dist/native-push-dispatch.d.ts +69 -0
  120. package/dist/observability.d.ts +3 -1
  121. package/dist/organization-mcp.d.ts +82 -0
  122. package/dist/prepared-mcp-actor.d.ts +77 -0
  123. package/dist/prepared-mcp-permissions.d.ts +8 -0
  124. package/dist/routes/analytics-consent.d.ts +26 -0
  125. package/dist/routes/api-integrations.d.ts +11 -0
  126. package/dist/routes/api-keys.d.ts +7 -7
  127. package/dist/routes/automations.d.ts +2 -0
  128. package/dist/routes/billing.d.ts +59 -0
  129. package/dist/routes/browser-sessions.d.ts +65 -2
  130. package/dist/routes/chat-completions.d.ts +11 -0
  131. package/dist/routes/claude-core-usage.d.ts +20 -0
  132. package/dist/routes/claude-subscription-accounts.d.ts +3 -0
  133. package/dist/routes/claude-subscription-oauth.d.ts +3 -0
  134. package/dist/routes/client-errors.d.ts +182 -0
  135. package/dist/routes/codex-core.d.ts +401 -0
  136. package/dist/routes/codex.d.ts +64 -2
  137. package/dist/routes/computer-sessions.d.ts +6 -1
  138. package/dist/routes/desktop-distribution.d.ts +32 -0
  139. package/dist/routes/github-browser-fonts.gen.d.ts +2 -0
  140. package/dist/routes/github-browser-pages.d.ts +27 -0
  141. package/dist/routes/inbox.d.ts +13 -0
  142. package/dist/routes/insights-response-cache.d.ts +10 -0
  143. package/dist/routes/insights-usage.d.ts +12 -0
  144. package/dist/routes/insights.d.ts +28 -0
  145. package/dist/routes/interaction-placement-message.d.ts +7 -0
  146. package/dist/routes/managed-sign-in-methods.d.ts +6 -0
  147. package/dist/routes/model-account-choice.d.ts +13 -0
  148. package/dist/routes/model-account-settings.d.ts +19 -0
  149. package/dist/routes/native-app-auth.d.ts +12 -0
  150. package/dist/routes/organization-integration-policy.d.ts +8 -0
  151. package/dist/routes/organization-integrations.d.ts +6 -0
  152. package/dist/routes/organization-mcp-connections.d.ts +3 -0
  153. package/dist/routes/organization-model-defaults.d.ts +9 -0
  154. package/dist/routes/organization-service-accounts.d.ts +3 -0
  155. package/dist/routes/personal-api-keys.d.ts +3 -0
  156. package/dist/routes/session-admin-access.d.ts +3 -0
  157. package/dist/routes/session-artifact-associations.d.ts +4 -0
  158. package/dist/routes/session-history-imports.d.ts +20 -0
  159. package/dist/routes/sessions.d.ts +38 -2
  160. package/dist/routes/site-data.d.ts +18 -0
  161. package/dist/routes/site-publishing.d.ts +5 -0
  162. package/dist/routes/site-runtime.d.ts +31 -0
  163. package/dist/routes/site-sql.d.ts +16 -0
  164. package/dist/routes/slack-bot-access.d.ts +3 -0
  165. package/dist/routes/subscription-account-pools.d.ts +21 -0
  166. package/dist/routes/subscription-core-http.d.ts +5 -0
  167. package/dist/routes/subscription-pool-access.d.ts +37 -0
  168. package/dist/routes/subscription-pool-core.d.ts +153 -0
  169. package/dist/routes/supergrok.d.ts +6 -6
  170. package/dist/routes/usage-allowances.d.ts +12 -0
  171. package/dist/routes/variable-sets-removed.d.ts +2 -0
  172. package/dist/routes/vault-providers.d.ts +34 -0
  173. package/dist/routes/vaults.d.ts +3 -0
  174. package/dist/routes/workspace-integrations.d.ts +35 -0
  175. package/dist/routes/workspace-model-providers.d.ts +4 -0
  176. package/dist/routes/workspaces.d.ts +11 -2
  177. package/dist/sandbox/agent-update.d.ts +64 -0
  178. package/dist/sandbox/auth-callout.d.ts +8 -0
  179. package/dist/sandbox/channel-a.d.ts +15 -1
  180. package/dist/sandbox/enrollment.d.ts +26 -4
  181. package/dist/sandbox/machine-audience.d.ts +29 -0
  182. package/dist/sandbox/machines.d.ts +15 -2
  183. package/dist/sandbox/metrics-ingestion.d.ts +6 -2
  184. package/dist/sandbox/screen-control.d.ts +85 -0
  185. package/dist/sandbox/viewer.d.ts +5 -1
  186. package/dist/sandbox-file-artifacts.d.ts +17 -1
  187. package/dist/scheduled-task-blockers.d.ts +39 -0
  188. package/dist/scheduled-task-deletion.d.ts +3 -4
  189. package/dist/scheduled-task-lifecycle.d.ts +14 -0
  190. package/dist/secret-answers.d.ts +40 -0
  191. package/dist/session-create-observability.d.ts +5 -0
  192. package/dist/session-event-view-36GO3IL7.js +528 -0
  193. package/dist/session-event-view-36GO3IL7.js.map +1 -0
  194. package/dist/signed-state-secret.d.ts +18 -0
  195. package/dist/site-agent-guard.d.ts +53 -0
  196. package/dist/site-data/operations.d.ts +103 -0
  197. package/dist/site-data/service.d.ts +203 -0
  198. package/dist/site-frame-guard.d.ts +32 -0
  199. package/dist/site-publishing/agent-data-tools.d.ts +84 -0
  200. package/dist/site-publishing/agent-model.d.ts +58 -0
  201. package/dist/site-publishing/agent-session.d.ts +16 -0
  202. package/dist/site-publishing/content-scanner.d.ts +31 -0
  203. package/dist/site-publishing/edge-sync.d.ts +48 -0
  204. package/dist/site-publishing/gateway-signature.d.ts +56 -0
  205. package/dist/site-publishing/limits.d.ts +63 -0
  206. package/dist/site-publishing/page-tool-authority.d.ts +30 -0
  207. package/dist/site-publishing/platform-usage.d.ts +113 -0
  208. package/dist/site-publishing/platform.d.ts +21 -0
  209. package/dist/site-publishing/readiness.d.ts +62 -0
  210. package/dist/site-publishing/runtime.d.ts +179 -0
  211. package/dist/site-publishing/service.d.ts +264 -0
  212. package/dist/site-publishing/session-events.d.ts +22 -0
  213. package/dist/site-publishing/session-views.d.ts +36 -0
  214. package/dist/site-publishing/viewer.d.ts +40 -0
  215. package/dist/site-session-origin.d.ts +31 -6
  216. package/dist/site-sql/format.d.ts +47 -0
  217. package/dist/site-sql/gateway.d.ts +50 -0
  218. package/dist/site-sql/service.d.ts +213 -0
  219. package/dist/temporal-schedule-sync.d.ts +26 -0
  220. package/dist/transcription/billing-refusal.d.ts +8 -0
  221. package/dist/transcription/normalize.d.ts +23 -0
  222. package/dist/transcription/providers/azure-mai.d.ts +12 -0
  223. package/dist/transcription/providers/azure-openai.d.ts +4 -0
  224. package/dist/transcription/providers/openai.d.ts +16 -0
  225. package/dist/transcription/providers/subscription-core.d.ts +39 -0
  226. package/dist/transcription/service.d.ts +10 -1
  227. package/dist/transcription/wav-duration.d.ts +8 -0
  228. package/dist/workspace-tool-gateway-attestations.d.ts +37 -0
  229. package/dist/workspace-tool-gateway-observability.d.ts +16 -0
  230. package/dist/workspace-tool-gateway-secrets.d.ts +27 -0
  231. package/dist/workspace-tool-gateway.d.ts +102 -6
  232. package/dist/workspace-tool-target-fence.d.ts +116 -0
  233. package/dist/workspace-tool-target.d.ts +225 -0
  234. package/dist/workspace-webhook-dispatch.d.ts +26 -0
  235. package/dist/xai-subscription-core.d.ts +60 -0
  236. package/package.json +25 -20
  237. package/src/access-grant-rls.ts +64 -14
  238. package/src/admission-trace.ts +14 -0
  239. package/src/app.ts +1684 -252
  240. package/src/artifact-catalog-cursor.ts +1 -0
  241. package/src/attached-browser-viewer.ts +26 -0
  242. package/src/auth/managed-auth-attempt-context.ts +9 -0
  243. package/src/auth/managed-auth-rate-limits.ts +252 -0
  244. package/src/auth/managed-auth-session-adapter.ts +64 -29
  245. package/src/auth/managed-auth.ts +303 -35
  246. package/src/auth/managed-email.ts +18 -1
  247. package/src/auth/managed-sign-in-notifications.ts +81 -0
  248. package/src/auth/new-signups-gate.ts +49 -0
  249. package/src/auth/organization-user-setup.ts +3 -3
  250. package/src/auth/signup-funnel-metrics.ts +164 -0
  251. package/src/azure-live.ts +103 -0
  252. package/src/browser-action-fences.ts +45 -0
  253. package/src/browser-controller-authority.ts +1 -186
  254. package/src/browser-ephemeral.ts +39 -0
  255. package/src/browser-lost-runtime-end.ts +71 -0
  256. package/src/browser-state-authority.ts +1 -236
  257. package/src/claude-default-models.ts +37 -0
  258. package/src/claude-subscription-core.ts +154 -0
  259. package/src/claude-subscription-oauth.ts +352 -0
  260. package/src/claude-subscription-profile.ts +73 -0
  261. package/src/claude-subscription-usage.ts +51 -0
  262. package/src/claude-workspace-connection.ts +82 -0
  263. package/src/codemode.ts +236 -39
  264. package/src/codex-realtime.ts +181 -53
  265. package/src/codex-session-pointers.ts +80 -0
  266. package/src/company-brain-okf.ts +1 -1
  267. package/src/connected-machine-computer-access.ts +2 -1
  268. package/src/connection-ownership.ts +19 -13
  269. package/src/controller-data-plane.ts +34 -0
  270. package/src/credit-promotion-snapshot.ts +36 -0
  271. package/src/editable-artifact-production.ts +10 -0
  272. package/src/editable-artifact-source-session.ts +123 -0
  273. package/src/editable-artifact-websocket.ts +16 -3
  274. package/src/fatal-process-boundary.ts +67 -3
  275. package/src/gateway-realtime.ts +12 -8
  276. package/src/github-access.ts +31 -5
  277. package/src/github-browser-flow.ts +1 -1
  278. package/src/http/acting-person.ts +105 -0
  279. package/src/http/api-contract-compat.ts +49 -0
  280. package/src/http/api-error.ts +210 -2
  281. package/src/http/auth.ts +68 -4
  282. package/src/http/cors.ts +37 -5
  283. package/src/http/deprecation.ts +118 -0
  284. package/src/http/keyed-admission.ts +29 -0
  285. package/src/http/local-browser-boundary.ts +343 -0
  286. package/src/http/metrics-listener.ts +57 -0
  287. package/src/http/path-identifier.ts +54 -0
  288. package/src/http/registered-route-label.ts +34 -0
  289. package/src/http/rejection-telemetry.ts +120 -0
  290. package/src/http/request-body.ts +127 -0
  291. package/src/http/request-source.ts +154 -13
  292. package/src/http/sse.ts +88 -28
  293. package/src/http/unmatched-route.ts +60 -0
  294. package/src/http/user-content.ts +158 -0
  295. package/src/http/variable-sets-removed.ts +122 -0
  296. package/src/http/websocket-upgrade-dispatch.ts +29 -0
  297. package/src/index.ts +107 -57
  298. package/src/integration-connect-metrics.ts +228 -0
  299. package/src/integration-endpoint-test.ts +291 -0
  300. package/src/integrations/api-integrations.ts +81 -7
  301. package/src/integrations/atlassian.ts +202 -83
  302. package/src/integrations/fiken.ts +66 -38
  303. package/src/integrations/github-app-connect.ts +64 -18
  304. package/src/integrations/github-lens-connect.ts +8 -29
  305. package/src/integrations/google-drive.ts +448 -152
  306. package/src/integrations/integration-commit-authority.ts +69 -0
  307. package/src/integrations/marketplace-plugin.ts +1 -1
  308. package/src/integrations/oauth-client.ts +733 -193
  309. package/src/integrations/oauth-profiles.ts +116 -45
  310. package/src/integrations/onepassword-vault-provider.ts +16 -0
  311. package/src/integrations/personal-github-repositories.ts +1 -1
  312. package/src/integrations/personal-github.ts +61 -35
  313. package/src/integrations/provider-oauth.ts +67 -85
  314. package/src/integrations/slack-app-home.ts +5 -5
  315. package/src/integrations/slack-bot.ts +893 -201
  316. package/src/integrations/slack-file-upload-flow.ts +147 -0
  317. package/src/integrations/slack-install.ts +7 -7
  318. package/src/integrations/slack-interactions.ts +1589 -299
  319. package/src/integrations/slack-mrkdwn.ts +193 -0
  320. package/src/integrations/slack-routing.ts +2 -2
  321. package/src/integrations/slack-session-defaults.ts +224 -0
  322. package/src/integrations/slack-task-file-upload.ts +323 -0
  323. package/src/integrations/slack-tool-review.ts +65 -0
  324. package/src/integrations/social-oauth.ts +90 -58
  325. package/src/interaction-agent-access.ts +40 -20
  326. package/src/interaction-agent-version.ts +85 -0
  327. package/src/interaction-holder-heartbeat.ts +24 -8
  328. package/src/interaction-metrics.ts +164 -2
  329. package/src/interaction-route-stages.ts +94 -0
  330. package/src/interaction-secrets-vault.ts +213 -0
  331. package/src/interaction-secrets.ts +426 -0
  332. package/src/mcp/action-catalog-types.ts +14 -0
  333. package/src/mcp/action-catalog.gen.ts +8014 -0
  334. package/src/mcp/action-descriptions.ts +299 -0
  335. package/src/mcp/action-schema-imports.gen.ts +304 -0
  336. package/src/mcp/capability-account-readiness.ts +17 -0
  337. package/src/mcp/child-read-evidence.ts +19 -0
  338. package/src/mcp/company-profile-agent-admin.ts +13 -0
  339. package/src/mcp/contract-input.ts +165 -0
  340. package/src/mcp/documents.ts +9 -2
  341. package/src/mcp/editable-artifacts.ts +29 -11
  342. package/src/mcp/files.ts +5 -1
  343. package/src/mcp/first-party-tool-permissions.ts +7 -285
  344. package/src/mcp/knowledge-entries.ts +108 -34
  345. package/src/mcp/notification-tools.ts +480 -0
  346. package/src/mcp/orchestration-failure-diagnostic.ts +43 -0
  347. package/src/mcp/scheduled-task-input.ts +89 -0
  348. package/src/mcp/scheduled-task-view.ts +63 -3
  349. package/src/mcp/secret-tools.ts +185 -0
  350. package/src/mcp/server.ts +2213 -1116
  351. package/src/mcp/session-admin-access-tools.ts +163 -0
  352. package/src/mcp/session-event-errors.ts +123 -0
  353. package/src/mcp/session-event-view.ts +395 -13
  354. package/src/mcp/session-view.ts +3 -0
  355. package/src/mcp/session-wait.ts +67 -7
  356. package/src/mcp/site-agent-clamp-tools.ts +140 -0
  357. package/src/mcp/site-data-tools.ts +339 -0
  358. package/src/mcp/site-publication-tools.ts +201 -0
  359. package/src/mcp/site-sql-tools.ts +427 -0
  360. package/src/mcp-oauth.ts +635 -45
  361. package/src/model-calls/chat-completions.ts +352 -0
  362. package/src/model-calls/pending-usage-settlements.ts +125 -0
  363. package/src/model-calls/service.ts +694 -0
  364. package/src/model-catalog.ts +68 -17
  365. package/src/native-push-dispatch.ts +457 -0
  366. package/src/observability.ts +15 -1
  367. package/src/organization-mcp.ts +485 -0
  368. package/src/prepared-mcp-actor.ts +154 -0
  369. package/src/prepared-mcp-permissions.ts +38 -0
  370. package/src/routes/analytics-consent.ts +123 -0
  371. package/src/routes/api-integrations.ts +268 -9
  372. package/src/routes/api-keys.ts +164 -25
  373. package/src/routes/artifact-catalog.ts +111 -0
  374. package/src/routes/automations.ts +163 -90
  375. package/src/routes/billing.ts +535 -59
  376. package/src/routes/browser-sessions.ts +966 -342
  377. package/src/routes/canonical-human-identities.ts +61 -0
  378. package/src/routes/capabilities.ts +112 -5
  379. package/src/routes/channels.ts +4 -3
  380. package/src/routes/chat-completions.ts +235 -0
  381. package/src/routes/claude-core-usage.ts +147 -0
  382. package/src/routes/claude-subscription-accounts.ts +233 -0
  383. package/src/routes/claude-subscription-oauth.ts +175 -0
  384. package/src/routes/client-errors.ts +394 -0
  385. package/src/routes/codex-core.ts +687 -0
  386. package/src/routes/codex.ts +1277 -1119
  387. package/src/routes/company-brain.ts +4 -0
  388. package/src/routes/computer-sessions.ts +319 -85
  389. package/src/routes/connect.ts +277 -175
  390. package/src/routes/connections.ts +690 -363
  391. package/src/routes/desktop-distribution.ts +217 -0
  392. package/src/routes/documents.ts +13 -7
  393. package/src/routes/editable-artifacts.ts +74 -4
  394. package/src/routes/enrollments.ts +110 -16
  395. package/src/routes/external-identity-links.ts +13 -12
  396. package/src/routes/files.ts +41 -16
  397. package/src/routes/github-browser-fonts.gen.ts +12 -0
  398. package/src/routes/github-browser-pages.ts +258 -0
  399. package/src/routes/github.ts +200 -75
  400. package/src/routes/inbox.ts +353 -0
  401. package/src/routes/insights-response-cache.ts +83 -0
  402. package/src/routes/insights-usage.ts +345 -0
  403. package/src/routes/insights.ts +103 -8
  404. package/src/routes/install.ts +51 -10
  405. package/src/routes/integration-facets.ts +70 -28
  406. package/src/routes/interaction-placement-message.ts +12 -0
  407. package/src/routes/knowledge.ts +136 -27
  408. package/src/routes/machines.ts +189 -135
  409. package/src/routes/managed-auth-session-sets.ts +43 -10
  410. package/src/routes/managed-onboarding.ts +39 -19
  411. package/src/routes/managed-sign-in-methods.ts +433 -0
  412. package/src/routes/memory-slack-publications.ts +3 -2
  413. package/src/routes/model-account-choice.ts +194 -0
  414. package/src/routes/model-account-settings.ts +235 -0
  415. package/src/routes/model-connection-access.ts +210 -48
  416. package/src/routes/native-app-auth.ts +296 -0
  417. package/src/routes/organization-integration-policy.ts +116 -0
  418. package/src/routes/organization-integrations.ts +326 -0
  419. package/src/routes/organization-mcp-connections.ts +183 -0
  420. package/src/routes/organization-memberships.ts +205 -19
  421. package/src/routes/organization-model-defaults.ts +110 -0
  422. package/src/routes/organization-model-providers.ts +74 -3
  423. package/src/routes/organization-service-accounts.ts +128 -0
  424. package/src/routes/organization-sessions.ts +21 -5
  425. package/src/routes/personal-api-keys.ts +184 -0
  426. package/src/routes/personal-github-git-broker.ts +2 -2
  427. package/src/routes/personal-github.ts +9 -4
  428. package/src/routes/plugins.ts +46 -7
  429. package/src/routes/pr-review-github.ts +105 -118
  430. package/src/routes/pr-review.ts +88 -82
  431. package/src/routes/preference-registry.ts +18 -0
  432. package/src/routes/rigs.ts +48 -13
  433. package/src/routes/scheduled-tasks.ts +347 -27
  434. package/src/routes/session-admin-access.ts +271 -0
  435. package/src/routes/session-artifact-associations.ts +164 -0
  436. package/src/routes/session-history-imports.ts +305 -0
  437. package/src/routes/sessions.ts +1682 -363
  438. package/src/routes/site-data.ts +340 -0
  439. package/src/routes/site-publishing.ts +584 -0
  440. package/src/routes/site-runtime.ts +1944 -0
  441. package/src/routes/site-sql.ts +263 -0
  442. package/src/routes/skill-content.ts +72 -7
  443. package/src/routes/skills.ts +5 -4
  444. package/src/routes/slack-bot-access.ts +108 -0
  445. package/src/routes/social.ts +27 -15
  446. package/src/routes/subscription-account-pools.ts +209 -0
  447. package/src/routes/subscription-core-http.ts +18 -0
  448. package/src/routes/subscription-pool-access.ts +180 -0
  449. package/src/routes/subscription-pool-core.ts +552 -0
  450. package/src/routes/supergrok.ts +174 -655
  451. package/src/routes/transcription-recordings.ts +71 -4
  452. package/src/routes/transcriptions.ts +26 -2
  453. package/src/routes/usage-allowances.ts +381 -0
  454. package/src/routes/variable-sets-removed.ts +19 -0
  455. package/src/routes/vault-providers.ts +782 -0
  456. package/src/routes/vaults.ts +421 -0
  457. package/src/routes/video-generation.ts +30 -12
  458. package/src/routes/workspace-artifacts.ts +165 -53
  459. package/src/routes/workspace-integrations.ts +576 -0
  460. package/src/routes/workspace-model-providers.ts +148 -0
  461. package/src/routes/workspace-state.ts +4 -0
  462. package/src/routes/workspaces.ts +625 -117
  463. package/src/sandbox/agent-update.ts +315 -0
  464. package/src/sandbox/auth-callout.ts +104 -21
  465. package/src/sandbox/channel-a.ts +165 -55
  466. package/src/sandbox/enrollment.ts +131 -22
  467. package/src/sandbox/machine-audience.ts +192 -0
  468. package/src/sandbox/machines.ts +75 -31
  469. package/src/sandbox/metrics-ingestion.ts +133 -2
  470. package/src/sandbox/rematerialize.ts +51 -21
  471. package/src/sandbox/screen-control.ts +289 -0
  472. package/src/sandbox/viewer.ts +33 -58
  473. package/src/sandbox-file-artifacts.ts +53 -27
  474. package/src/scheduled-task-blockers.ts +293 -0
  475. package/src/scheduled-task-deletion.ts +13 -3
  476. package/src/scheduled-task-lifecycle.ts +84 -0
  477. package/src/secret-answers.ts +119 -0
  478. package/src/session-create-observability.ts +46 -0
  479. package/src/session-realtime-context.ts +26 -4
  480. package/src/signed-state-secret.ts +58 -0
  481. package/src/site-agent-guard.ts +261 -0
  482. package/src/site-data/operations.ts +777 -0
  483. package/src/site-data/service.ts +1043 -0
  484. package/src/site-frame-guard.ts +107 -0
  485. package/src/site-publishing/agent-data-tools.ts +250 -0
  486. package/src/site-publishing/agent-model.ts +127 -0
  487. package/src/site-publishing/agent-session.ts +148 -0
  488. package/src/site-publishing/content-scanner.ts +93 -0
  489. package/src/site-publishing/edge-sync.ts +148 -0
  490. package/src/site-publishing/gateway-signature.ts +198 -0
  491. package/src/site-publishing/limits.ts +315 -0
  492. package/src/site-publishing/page-tool-authority.ts +111 -0
  493. package/src/site-publishing/platform-usage.ts +479 -0
  494. package/src/site-publishing/platform.ts +98 -0
  495. package/src/site-publishing/readiness.ts +330 -0
  496. package/src/site-publishing/runtime.ts +831 -0
  497. package/src/site-publishing/service.ts +1282 -0
  498. package/src/site-publishing/session-events.ts +262 -0
  499. package/src/site-publishing/session-views.ts +262 -0
  500. package/src/site-publishing/viewer.ts +75 -0
  501. package/src/site-session-origin.ts +91 -6
  502. package/src/site-sql/format.ts +134 -0
  503. package/src/site-sql/gateway.ts +168 -0
  504. package/src/site-sql/service.ts +1636 -0
  505. package/src/site-uploads.ts +27 -5
  506. package/src/temporal-schedule-sync.ts +74 -0
  507. package/src/transcription/billing-refusal.ts +14 -0
  508. package/src/transcription/normalize.ts +77 -0
  509. package/src/transcription/providers/azure-mai.ts +98 -0
  510. package/src/transcription/providers/azure-openai.ts +16 -7
  511. package/src/transcription/providers/codex-subscription.ts +190 -67
  512. package/src/transcription/providers/openai.ts +79 -5
  513. package/src/transcription/providers/subscription-core.ts +125 -0
  514. package/src/transcription/providers/xai-subscription.ts +81 -83
  515. package/src/transcription/segmenter.ts +7 -1
  516. package/src/transcription/service.ts +239 -10
  517. package/src/transcription/wav-duration.ts +39 -0
  518. package/src/workspace-deletion.ts +25 -1
  519. package/src/workspace-tool-gateway-attestations.ts +133 -0
  520. package/src/workspace-tool-gateway-observability.ts +64 -0
  521. package/src/workspace-tool-gateway-secrets.ts +104 -0
  522. package/src/workspace-tool-gateway.ts +921 -103
  523. package/src/workspace-tool-target-fence.ts +380 -0
  524. package/src/workspace-tool-target.ts +554 -0
  525. package/src/workspace-webhook-dispatch.ts +225 -0
  526. package/src/xai-realtime.ts +85 -81
  527. package/src/xai-subscription-core.ts +172 -0
  528. package/dist/browser-auth-broker.d.ts +0 -20
  529. package/dist/chunk-AJ62T5QN.js.map +0 -1
  530. package/dist/connection-authority-owner.d.ts +0 -59
  531. package/dist/routes/connection-authorities.d.ts +0 -3
  532. package/dist/routes/environments.d.ts +0 -5
  533. package/dist/routes/host-mcp-bindings.d.ts +0 -5
  534. package/dist/routes/host-mcp-resolvers.d.ts +0 -3
  535. package/dist/routes/packs.d.ts +0 -3
  536. package/dist/session-event-view-MU3HMNV3.js +0 -264
  537. package/dist/session-event-view-MU3HMNV3.js.map +0 -1
  538. package/dist/xai-subscription-auth.d.ts +0 -23
  539. package/src/browser-auth-broker.ts +0 -155
  540. package/src/connection-authority-owner.ts +0 -61
  541. package/src/routes/connection-authorities.ts +0 -139
  542. package/src/routes/environments.ts +0 -388
  543. package/src/routes/host-mcp-bindings.ts +0 -122
  544. package/src/routes/host-mcp-resolvers.ts +0 -41
  545. package/src/routes/packs.ts +0 -784
  546. package/src/xai-subscription-auth.ts +0 -139
@@ -1,3 +1,5 @@
1
+ import type { CodexRateLimitResetCreditsAccountResult } from "@opengeni/db";
2
+ import type { CodexResetRedemptionRecovery } from "@opengeni/db";
1
3
  // Codex (ChatGPT) subscription connect / status / usage routes.
2
4
  //
3
5
  // Connect uses the device-code flow split into two stateless calls: `start`
@@ -7,20 +9,26 @@
7
9
  // is added to isAuthExempt. Secrets never leave the server: status/usage read the
8
10
  // decrypted token only to call the codex backend; the token is never returned.
9
11
 
10
- import { environmentsEncryptionKeyBytes, productLabelForModelId } from "@opengeni/config";
12
+ import {
13
+ environmentsEncryptionKeyBytes,
14
+ configuredModels,
15
+ getSettings,
16
+ productLabelForModelId,
17
+ withCodexCatalogProvider,
18
+ type Settings,
19
+ } from "@opengeni/config";
11
20
  import {
12
21
  accessTokenExpiry,
13
22
  buildCodexUsageWindowFromCache,
14
23
  CODEX_CLIENT_VERSION,
15
- CODEX_FALLBACK_MODEL_SLUGS,
16
24
  CODEX_FIVE_HOUR_WINDOW_SECONDS,
17
- CODEX_MODEL_ID_PREFIX,
18
25
  CODEX_PROVIDER_ID,
19
26
  CODEX_WEEKLY_WINDOW_SECONDS,
20
27
  CodexDeviceError,
28
+ CodexReloginRequired,
21
29
  consumeCodexRateLimitResetCredit,
22
30
  exchangeDeviceCode,
23
- fetchCodexModels,
31
+ fetchCodexRateLimitResetCredits,
24
32
  parseIdToken,
25
33
  pollDeviceCode,
26
34
  startDeviceCode,
@@ -32,49 +40,34 @@ import {
32
40
  import {
33
41
  abandonCodexResetRedemptionBeforeProvider,
34
42
  adoptCodexResetRedemptionAttempt,
35
- buildCodexTokenResolver,
43
+ buildSubscriptionCoreCodexConnectionTokenResolver,
44
+ buildSubscriptionCoreCodexOperationFetch,
45
+ completeSubscriptionCoreCodexResetRedemption,
46
+ deliverSubscriptionCoreCodexWake,
47
+ fenceSubscriptionCoreCodexResetCredit,
48
+ fetchSubscriptionCoreCodexUsage,
49
+ getSubscriptionCoreCodexWorkspaceProjection,
50
+ listSubscriptionCoreCodexResetRedemptionRecoveries,
51
+ readSubscriptionCoreCodexResetAuthority,
52
+ resolveSubscriptionCoreCodexConnectionId,
53
+ subscriptionCoreCodexResetAuthority,
54
+ subscriptionCoreCodexResetCreditFence,
55
+ SubscriptionCoreCodexOperationUnavailableError,
56
+ withSessionRlsActorContext,
36
57
  claimCodexResetRedemption,
37
- completeCodexResetRedemption,
38
- clearCodexAppsCredential,
39
- designateCodexAppsCredential,
40
- disconnectAllCodexAccounts,
41
- disconnectCodexAccount,
42
- disconnectOrganizationCodexAccount,
58
+ assertOrganizationCodexAdministrator,
59
+ buildOrganizationCodexAdministratorTokenResolver,
43
60
  encryptEnvironmentValue,
44
- ensureCodexRotationSettings,
45
- ensureOrganizationCodexRotationSettings,
46
- fetchCodexUsageForAccount,
47
- fetchCodexRateLimitResetCreditsForAccount,
61
+ findOrganizationCodexConnectionForAdministrator,
62
+ fetchOrganizationCodexUsageForAccount,
48
63
  fenceCodexResetRedemptionSend,
49
64
  getCodexResetRedemptionAttempt,
50
- getCodexCredentialStatus,
51
- getCodexAppsSettings,
52
- getCodexRotationSettings,
53
- getOrganizationCodexRotationSettings,
54
- getWorkspaceCodexSubscriptionSource,
55
- listPendingCodexCapacityWakeTargets,
56
- listCodexAccountStatuses,
57
- listOrganizationCodexAccountStatuses,
58
- listCodexResetRedemptionRecoveries,
65
+ getSubscriptionCoreOrganizationCodexProjection,
59
66
  nestedPostgresSqlState,
60
67
  releaseCodexResetRedemptionClaim,
61
- updateCodexAllocatorEligibility,
62
- loadCodexCredentialForRun,
63
- renameCodexAccount,
64
- renameOrganizationCodexAccount,
65
- setActiveCodexCredential,
66
- setActiveOrganizationCodexCredential,
67
- setInitialActiveCodexCredential,
68
- setWorkspaceCodexSubscriptionMode,
69
- setWorkspaceCodexSubscriptionModeInTransaction,
70
- updateOrganizationCodexRotationSettings,
71
- updateCodexRotationSettings,
72
- upsertOrganizationCodexSubscriptionCredential,
73
- upsertCodexSubscriptionCredential,
74
- withCodexCapacityMutation,
75
- withSessionCodexCapacityMutation,
68
+ resolveOrganizationCodexRedemptionWorkspace,
69
+ activeCodexPlanExclusions,
76
70
  type CodexAccountStatus,
77
- type CodexCapacityWakeTarget,
78
71
  } from "@opengeni/db";
79
72
 
80
73
  // The picker surfaces codex models under their own "no credits" provider group so
@@ -84,7 +77,7 @@ const CODEX_PROVIDER_LABEL = "Codex subscription · no credits";
84
77
  // The wire shape for one Codex account (metadata only; never the secret column).
85
78
  // P2: fiveHour/weekly ride along, built from the CACHED usage columns (zero
86
79
  // provider calls, zero decrypts) so the bars render instantly off this read.
87
- function codexAccountJson(
80
+ export function codexAccountJson(
88
81
  row: CodexAccountStatus,
89
82
  options: {
90
83
  appsCredentialId?: string | null;
@@ -99,6 +92,20 @@ function codexAccountJson(
99
92
  label: row.label,
100
93
  email: row.accountEmail,
101
94
  plan: row.planType,
95
+ planCheckedAt: row.planCheckedAt ?? null,
96
+ // The most recent observed plan change (for example "pro" before a move to
97
+ // "free"), kept as evidence until the plan changes again.
98
+ planChangedFrom: row.planPreviousType ?? null,
99
+ planChangedAt: row.planChangedAt ?? null,
100
+ // Models the CURRENT plan was proven not to include. Each leaves automatic
101
+ // selection until `retryAfter` (one request then re-checks it) or until a
102
+ // different plan is observed (refresh usage after an upgrade).
103
+ planExcludedModels: activeCodexPlanExclusions(row, new Date()).map((entry) => ({
104
+ model: entry.modelId,
105
+ label: productLabelForModelId(entry.modelId),
106
+ excludedAt: entry.excludedAt,
107
+ retryAfter: entry.expiresAt,
108
+ })),
102
109
  status: row.status,
103
110
  active: row.isActive,
104
111
  expiresAt: row.expiresAt,
@@ -115,6 +122,9 @@ function codexAccountJson(
115
122
  CODEX_WEEKLY_WINDOW_SECONDS,
116
123
  ),
117
124
  usageCheckedAt: row.usageCheckedAt,
125
+ extraCreditsEnabled: row.extraCreditsEnabled ?? false,
126
+ extraCreditsVersion: row.extraCreditsVersion ?? 1,
127
+ extraCreditsUpdatedAt: row.extraCreditsUpdatedAt ?? null,
118
128
  allocatorEnabled: row.allocatorEnabled,
119
129
  allocatorVersion: row.allocatorVersion,
120
130
  allocatorUpdatedAt: row.allocatorUpdatedAt,
@@ -122,6 +132,7 @@ function codexAccountJson(
122
132
  resetCreditsCheckedAt: row.resetCreditsCheckedAt,
123
133
  // P3 rotation cooldown: when set and in the future, this account is cooling-down.
124
134
  exhaustedUntil: row.exhaustedUntil,
135
+ ...(row.ownInWorkspaceIds ? { ownInWorkspaceIds: row.ownInWorkspaceIds } : {}),
125
136
  appsDesignated: options.appsCredentialId === row.id,
126
137
  canEnableApps:
127
138
  row.source === "workspace" &&
@@ -186,27 +197,36 @@ export function codexWorkerReadiness(input: {
186
197
  // The /codex/usage{,/refresh,/:id} wire wrapper: the rich normalized payload
187
198
  // carries its own `status`, surfaced at the top level for back-compat with the
188
199
  // existing CodexUsage = { status; usage } shape.
189
- function codexUsageJson(payload: CodexUsagePayload): {
200
+ export function codexUsageJson(payload: CodexUsagePayload): {
190
201
  status: CodexUsagePayload["status"];
191
202
  usage: CodexUsagePayload;
192
203
  } {
193
204
  return { status: payload.status, usage: payload };
194
205
  }
195
206
 
196
- export function codexModelsForPicker(): Array<{
207
+ export function codexModelsForPicker(
208
+ settings: Settings = getSettings(),
209
+ supportedSlugs?: readonly string[],
210
+ ): Array<{
197
211
  id: string;
198
212
  label: string;
199
213
  provider: string;
200
214
  providerLabel: string;
201
215
  api: "responses";
202
216
  }> {
203
- return CODEX_FALLBACK_MODEL_SLUGS.map((slug) => ({
204
- id: `${CODEX_MODEL_ID_PREFIX}${slug}`,
205
- label: productLabelForModelId(slug),
206
- provider: CODEX_PROVIDER_ID,
207
- providerLabel: CODEX_PROVIDER_LABEL,
208
- api: "responses" as const,
209
- }));
217
+ return configuredModels(withCodexCatalogProvider(settings))
218
+ .filter(
219
+ (model) =>
220
+ model.providerId === CODEX_PROVIDER_ID &&
221
+ (supportedSlugs === undefined || supportedSlugs.includes(model.upstreamModelId)),
222
+ )
223
+ .map((model) => ({
224
+ id: model.id,
225
+ label: model.label,
226
+ provider: CODEX_PROVIDER_ID,
227
+ providerLabel: CODEX_PROVIDER_LABEL,
228
+ api: "responses" as const,
229
+ }));
210
230
  }
211
231
  import { createSignedState, readSignedState } from "@opengeni/github";
212
232
  import {
@@ -214,11 +234,44 @@ import {
214
234
  hasPermission,
215
235
  requireAccessGrant,
216
236
  requireCanonicalLocalAccountAdministrator,
237
+ resolveCatalogSettings,
217
238
  type ApiRouteDeps,
218
239
  } from "@opengeni/core";
219
240
  import type { Context, Hono } from "hono";
241
+ import {
242
+ codexRouteDisposition,
243
+ coreCodexUsage,
244
+ coreCodexUsageRefresh,
245
+ coreCodexAccounts,
246
+ coreCodexActivate,
247
+ coreCodexAllocator,
248
+ coreCodexExtraCredits,
249
+ coreCodexClearApps,
250
+ coreCodexConnected,
251
+ coreCodexDesignateApps,
252
+ coreCodexDisconnect,
253
+ coreCodexDisconnectAll,
254
+ coreCodexRename,
255
+ coreCodexSetRotation,
256
+ coreCodexSetSource,
257
+ coreCodexSource,
258
+ coreCodexStatus,
259
+ coreOrganizationCodexAccounts,
260
+ } from "./codex-core";
220
261
  import { HTTPException } from "hono/http-exception";
262
+ import {
263
+ agentActingAsPerson,
264
+ agentActingAsPersonBeforeGrantCheck,
265
+ isAgentActingAsPerson,
266
+ organizationSettingsPermission,
267
+ requireNotAgent,
268
+ } from "../http/acting-person";
221
269
  import * as z from "zod/v4";
270
+ import {
271
+ CodexSourceRequest,
272
+ SubscriptionAccountToggleRequest,
273
+ SubscriptionRotationSettingsRequest,
274
+ } from "@opengeni/contracts";
222
275
  import {
223
276
  hashCodexBrowserSession,
224
277
  signCodexRedemptionConfirmation,
@@ -243,7 +296,16 @@ type ManagedCookieHuman = {
243
296
  browserSessionHash: string;
244
297
  };
245
298
 
246
- async function managedCookieHuman(
299
+ /**
300
+ * The stand-in browser session for an agent acting as a person: stable per
301
+ * person, so the agent can confirm a usage limit reset it prepared, and never
302
+ * equal to a real browser session's hash.
303
+ */
304
+ async function agentSessionHash(subjectId: string): Promise<string> {
305
+ return await hashCodexBrowserSession(`agent:${subjectId}`);
306
+ }
307
+
308
+ export async function managedCookieHuman(
247
309
  c: Context,
248
310
  deps: ApiRouteDeps,
249
311
  ): Promise<ManagedCookieHuman | null> {
@@ -267,14 +329,50 @@ async function managedCookieHuman(
267
329
  };
268
330
  }
269
331
 
332
+ /**
333
+ * The person in this browser, or an agent acting as them. Only for routes that
334
+ * then require the same person's workspace grant; provider sign-in steps keep
335
+ * managedCookieHuman. An agent has no browser session, so its hash never
336
+ * matches a sign-in started in one.
337
+ */
338
+ export async function managedHumanOrAgent(
339
+ c: Context,
340
+ deps: ApiRouteDeps,
341
+ ): Promise<ManagedCookieHuman | null> {
342
+ const human = await managedCookieHuman(c, deps);
343
+ if (human) return human;
344
+ const agent = agentActingAsPersonBeforeGrantCheck(c);
345
+ return agent
346
+ ? {
347
+ subjectId: agent.subjectId,
348
+ browserSessionHash: await agentSessionHash(agent.subjectId),
349
+ }
350
+ : null;
351
+ }
352
+
270
353
  export async function requireOrganizationCodexHuman(
271
354
  c: Context,
272
355
  deps: ApiRouteDeps,
273
356
  organizationId: string,
357
+ options: {
358
+ /** A provider sign-in step: the person does it in the browser, never an agent. */
359
+ providerConsent?: boolean;
360
+ } = {},
274
361
  ): Promise<ManagedCookieHuman> {
362
+ if (options.providerConsent) requireNotAgent(c, "Signing in to a provider");
275
363
  const parsed = z.string().uuid().safeParse(organizationId);
276
364
  if (!parsed.success) throw new HTTPException(422, { message: "invalid organization id" });
277
365
  let human = await managedCookieHuman(c, deps);
366
+ if (!human) {
367
+ const agent = agentActingAsPerson(c, parsed.data, organizationSettingsPermission(c));
368
+ // An agent has no browser session, so it never matches a sign-in started in one.
369
+ if (agent) {
370
+ human = {
371
+ subjectId: agent.subjectId,
372
+ browserSessionHash: await agentSessionHash(agent.subjectId),
373
+ };
374
+ }
375
+ }
278
376
  if (!human && deps.settings.productAccessMode === "local") {
279
377
  const local = await requireCanonicalLocalAccountAdministrator(c, deps, organizationId);
280
378
  human = {
@@ -288,7 +386,9 @@ export async function requireOrganizationCodexHuman(
288
386
  });
289
387
  }
290
388
  try {
291
- await getOrganizationCodexRotationSettings(deps.db, {
389
+ // The generic organization administrator check only: after 0680 the
390
+ // legacy organization Codex tables must not be read on this path.
391
+ await assertOrganizationCodexAdministrator(deps.db, {
292
392
  organizationId,
293
393
  actorSubjectId: human.subjectId,
294
394
  });
@@ -307,24 +407,10 @@ export async function requireOrganizationCodexHuman(
307
407
  return human;
308
408
  }
309
409
 
310
- async function requireWorkspaceCodexManagementSource(
311
- deps: ApiRouteDeps,
312
- workspaceId: string,
313
- ): Promise<void> {
314
- const source = await getWorkspaceCodexSubscriptionSource(deps.db, workspaceId);
315
- if (source.effectiveSource === "organization") {
316
- throw new HTTPException(409, {
317
- message: "this Codex subscription is managed in Organization settings",
318
- });
319
- }
320
- if (source.effectiveSource === "disabled") {
321
- throw new HTTPException(409, {
322
- message: "Codex is disabled for this workspace",
323
- });
324
- }
325
- }
326
-
327
410
  export function requireSameOriginBrowserMutation(c: Context, deps: ApiRouteDeps): void {
411
+ // Built in process for an agent acting as a person: no browser credentials
412
+ // ride along, so there is no cross-site request to guard against.
413
+ if (isAgentActingAsPerson(c)) return;
328
414
  const contentType = c.req.header("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
329
415
  if (contentType !== "application/json") {
330
416
  throw new HTTPException(403, {
@@ -434,17 +520,29 @@ async function requireRedemptionHuman(
434
520
  message: "reset redemption requires managed product mode",
435
521
  });
436
522
  }
437
- // Normal managed auth prefers a bearer over a cookie. This irreversible route
438
- // rejects the header before grant resolution so an API key/delegated/agent
439
- // token can never borrow a browser cookie that happens to ride along. Exact
440
- // JSON content type plus Origin and Fetch Metadata fail closed before auth.
441
- if (c.req.header("authorization")) {
442
- throw new HTTPException(403, {
443
- message: "authorization bearer is not allowed for redemption",
444
- });
523
+ // An agent the person signed in (organization MCP) acts as that person and
524
+ // may redeem, within the same exact-person grant check below. Its
525
+ // confirmation binds to a stable per-person agent hash, which can never equal
526
+ // a browser session hash.
527
+ const agent = isAgentActingAsPerson(c) ? agentActingAsPersonBeforeGrantCheck(c) : null;
528
+ if (!agent) {
529
+ // Normal managed auth prefers a bearer over a cookie. This irreversible
530
+ // route rejects the header before grant resolution so an API key or
531
+ // delegated token can never borrow a browser cookie that happens to ride
532
+ // along. Exact JSON content type plus Origin and Fetch Metadata fail closed.
533
+ if (c.req.header("authorization")) {
534
+ throw new HTTPException(403, {
535
+ message: "authorization bearer is not allowed for redemption",
536
+ });
537
+ }
538
+ requireSameOriginBrowserMutation(c, deps);
445
539
  }
446
- requireSameOriginBrowserMutation(c, deps);
447
- const human = await managedCookieHuman(c, deps);
540
+ const human: ManagedCookieHuman | null = agent
541
+ ? {
542
+ subjectId: agent.subjectId,
543
+ browserSessionHash: await agentSessionHash(agent.subjectId),
544
+ }
545
+ : await managedCookieHuman(c, deps);
448
546
  if (!human) {
449
547
  throw new HTTPException(401, {
450
548
  message: "managed browser session required",
@@ -459,7 +557,7 @@ async function requireRedemptionHuman(
459
557
  return { human, accountId: grant.accountId };
460
558
  }
461
559
 
462
- async function requireCodexAppsHuman(
560
+ export async function requireCodexAppsHuman(
463
561
  c: Context,
464
562
  deps: ApiRouteDeps,
465
563
  workspaceId: string,
@@ -470,7 +568,7 @@ async function requireCodexAppsHuman(
470
568
  });
471
569
  }
472
570
  requireSameOriginBrowserMutation(c, deps);
473
- const human = await managedCookieHuman(c, deps);
571
+ const human = await managedHumanOrAgent(c, deps);
474
572
  if (!human) {
475
573
  throw new HTTPException(401, {
476
574
  message: "managed browser session required",
@@ -591,35 +689,11 @@ type CodexRedemptionAccess = {
591
689
  canClaimUnownedViaReconnect: boolean;
592
690
  };
593
691
 
594
- function codexRedemptionAccess(input: {
595
- connectedBySubjectId: string | null;
596
- grantSubjectId: string;
597
- managedHumanSubjectId: string | null;
598
- canManage: boolean;
599
- }): CodexRedemptionAccess {
600
- if (
601
- input.managedHumanSubjectId === null ||
602
- input.managedHumanSubjectId !== input.grantSubjectId
603
- ) {
604
- return {
605
- ownership: "managed_human_unavailable",
606
- canClaimUnownedViaReconnect: false,
607
- };
608
- }
609
- if (input.connectedBySubjectId === null) {
610
- return {
611
- ownership: "unowned",
612
- canClaimUnownedViaReconnect: input.canManage,
613
- };
614
- }
615
- return {
616
- ownership:
617
- input.connectedBySubjectId === input.managedHumanSubjectId
618
- ? "current_human"
619
- : "different_human",
620
- canClaimUnownedViaReconnect: false,
621
- };
622
- }
692
+ /** Provider reads for one overview account (shared-core organizations). */
693
+ type CodexOverviewSources = {
694
+ usage(connectionId: string): Promise<CodexUsagePayload>;
695
+ details(connectionId: string): Promise<CodexRateLimitResetCreditsAccountResult>;
696
+ };
623
697
 
624
698
  async function fetchCodexAccountOverview(
625
699
  deps: ApiRouteDeps,
@@ -628,25 +702,13 @@ async function fetchCodexAccountOverview(
628
702
  redemptionAccess: CodexRedemptionAccess,
629
703
  canRedeem: boolean,
630
704
  canResumeRedemption: boolean,
631
- redemptions: Awaited<ReturnType<typeof listCodexResetRedemptionRecoveries>> = [],
632
- providerCall: CodexProviderCall = async (operation) => await operation(),
705
+ redemptions: CodexResetRedemptionRecovery[],
706
+ providerCall: CodexProviderCall,
707
+ sources: CodexOverviewSources,
633
708
  ) {
634
- const fetchImpl = (deps.codexFetch ?? fetch) as CodexFetch;
635
709
  const [usageSettled, detailsSettled] = await Promise.allSettled([
636
- providerCall(
637
- async () =>
638
- await fetchCodexUsageForAccount(deps.db, deps.settings, workspaceId, row.id, fetchImpl),
639
- ),
640
- providerCall(
641
- async () =>
642
- await fetchCodexRateLimitResetCreditsForAccount(
643
- deps.db,
644
- deps.settings,
645
- workspaceId,
646
- row.id,
647
- fetchImpl,
648
- ),
649
- ),
710
+ providerCall(() => sources.usage(row.id)),
711
+ providerCall(() => sources.details(row.id)),
650
712
  ]);
651
713
  const liveUsage = usageSettled.status === "fulfilled" ? usageSettled.value : null;
652
714
  const cached = cachedUsage(row);
@@ -756,40 +818,6 @@ type CodexConnectState = {
756
818
  iat?: number;
757
819
  };
758
820
 
759
- async function signalCodexCapacityTargets(
760
- deps: ApiRouteDeps,
761
- targets: CodexCapacityWakeTarget[],
762
- ): Promise<void> {
763
- await Promise.allSettled(
764
- targets.map((target) =>
765
- deps.workflowClient.signalCodexCapacity
766
- ? deps.workflowClient.signalCodexCapacity({
767
- accountId: target.accountId,
768
- workspaceId: target.workspaceId,
769
- sessionId: target.sessionId,
770
- workflowId: target.workflowId,
771
- wakeRevision: target.wakeRevision,
772
- workflowWakeRevision: target.workflowWakeRevision,
773
- })
774
- : deps.workflowClient.wakeSessionWorkflow({
775
- accountId: target.accountId,
776
- workspaceId: target.workspaceId,
777
- sessionId: target.sessionId,
778
- workflowId: target.workflowId,
779
- wakeRevision: target.workflowWakeRevision,
780
- }),
781
- ),
782
- );
783
- }
784
-
785
- async function signalPendingCodexCapacityTargets(
786
- deps: ApiRouteDeps,
787
- workspaceId: string,
788
- ): Promise<void> {
789
- const targets = await listPendingCodexCapacityWakeTargets(deps.db, workspaceId).catch(() => []);
790
- await signalCodexCapacityTargets(deps, targets);
791
- }
792
-
793
821
  const CODEX_DEVICE_EXPIRY_SECONDS = 15 * 60; // the device code expires 15 min after start (spec §1.1)
794
822
 
795
823
  export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
@@ -797,72 +825,139 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
797
825
 
798
826
  app.get("/v1/workspaces/:workspaceId/codex/source", async (c) => {
799
827
  const workspaceId = c.req.param("workspaceId");
800
- await requireAccessGrant(c, deps, workspaceId, "workspace:read");
801
- return c.json(await getWorkspaceCodexSubscriptionSource(db, workspaceId));
828
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
829
+ await codexRouteDisposition(deps, grant.accountId);
830
+
831
+ return await coreCodexSource(c, deps, grant.accountId, workspaceId);
802
832
  });
803
833
 
804
834
  app.patch("/v1/workspaces/:workspaceId/codex/source", async (c) => {
805
835
  const workspaceId = c.req.param("workspaceId");
806
836
  const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
807
- const parsed = z
808
- .object({
809
- mode: z.enum(["automatic", "workspace", "organization", "disabled"]),
810
- })
811
- .safeParse(await c.req.json().catch(() => null));
837
+ const parsed = CodexSourceRequest.safeParse(await c.req.json().catch(() => null));
812
838
  if (!parsed.success) {
813
839
  throw new HTTPException(400, {
814
840
  message: "a valid Codex source mode is required",
815
841
  });
816
842
  }
817
- try {
818
- return c.json(
819
- await setWorkspaceCodexSubscriptionMode(db, {
820
- accountId: grant.accountId,
821
- workspaceId,
822
- subjectId: grant.subjectId,
823
- mode: parsed.data.mode,
824
- }),
825
- );
826
- } catch (error) {
827
- if (
828
- error instanceof Error &&
829
- (error.message.includes("personal workspaces") ||
830
- error.message.includes("active turns are using it"))
831
- ) {
832
- throw new HTTPException(409, { message: error.message });
833
- }
834
- throw error;
835
- }
843
+ await codexRouteDisposition(deps, grant.accountId);
844
+
845
+ return await coreCodexSetSource(c, deps, {
846
+ accountId: grant.accountId,
847
+ workspaceId,
848
+ subjectId: grant.subjectId,
849
+ mode: parsed.data.mode,
850
+ });
836
851
  });
837
852
 
838
853
  app.get("/v1/organizations/:organizationId/codex/accounts", async (c) => {
839
854
  const organizationId = c.req.param("organizationId");
840
855
  const human = await requireOrganizationCodexHuman(c, deps, organizationId);
841
- const [accounts, rotation] = await Promise.all([
842
- listOrganizationCodexAccountStatuses(db, {
843
- organizationId,
844
- actorSubjectId: human.subjectId,
845
- }),
846
- getOrganizationCodexRotationSettings(db, {
856
+ await codexRouteDisposition(deps, organizationId);
857
+
858
+ return await coreOrganizationCodexAccounts(c, deps, organizationId, human.subjectId);
859
+ });
860
+
861
+ app.get("/v1/organizations/:organizationId/codex/accounts/:accountId/usage", async (c) => {
862
+ const organizationId = c.req.param("organizationId");
863
+ const human = await requireOrganizationCodexHuman(c, deps, organizationId);
864
+ const mode = await codexRouteDisposition(deps, organizationId);
865
+ const credentialId = c.req.param("accountId");
866
+ if (!z.string().uuid().safeParse(credentialId).success) {
867
+ throw new HTTPException(404, { message: "codex account not found" });
868
+ }
869
+ const usage = await fetchOrganizationCodexUsageForAccount(
870
+ db,
871
+ settings,
872
+ {
847
873
  organizationId,
848
874
  actorSubjectId: human.subjectId,
849
- }),
850
- ]);
851
- return c.json({
852
- accounts: accounts.map((account) => codexAccountJson(account)),
853
- activeAccountId: rotation?.activeCredentialId ?? null,
854
- settings: {
855
- rotationEnabled: rotation?.rotationEnabled ?? false,
856
- rotationStrategy: "sharded",
857
- activeCredentialId: rotation?.activeCredentialId ?? null,
875
+ credentialId,
876
+ mode,
858
877
  },
859
- });
878
+ deps.codexFetch ?? fetch,
879
+ );
880
+ return c.json(codexUsageJson(usage));
881
+ });
882
+
883
+ // One organization account's usage and usage limit resets, for its page on
884
+ // the organization's Models page (any workspace use not required).
885
+ app.get("/v1/organizations/:organizationId/codex/accounts/:accountId/overview", async (c) => {
886
+ const organizationId = c.req.param("organizationId");
887
+ const human = await requireOrganizationCodexHuman(c, deps, organizationId);
888
+ return await coreOrganizationCodexOverview(
889
+ c,
890
+ deps,
891
+ organizationId,
892
+ human,
893
+ c.req.param("accountId"),
894
+ );
860
895
  });
861
896
 
897
+ // Redeem an organization account's usage limit reset from the organization's
898
+ // Models page: the same HMAC confirmation, single-use ledger fences and
899
+ // ambiguous-outcome recovery as a workspace page, for an organization
900
+ // administrator in their own browser or an agent acting as them.
901
+ app.post(
902
+ "/v1/organizations/:organizationId/codex/accounts/:accountId/reset-credits/prepare",
903
+ async (c) => {
904
+ const organizationId = c.req.param("organizationId");
905
+ const credentialId = c.req.param("accountId");
906
+ const { human, workspaceId } = await requireOrganizationRedemptionHuman(
907
+ c,
908
+ deps,
909
+ organizationId,
910
+ );
911
+ await codexRouteDisposition(deps, organizationId);
912
+ return await coreCodexResetPrepare(
913
+ c,
914
+ deps,
915
+ { human, accountId: organizationId, workspaceId, credentialId },
916
+ () =>
917
+ organizationRedemptionTarget(deps, {
918
+ organizationId,
919
+ subjectId: human.subjectId,
920
+ credentialId,
921
+ }),
922
+ );
923
+ },
924
+ );
925
+
926
+ app.post(
927
+ "/v1/organizations/:organizationId/codex/accounts/:accountId/reset-credits/redeem",
928
+ async (c) => {
929
+ const organizationId = c.req.param("organizationId");
930
+ const credentialId = c.req.param("accountId");
931
+ const { human, workspaceId } = await requireOrganizationRedemptionHuman(
932
+ c,
933
+ deps,
934
+ organizationId,
935
+ );
936
+ await codexRouteDisposition(deps, organizationId);
937
+ return await coreCodexResetRedeem(
938
+ c,
939
+ deps,
940
+ { human, accountId: organizationId, workspaceId, credentialId },
941
+ () =>
942
+ organizationRedemptionTarget(deps, {
943
+ organizationId,
944
+ subjectId: human.subjectId,
945
+ credentialId,
946
+ }),
947
+ );
948
+ },
949
+ );
950
+
862
951
  app.post("/v1/organizations/:organizationId/codex/connect/start", async (c) => {
863
952
  const organizationId = c.req.param("organizationId");
864
953
  requireSameOriginBrowserMutation(c, deps);
954
+ // Device sign-in accepts an agent acting as an admin, as the workspace
955
+ // route does: the agent relays the short code and the person still
956
+ // approves at OpenAI themselves. The state binds the same person.
865
957
  const human = await requireOrganizationCodexHuman(c, deps, organizationId);
958
+ // The device-code start touches no account state: public compatibility
959
+ // (a disabled cutover still fails closed).
960
+ await codexRouteDisposition(deps, organizationId);
866
961
  let start: Awaited<ReturnType<typeof startDeviceCode>>;
867
962
  try {
868
963
  start = await startDeviceCode();
@@ -889,6 +984,7 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
889
984
  const organizationId = c.req.param("organizationId");
890
985
  requireSameOriginBrowserMutation(c, deps);
891
986
  const human = await requireOrganizationCodexHuman(c, deps, organizationId);
987
+ await codexRouteDisposition(deps, organizationId);
892
988
  const { state } = (await c.req.json().catch(() => null)) as {
893
989
  state?: string;
894
990
  };
@@ -943,53 +1039,32 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
943
1039
  });
944
1040
  }
945
1041
  const id = parseIdToken(tokens.idToken);
946
- await ensureOrganizationCodexRotationSettings(db, {
947
- organizationId,
948
- actorSubjectId: human.subjectId,
949
- });
950
- let upserted: Awaited<ReturnType<typeof upsertOrganizationCodexSubscriptionCredential>>;
951
- try {
952
- upserted = await upsertOrganizationCodexSubscriptionCredential(db, {
953
- organizationId,
954
- actorSubjectId: human.subjectId,
955
- credentialEncrypted: encryptEnvironmentValue(
956
- key,
957
- JSON.stringify({
958
- access_token: tokens.accessToken,
959
- refresh_token: tokens.refreshToken,
960
- id_token: tokens.idToken,
961
- }),
962
- ),
963
- chatgptAccountId: id.chatgptAccountId,
964
- scopes: null,
1042
+ const credentialEncrypted = encryptEnvironmentValue(
1043
+ key,
1044
+ JSON.stringify({
1045
+ access_token: tokens.accessToken,
1046
+ refresh_token: tokens.refreshToken,
1047
+ id_token: tokens.idToken,
1048
+ }),
1049
+ );
1050
+ await codexRouteDisposition(deps, organizationId);
1051
+
1052
+ return await coreCodexConnected(c, deps, {
1053
+ accountId: organizationId,
1054
+ workspaceId: null,
1055
+ subjectId: human.subjectId,
1056
+ credential: {
1057
+ credentialEncrypted,
1058
+ providerAccountId: id.chatgptAccountId,
1059
+ providerSubjectId: id.chatgptUserId,
965
1060
  planType: id.planType,
966
1061
  isFedramp: id.isFedramp,
967
1062
  expiresAt: accessTokenExpiry(tokens.accessToken),
968
1063
  lastRefreshAt: new Date(),
969
1064
  accountEmail: id.email ?? null,
970
1065
  label: id.email ?? id.chatgptAccountId ?? null,
971
- });
972
- } catch (error) {
973
- const cause = (error as { cause?: unknown } | null)?.cause;
974
- const message =
975
- cause instanceof Error ? cause.message : error instanceof Error ? error.message : "";
976
- if (message.includes("active turns are using it")) {
977
- throw new HTTPException(409, {
978
- message: "Codex subscription source cannot change while active turns are using it",
979
- });
980
- }
981
- throw error;
982
- }
983
- await signalCodexCapacityTargets(deps, upserted.wakeTargets);
984
- const rotation = await getOrganizationCodexRotationSettings(db, {
985
- organizationId,
986
- actorSubjectId: human.subjectId,
987
- });
988
- return c.json({
989
- status: "connected",
990
- plan: id.planType,
991
- accountId: upserted.id,
992
- isActive: rotation?.activeCredentialId === upserted.id,
1066
+ connectedBySubjectId: (await managedCookieHuman(c, deps))?.subjectId ?? null,
1067
+ },
993
1068
  });
994
1069
  });
995
1070
 
@@ -998,42 +1073,69 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
998
1073
  requireSameOriginBrowserMutation(c, deps);
999
1074
  const human = await requireOrganizationCodexHuman(c, deps, organizationId);
1000
1075
  const credentialId = c.req.param("accountId");
1001
- const activation = await setActiveOrganizationCodexCredential(db, {
1002
- organizationId,
1003
- actorSubjectId: human.subjectId,
1076
+ await codexRouteDisposition(deps, organizationId);
1077
+
1078
+ return await coreCodexActivate(
1079
+ c,
1080
+ deps,
1081
+ { accountId: organizationId, workspaceId: null, subjectId: human.subjectId },
1004
1082
  credentialId,
1005
- });
1006
- if (!activation.activated) {
1007
- throw new HTTPException(404, { message: "codex account not found" });
1008
- }
1009
- await signalCodexCapacityTargets(deps, activation.wakeTargets);
1010
- return c.json({ activated: true, accountId: credentialId });
1083
+ );
1011
1084
  });
1012
1085
 
1013
1086
  app.patch("/v1/organizations/:organizationId/codex/settings", async (c) => {
1014
1087
  const organizationId = c.req.param("organizationId");
1015
1088
  requireSameOriginBrowserMutation(c, deps);
1016
1089
  const human = await requireOrganizationCodexHuman(c, deps, organizationId);
1017
- const parsed = z
1018
- .object({ rotationEnabled: z.boolean() })
1019
- .safeParse(await c.req.json().catch(() => null));
1090
+ const parsed = SubscriptionRotationSettingsRequest.safeParse(
1091
+ await c.req.json().catch(() => null),
1092
+ );
1020
1093
  if (!parsed.success) {
1021
1094
  throw new HTTPException(400, { message: "rotationEnabled is required" });
1022
1095
  }
1023
- const updated = await updateOrganizationCodexRotationSettings(db, {
1024
- organizationId,
1025
- actorSubjectId: human.subjectId,
1026
- rotationEnabled: parsed.data.rotationEnabled,
1027
- });
1028
- if (!updated) throw new HTTPException(404, { message: "Codex settings not found" });
1029
- await signalCodexCapacityTargets(deps, updated.wakeTargets);
1030
- return c.json({
1031
- rotationEnabled: updated.rotationEnabled,
1032
- rotationStrategy: "sharded",
1033
- activeCredentialId: updated.activeCredentialId,
1034
- });
1096
+ await codexRouteDisposition(deps, organizationId);
1097
+
1098
+ return await coreCodexSetRotation(
1099
+ c,
1100
+ deps,
1101
+ { accountId: organizationId, workspaceId: null, subjectId: human.subjectId },
1102
+ parsed.data.rotationEnabled,
1103
+ );
1104
+ });
1105
+
1106
+ app.patch("/v1/organizations/:organizationId/codex/accounts/:accountId/allocator", async (c) => {
1107
+ const organizationId = c.req.param("organizationId");
1108
+ requireSameOriginBrowserMutation(c, deps);
1109
+ const human = await requireOrganizationCodexHuman(c, deps, organizationId);
1110
+ const parsed = SubscriptionAccountToggleRequest.safeParse(await c.req.json().catch(() => null));
1111
+ if (!parsed.success) {
1112
+ throw new HTTPException(400, { message: "enabled and expectedVersion are required" });
1113
+ }
1114
+ const admin = { accountId: organizationId, workspaceId: null, subjectId: human.subjectId };
1115
+ await codexRouteDisposition(deps, organizationId);
1116
+
1117
+ return await coreCodexAllocator(c, deps, admin, c.req.param("accountId"), parsed.data);
1035
1118
  });
1036
1119
 
1120
+ app.patch(
1121
+ "/v1/organizations/:organizationId/codex/accounts/:accountId/extra-credits",
1122
+ async (c) => {
1123
+ const organizationId = c.req.param("organizationId");
1124
+ requireSameOriginBrowserMutation(c, deps);
1125
+ const human = await requireOrganizationCodexHuman(c, deps, organizationId);
1126
+ const parsed = SubscriptionAccountToggleRequest.safeParse(
1127
+ await c.req.json().catch(() => null),
1128
+ );
1129
+ if (!parsed.success) {
1130
+ throw new HTTPException(400, { message: "enabled and expectedVersion are required" });
1131
+ }
1132
+ const admin = { accountId: organizationId, workspaceId: null, subjectId: human.subjectId };
1133
+ await codexRouteDisposition(deps, organizationId);
1134
+
1135
+ return await coreCodexExtraCredits(c, deps, admin, c.req.param("accountId"), parsed.data);
1136
+ },
1137
+ );
1138
+
1037
1139
  app.patch("/v1/organizations/:organizationId/codex/accounts/:accountId", async (c) => {
1038
1140
  const organizationId = c.req.param("organizationId");
1039
1141
  requireSameOriginBrowserMutation(c, deps);
@@ -1041,48 +1143,28 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1041
1143
  const body = (await c.req.json().catch(() => null)) as {
1042
1144
  label?: unknown;
1043
1145
  } | null;
1044
- const renamed = await renameOrganizationCodexAccount(db, {
1045
- organizationId,
1046
- actorSubjectId: human.subjectId,
1047
- credentialId: c.req.param("accountId"),
1048
- label: typeof body?.label === "string" ? body.label : null,
1049
- });
1050
- if (!renamed) throw new HTTPException(404, { message: "codex account not found" });
1051
- const accounts = await listOrganizationCodexAccountStatuses(db, {
1052
- organizationId,
1053
- actorSubjectId: human.subjectId,
1054
- });
1055
- const row = accounts.find((account) => account.id === c.req.param("accountId"));
1056
- if (!row) throw new HTTPException(404, { message: "codex account not found" });
1057
- return c.json(codexAccountJson(row));
1146
+ await codexRouteDisposition(deps, organizationId);
1147
+
1148
+ return await coreCodexRename(
1149
+ c,
1150
+ deps,
1151
+ { accountId: organizationId, workspaceId: null, subjectId: human.subjectId },
1152
+ c.req.param("accountId"),
1153
+ typeof body?.label === "string" ? body.label : null,
1154
+ );
1058
1155
  });
1059
1156
 
1060
1157
  app.delete("/v1/organizations/:organizationId/codex/accounts/:accountId", async (c) => {
1061
1158
  const organizationId = c.req.param("organizationId");
1062
1159
  requireSameOriginBrowserMutation(c, deps);
1063
1160
  const human = await requireOrganizationCodexHuman(c, deps, organizationId);
1064
- let result: Awaited<ReturnType<typeof disconnectOrganizationCodexAccount>>;
1065
- try {
1066
- result = await disconnectOrganizationCodexAccount(db, {
1067
- organizationId,
1068
- actorSubjectId: human.subjectId,
1069
- credentialId: c.req.param("accountId"),
1070
- });
1071
- } catch (error) {
1072
- const cause = (error as { cause?: unknown } | null)?.cause;
1073
- const message =
1074
- cause instanceof Error ? cause.message : error instanceof Error ? error.message : "";
1075
- if (message.includes("active turns are using it")) {
1076
- throw new HTTPException(409, {
1077
- message: "Codex subscription cannot disconnect while active turns are using it",
1078
- });
1079
- }
1080
- throw error;
1081
- }
1082
- await signalCodexCapacityTargets(deps, result.wakeTargets);
1083
- return c.json({
1084
- disconnected: result.removed,
1085
- newActiveId: result.newActiveCredentialId,
1161
+ await codexRouteDisposition(deps, organizationId);
1162
+
1163
+ return await coreCodexDisconnect(c, deps, {
1164
+ accountId: organizationId,
1165
+ workspaceId: null,
1166
+ subjectId: human.subjectId,
1167
+ connectionId: c.req.param("accountId"),
1086
1168
  });
1087
1169
  });
1088
1170
 
@@ -1090,7 +1172,10 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1090
1172
  // signed state that carries the device_auth_id back to `poll`.
1091
1173
  app.post("/v1/workspaces/:workspaceId/codex/connect/start", async (c) => {
1092
1174
  const workspaceId = c.req.param("workspaceId");
1093
- await requireAccessGrant(c, deps, workspaceId, "connections:write");
1175
+ const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
1176
+ if (await managedCookieHuman(c, deps)) requireSameOriginBrowserMutation(c, deps);
1177
+ // The device-code start touches no account state.
1178
+ await codexRouteDisposition(deps, grant.accountId);
1094
1179
  let start: Awaited<ReturnType<typeof startDeviceCode>>;
1095
1180
  try {
1096
1181
  start = await startDeviceCode();
@@ -1102,6 +1187,7 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1102
1187
  }
1103
1188
  const state = createSignedState(githubStateSecret, {
1104
1189
  workspaceId,
1190
+ actorSubjectId: grant.subjectId,
1105
1191
  deviceAuthId: start.deviceAuthId,
1106
1192
  userCode: start.userCode,
1107
1193
  });
@@ -1117,6 +1203,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1117
1203
  app.post("/v1/workspaces/:workspaceId/codex/connect/poll", async (c) => {
1118
1204
  const workspaceId = c.req.param("workspaceId");
1119
1205
  const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
1206
+ const connectingHuman = await managedCookieHuman(c, deps);
1207
+ if (connectingHuman) requireSameOriginBrowserMutation(c, deps);
1208
+ await codexRouteDisposition(deps, grant.accountId);
1120
1209
  const { state } = (await c.req.json()) as { state?: string };
1121
1210
  const payload = (state
1122
1211
  ? readSignedState(state, githubStateSecret)
@@ -1124,6 +1213,7 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1124
1213
  if (
1125
1214
  !payload ||
1126
1215
  payload.workspaceId !== workspaceId ||
1216
+ payload.actorSubjectId !== grant.subjectId ||
1127
1217
  !payload.deviceAuthId ||
1128
1218
  !payload.userCode
1129
1219
  ) {
@@ -1170,22 +1260,23 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1170
1260
  });
1171
1261
  }
1172
1262
  const id = parseIdToken(tokens.idToken);
1173
- const connectingHuman = await managedCookieHuman(c, deps);
1174
1263
  const key = environmentsEncryptionKeyBytes(settings);
1175
1264
  if (!key) {
1176
1265
  throw new HTTPException(500, {
1177
1266
  message: "OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY is not configured",
1178
1267
  });
1179
1268
  }
1180
- const mutation = await withSessionCodexCapacityMutation<{
1181
- upserted: Awaited<ReturnType<typeof upsertCodexSubscriptionCredential>>;
1182
- isActive: boolean;
1183
- }>(db, { workspaceId, reason: "codex_credential_connected" }, async (tx) => {
1184
- // The capacity mutation holds the source lock before entering this callback.
1185
- const sourceBeforeConnect = await getWorkspaceCodexSubscriptionSource(tx, workspaceId);
1186
- const upserted = await upsertCodexSubscriptionCredential(tx, {
1187
- accountId: grant.accountId,
1188
- workspaceId,
1269
+ await codexRouteDisposition(deps, grant.accountId);
1270
+
1271
+ // The core writer decides authority from the grant's subject: an
1272
+ // organization administrator (new or reconnected shared account), a
1273
+ // workspace administrator (reconnect of an account this workspace
1274
+ // manages), or the person in their own Personal workspace.
1275
+ return await coreCodexConnected(c, deps, {
1276
+ accountId: grant.accountId,
1277
+ workspaceId,
1278
+ subjectId: grant.subjectId,
1279
+ credential: {
1189
1280
  credentialEncrypted: encryptEnvironmentValue(
1190
1281
  key,
1191
1282
  JSON.stringify({
@@ -1194,59 +1285,16 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1194
1285
  id_token: tokens.idToken,
1195
1286
  }),
1196
1287
  ),
1197
- chatgptAccountId: id.chatgptAccountId,
1198
- scopes: null, // device grant scopes are discovered at runtime, not asserted here
1288
+ providerAccountId: id.chatgptAccountId,
1289
+ providerSubjectId: id.chatgptUserId,
1199
1290
  planType: id.planType,
1200
1291
  isFedramp: id.isFedramp,
1201
1292
  expiresAt: accessTokenExpiry(tokens.accessToken),
1202
1293
  lastRefreshAt: new Date(),
1203
1294
  accountEmail: id.email ?? null,
1204
1295
  label: id.email ?? id.chatgptAccountId ?? null,
1205
- connectedBySubjectId:
1206
- connectingHuman?.subjectId === grant.subjectId ? connectingHuman.subjectId : null,
1207
- });
1208
- if (upserted.kind === "unresolved_redemption") {
1209
- return { result: { upserted, isActive: false }, changed: false };
1210
- }
1211
- await ensureCodexRotationSettings(tx, grant.accountId, workspaceId);
1212
- await setInitialActiveCodexCredential(tx, workspaceId, upserted.id);
1213
- await setWorkspaceCodexSubscriptionModeInTransaction(tx, {
1214
- accountId: grant.accountId,
1215
- workspaceId,
1216
- subjectId: grant.subjectId,
1217
- mode: "workspace",
1218
- effectiveSourceBeforeMutation: sourceBeforeConnect.effectiveSource,
1219
- });
1220
- const rotation = await getCodexRotationSettings(tx, workspaceId);
1221
- return {
1222
- result: {
1223
- upserted,
1224
- isActive: rotation?.activeCredentialId === upserted.id,
1225
- },
1226
- changed: true,
1227
- };
1228
- }).catch((error: unknown) => {
1229
- const cause = (error as { cause?: unknown } | null)?.cause;
1230
- const message =
1231
- cause instanceof Error ? cause.message : error instanceof Error ? error.message : "";
1232
- if (message.includes("active turns are using it")) {
1233
- throw new HTTPException(409, { message });
1234
- }
1235
- throw error;
1236
- });
1237
- const { upserted, isActive } = mutation.result;
1238
- if (upserted.kind === "unresolved_redemption") {
1239
- throw new HTTPException(409, {
1240
- message:
1241
- "this subscription has an unresolved reset redemption; recover it before changing ownership",
1242
- });
1243
- }
1244
- await signalCodexCapacityTargets(deps, mutation.wakeTargets);
1245
- return c.json({
1246
- status: "connected",
1247
- plan: id.planType,
1248
- accountId: upserted.id,
1249
- isActive,
1296
+ connectedBySubjectId: connectingHuman?.subjectId ?? null,
1297
+ },
1250
1298
  });
1251
1299
  });
1252
1300
 
@@ -1254,232 +1302,77 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1254
1302
  // the token is accepted). Never runs a generation. Never returns the token.
1255
1303
  app.get("/v1/workspaces/:workspaceId/codex/status", async (c) => {
1256
1304
  const workspaceId = c.req.param("workspaceId");
1257
- await requireAccessGrant(c, deps, workspaceId, "workspace:read");
1258
- const [status, accounts, source, rotation] = await Promise.all([
1259
- getCodexCredentialStatus(db, workspaceId),
1260
- listCodexAccountStatuses(db, workspaceId),
1261
- getWorkspaceCodexSubscriptionSource(db, workspaceId),
1262
- getCodexRotationSettings(db, workspaceId),
1263
- ]);
1264
- const activeRow = accounts.find((account) => account.id === status?.credentialId) ?? null;
1265
- const activeAccount = activeRow
1266
- ? {
1267
- id: activeRow.id,
1268
- label:
1269
- activeRow.label ??
1270
- activeRow.accountEmail ??
1271
- activeRow.planType ??
1272
- activeRow.chatgptAccountId,
1273
- chatgptAccountId: activeRow.chatgptAccountId,
1274
- }
1275
- : null;
1276
- const activeCredentialId = status?.credentialId ?? rotation?.activeCredentialId ?? null;
1277
- const readiness = codexWorkerReadiness({
1278
- effectiveSource: source.effectiveSource,
1279
- rotationEnabled: rotation?.rotationEnabled ?? false,
1280
- activeCredentialId,
1281
- accounts,
1282
- now: new Date(),
1283
- });
1284
- let valid = false;
1285
- const models = codexModelsForPicker();
1286
- let catalogError: string | null = null;
1287
- try {
1288
- const cred = status?.credentialId
1289
- ? await loadCodexCredentialForRun(db, settings, workspaceId, status.credentialId)
1290
- : null;
1291
- if (cred) {
1292
- const live = await fetchCodexModels({
1293
- accessToken: cred.tokens.accessToken,
1294
- chatgptAccountId: cred.chatgptAccountId,
1295
- isFedramp: cred.isFedramp,
1296
- clientVersion: CODEX_CLIENT_VERSION,
1297
- });
1298
- if (live.ok) {
1299
- valid = true;
1300
- } else {
1301
- catalogError = `Codex models request failed with status ${live.status}`;
1302
- }
1303
- }
1304
- } catch (error) {
1305
- valid = false;
1306
- catalogError = error instanceof Error ? error.message : String(error);
1307
- }
1308
- return c.json({
1309
- connected: status?.connected ?? false,
1310
- plan: status?.planType ?? null,
1311
- valid,
1312
- activeAccountValid: valid,
1313
- poolReady: readiness.poolReady,
1314
- workerRoutable: readiness.workerRoutable,
1315
- expiresAt: status?.expiresAt ?? null,
1316
- lastError: catalogError ?? status?.lastError ?? null,
1317
- models, // ClientModel[] the picker surfaces under the "no credits" group
1318
- activeAccount, // the account a session runs on when unpinned (label for the indicator)
1319
- accountCount: accounts.length,
1320
- source,
1321
- });
1322
- });
1305
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
1306
+ await codexRouteDisposition(deps, grant.accountId);
1323
1307
 
1324
- // List every connected Codex account (metadata only, never decrypts) + the
1308
+ return await coreCodexStatus(
1309
+ c,
1310
+ deps,
1311
+ grant.accountId,
1312
+ workspaceId,
1313
+ (await resolveCatalogSettings(db, settings)).settings,
1314
+ grant.subjectId,
1315
+ );
1316
+ });
1317
+
1318
+ // List every connected Codex account (metadata only, never decrypts) + the
1325
1319
  // workspace active pointer + rotation settings. Read access.
1326
1320
  app.get("/v1/workspaces/:workspaceId/codex/accounts", async (c) => {
1327
1321
  const workspaceId = c.req.param("workspaceId");
1328
1322
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
1329
- const [accounts, rotation, apps, human, source] = await Promise.all([
1330
- listCodexAccountStatuses(db, workspaceId),
1331
- getCodexRotationSettings(db, workspaceId),
1332
- getCodexAppsSettings(db, workspaceId),
1333
- managedCookieHuman(c, deps),
1334
- getWorkspaceCodexSubscriptionSource(db, workspaceId),
1335
- ]);
1336
- const activeAccountId = rotation?.activeCredentialId ?? null;
1337
- const humanSubjectId = human?.subjectId === grant.subjectId ? human.subjectId : null;
1338
- const canManageApps =
1339
- humanSubjectId !== null && hasPermission(grant.permissions, "connections:write");
1340
- return c.json({
1341
- accounts: accounts.map((account) =>
1342
- codexAccountJson(account, {
1343
- appsCredentialId: apps.credentialId,
1344
- canManageApps: settings.codexConnectedAppsEnabled && canManageApps,
1345
- humanSubjectId,
1346
- }),
1347
- ),
1348
- activeAccountId,
1349
- source,
1350
- apps: {
1351
- available: settings.codexConnectedAppsEnabled,
1352
- credentialId: apps.credentialId,
1353
- version: apps.version,
1354
- designatedAt: apps.designatedAt,
1355
- canDisable: canManageApps && apps.credentialId !== null,
1356
- },
1357
- settings: {
1358
- rotationEnabled: rotation?.rotationEnabled ?? false,
1359
- // sharded-rotation policy: rotation-enabled always behaves as sticky-sharded; report the
1360
- // effective truth, never the stored legacy residue.
1361
- rotationStrategy: "sharded",
1362
- activeCredentialId: activeAccountId,
1363
- },
1364
- });
1323
+ await codexRouteDisposition(deps, grant.accountId);
1324
+
1325
+ return await coreCodexAccounts(c, deps, grant, workspaceId);
1365
1326
  });
1366
1327
 
1367
1328
  app.post("/v1/workspaces/:workspaceId/codex/apps", async (c) => {
1368
1329
  const workspaceId = c.req.param("workspaceId");
1369
- await requireWorkspaceCodexManagementSource(deps, workspaceId);
1370
- if (!settings.codexConnectedAppsEnabled) {
1371
- throw new HTTPException(409, {
1372
- message: "Codex Apps is disabled for this deployment",
1373
- });
1374
- }
1330
+ // Authenticate the designating human before any organization state is
1331
+ // read, so an unauthenticated caller learns nothing about the cutover row.
1332
+ // The core gate fails closed without consulting the historical source.
1375
1333
  const { human, accountId } = await requireCodexAppsHuman(c, deps, workspaceId);
1376
- const parsed = z
1377
- .object({
1378
- accountId: z.string().uuid(),
1379
- expectedVersion: z.number().int().nonnegative(),
1380
- })
1381
- .safeParse(await c.req.json().catch(() => null));
1382
- if (!parsed.success) {
1383
- throw new HTTPException(400, {
1384
- message: "accountId and expectedVersion are required",
1385
- });
1386
- }
1387
- const result = await designateCodexAppsCredential(db, {
1388
- accountId,
1389
- workspaceId,
1390
- credentialId: parsed.data.accountId,
1391
- subjectId: human.subjectId,
1392
- expectedVersion: parsed.data.expectedVersion,
1393
- });
1394
- if (result.kind === "not_found") {
1395
- throw new HTTPException(404, { message: "codex account not found" });
1396
- }
1397
- if (result.kind === "not_owner") {
1398
- throw new HTTPException(403, {
1399
- message: "only the managed human who connected this subscription may designate it",
1400
- });
1401
- }
1402
- if (result.kind === "forbidden") {
1403
- throw new HTTPException(403, {
1404
- message: "missing permission: connections:write",
1405
- });
1406
- }
1407
- if (result.kind === "unavailable") {
1408
- throw new HTTPException(409, {
1409
- message: "codex account requires relogin",
1410
- });
1411
- }
1412
- const response = {
1413
- credentialId: result.credentialId,
1414
- version: result.version,
1415
- designatedAt: result.designatedAt,
1416
- changed: result.kind === "updated",
1417
- };
1418
- return result.kind === "updated" ? c.json(response) : c.json(response, 409);
1334
+ await codexRouteDisposition(deps, accountId);
1335
+
1336
+ return await coreCodexDesignateApps(c, deps, workspaceId, { human, accountId });
1419
1337
  });
1420
1338
 
1421
1339
  app.delete("/v1/workspaces/:workspaceId/codex/apps", async (c) => {
1422
1340
  const workspaceId = c.req.param("workspaceId");
1423
- await requireWorkspaceCodexManagementSource(deps, workspaceId);
1341
+ // The Apps designation is its own setting, not part of inference-account
1342
+ // routing: a workspace that switched routing to organization accounts (or
1343
+ // disabled subscriptions) must still be able to turn Apps off. Clearing
1344
+ // only removes authority; the owner/permission checks below still apply.
1424
1345
  const { human, accountId } = await requireCodexAppsHuman(c, deps, workspaceId);
1425
- const parsed = z
1426
- .object({ expectedVersion: z.number().int().nonnegative() })
1427
- .safeParse(await c.req.json().catch(() => null));
1428
- if (!parsed.success) {
1429
- throw new HTTPException(400, { message: "expectedVersion is required" });
1430
- }
1431
- const result = await clearCodexAppsCredential(db, {
1432
- accountId,
1433
- workspaceId,
1434
- subjectId: human.subjectId,
1435
- expectedVersion: parsed.data.expectedVersion,
1436
- });
1437
- if (result.kind === "forbidden") {
1438
- throw new HTTPException(403, {
1439
- message: "missing permission: connections:write",
1440
- });
1441
- }
1442
- const response = {
1443
- credentialId: result.credentialId,
1444
- version: result.version,
1445
- designatedAt: result.designatedAt,
1446
- changed: result.kind === "updated",
1447
- };
1448
- return result.kind === "conflict" ? c.json(response, 409) : c.json(response);
1346
+ await codexRouteDisposition(deps, accountId);
1347
+
1348
+ return await coreCodexClearApps(c, deps, workspaceId, { human, accountId });
1449
1349
  });
1450
1350
 
1451
1351
  // Manually switch the workspace ACTIVE account (the one unpinned sessions use).
1452
1352
  // Pure pointer flip; in-flight turns pick it up on their next token fetch.
1453
1353
  app.post("/v1/workspaces/:workspaceId/codex/accounts/:accountId/activate", async (c) => {
1454
1354
  const workspaceId = c.req.param("workspaceId");
1455
- await requireAccessGrant(c, deps, workspaceId, "connections:write");
1456
- await requireWorkspaceCodexManagementSource(deps, workspaceId);
1457
- const accountId = c.req.param("accountId");
1458
- const mutation = await withCodexCapacityMutation(
1459
- db,
1460
- { workspaceId, reason: "codex_active_credential_changed" },
1461
- async (tx) => {
1462
- const activated = await setActiveCodexCredential(tx, workspaceId, accountId);
1463
- return { result: activated, changed: activated };
1464
- },
1355
+ const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
1356
+ await codexRouteDisposition(deps, grant.accountId);
1357
+
1358
+ return await coreCodexActivate(
1359
+ c,
1360
+ deps,
1361
+ { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId },
1362
+ c.req.param("accountId"),
1465
1363
  );
1466
- const activated = mutation.result;
1467
- if (!activated) {
1468
- throw new HTTPException(404, { message: "codex account not found" });
1469
- }
1470
- await signalCodexCapacityTargets(deps, mutation.wakeTargets);
1471
- return c.json({ activated: true, accountId });
1472
1364
  });
1473
1365
 
1474
1366
  // Update rotation settings. Connection-management access. Sharded-rotation policy: the strategy picker is GONE —
1475
1367
  // rotation-enabled always behaves as sticky-sharded (worker-side
1476
1368
  // effectiveRotationStrategy normalization). `rotationStrategy` in the body is
1477
1369
  // ACCEPTED-BUT-IGNORED so no existing SDK/UI caller breaks (deprecation), and
1478
- // the stored column is only legacy residue kept for old-binary rollback.
1370
+ // the stored column is historical residue, not authority to restart old binaries.
1479
1371
  app.patch("/v1/workspaces/:workspaceId/codex/settings", async (c) => {
1480
1372
  const workspaceId = c.req.param("workspaceId");
1481
1373
  const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
1482
- await requireWorkspaceCodexManagementSource(deps, workspaceId);
1374
+ await codexRouteDisposition(deps, grant.accountId);
1375
+
1483
1376
  const body = (await c.req.json().catch(() => ({}))) as {
1484
1377
  rotationEnabled?: unknown;
1485
1378
  rotationStrategy?: unknown;
@@ -1499,48 +1392,32 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1499
1392
  rotationStrategyDeprecated: true,
1500
1393
  });
1501
1394
  }
1502
- await ensureCodexRotationSettings(db, grant.accountId, workspaceId);
1503
- const mutation = await withCodexCapacityMutation(
1504
- db,
1505
- { workspaceId, reason: "codex_rotation_settings_changed" },
1506
- async (tx) => {
1507
- const updated = await updateCodexRotationSettings(tx, workspaceId, patch);
1508
- return { result: updated, changed: updated !== null };
1509
- },
1395
+
1396
+ return await coreCodexSetRotation(
1397
+ c,
1398
+ deps,
1399
+ { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId },
1400
+ patch.rotationEnabled,
1510
1401
  );
1511
- const updated = mutation.result;
1512
- if (!updated) {
1513
- throw new HTTPException(404, {
1514
- message: "codex rotation settings not found",
1515
- });
1516
- }
1517
- await signalCodexCapacityTargets(deps, mutation.wakeTargets);
1518
- return c.json({
1519
- rotationEnabled: updated.rotationEnabled,
1520
- // sharded-rotation policy: sharded is the only behavior; the stored column is residue.
1521
- rotationStrategy: "sharded",
1522
- activeCredentialId: updated.activeCredentialId,
1523
- });
1524
1402
  });
1525
1403
 
1526
1404
  // Rename an account (label only in P1).
1527
1405
  app.patch("/v1/workspaces/:workspaceId/codex/accounts/:accountId", async (c) => {
1528
1406
  const workspaceId = c.req.param("workspaceId");
1529
- await requireAccessGrant(c, deps, workspaceId, "connections:write");
1530
- await requireWorkspaceCodexManagementSource(deps, workspaceId);
1407
+ const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
1408
+ await codexRouteDisposition(deps, grant.accountId);
1409
+
1531
1410
  const accountId = c.req.param("accountId");
1532
1411
  const body = (await c.req.json()) as { label?: string | null };
1533
1412
  const label = typeof body.label === "string" ? body.label : null;
1534
- const renamed = await renameCodexAccount(db, workspaceId, accountId, label);
1535
- if (!renamed) {
1536
- throw new HTTPException(404, { message: "codex account not found" });
1537
- }
1538
- const accounts = await listCodexAccountStatuses(db, workspaceId);
1539
- const row = accounts.find((account) => account.id === accountId);
1540
- if (!row) {
1541
- throw new HTTPException(404, { message: "codex account not found" });
1542
- }
1543
- return c.json(codexAccountJson(row));
1413
+
1414
+ return await coreCodexRename(
1415
+ c,
1416
+ deps,
1417
+ { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId },
1418
+ accountId,
1419
+ label,
1420
+ );
1544
1421
  });
1545
1422
 
1546
1423
  // Codex quota: independent OCC for new-turn allocator eligibility. Same-state is
@@ -1549,38 +1426,43 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1549
1426
  app.patch("/v1/workspaces/:workspaceId/codex/accounts/:accountId/allocator", async (c) => {
1550
1427
  const workspaceId = c.req.param("workspaceId");
1551
1428
  const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
1552
- await requireWorkspaceCodexManagementSource(deps, workspaceId);
1553
- const parsed = z
1554
- .object({
1555
- enabled: z.boolean(),
1556
- expectedVersion: z.number().int().positive(),
1557
- })
1558
- .safeParse(await c.req.json().catch(() => null));
1429
+ await codexRouteDisposition(deps, grant.accountId);
1430
+
1431
+ const parsed = SubscriptionAccountToggleRequest.safeParse(await c.req.json().catch(() => null));
1559
1432
  if (!parsed.success) {
1560
1433
  throw new HTTPException(400, {
1561
1434
  message: "enabled and expectedVersion are required",
1562
1435
  });
1563
1436
  }
1564
- const mutation = await updateCodexAllocatorEligibility(db, {
1565
- accountId: grant.accountId,
1566
- workspaceId,
1567
- credentialId: c.req.param("accountId"),
1568
- subjectId: grant.subjectId,
1569
- enabled: parsed.data.enabled,
1570
- expectedVersion: parsed.data.expectedVersion,
1571
- });
1572
- const result = mutation.result;
1573
- if (result.kind === "not_found") {
1574
- throw new HTTPException(404, { message: "codex account not found" });
1437
+
1438
+ return await coreCodexAllocator(
1439
+ c,
1440
+ deps,
1441
+ { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId },
1442
+ c.req.param("accountId"),
1443
+ parsed.data,
1444
+ );
1445
+ });
1446
+
1447
+ app.patch("/v1/workspaces/:workspaceId/codex/accounts/:accountId/extra-credits", async (c) => {
1448
+ const workspaceId = c.req.param("workspaceId");
1449
+ const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
1450
+ await codexRouteDisposition(deps, grant.accountId);
1451
+
1452
+ const parsed = SubscriptionAccountToggleRequest.safeParse(await c.req.json().catch(() => null));
1453
+ if (!parsed.success) {
1454
+ throw new HTTPException(400, {
1455
+ message: "enabled and expectedVersion are required",
1456
+ });
1575
1457
  }
1576
- const response = {
1577
- allocatorEnabled: result.allocatorEnabled,
1578
- allocatorVersion: result.allocatorVersion,
1579
- allocatorUpdatedAt: result.allocatorUpdatedAt,
1580
- changed: result.kind === "updated",
1581
- };
1582
- await signalCodexCapacityTargets(deps, mutation.wakeTargets);
1583
- return result.kind === "conflict" ? c.json(response, 409) : c.json(response);
1458
+
1459
+ return await coreCodexExtraCredits(
1460
+ c,
1461
+ deps,
1462
+ { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId },
1463
+ c.req.param("accountId"),
1464
+ parsed.data,
1465
+ );
1584
1466
  });
1585
1467
 
1586
1468
  // Disconnect ONE account by id. The accessor re-picks active when the removed
@@ -1588,27 +1470,13 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1588
1470
  app.delete("/v1/workspaces/:workspaceId/codex/accounts/:accountId", async (c) => {
1589
1471
  const workspaceId = c.req.param("workspaceId");
1590
1472
  const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
1591
- await requireWorkspaceCodexManagementSource(deps, workspaceId);
1592
- const accountId = c.req.param("accountId");
1593
- const mutation = await withCodexCapacityMutation(
1594
- db,
1595
- { workspaceId, reason: "codex_credential_disconnected" },
1596
- async (tx) => {
1597
- const result = await disconnectCodexAccount(tx, workspaceId, accountId, grant.subjectId);
1598
- return { result, changed: result.removed };
1599
- },
1600
- );
1601
- const result = mutation.result;
1602
- if (result.blockedByUnresolvedRedemption) {
1603
- throw new HTTPException(409, {
1604
- message:
1605
- "this subscription has an unresolved reset redemption; recover it before disconnecting",
1606
- });
1607
- }
1608
- await signalCodexCapacityTargets(deps, mutation.wakeTargets);
1609
- return c.json({
1610
- disconnected: result.removed,
1611
- newActiveId: result.newActiveCredentialId,
1473
+ await codexRouteDisposition(deps, grant.accountId);
1474
+
1475
+ return await coreCodexDisconnect(c, deps, {
1476
+ accountId: grant.accountId,
1477
+ workspaceId,
1478
+ subjectId: grant.subjectId,
1479
+ connectionId: c.req.param("accountId"),
1612
1480
  });
1613
1481
  });
1614
1482
 
@@ -1617,24 +1485,13 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1617
1485
  app.delete("/v1/workspaces/:workspaceId/codex", async (c) => {
1618
1486
  const workspaceId = c.req.param("workspaceId");
1619
1487
  const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
1620
- await requireWorkspaceCodexManagementSource(deps, workspaceId);
1621
- const mutation = await withCodexCapacityMutation(
1622
- db,
1623
- { workspaceId, reason: "codex_credentials_disconnected" },
1624
- async (tx) => {
1625
- const result = await disconnectAllCodexAccounts(tx, workspaceId, grant.subjectId);
1626
- return { result, changed: result.removed > 0 };
1627
- },
1628
- );
1629
- const result = mutation.result;
1630
- if (result.blockedCredentialIds.length > 0) {
1631
- throw new HTTPException(409, {
1632
- message:
1633
- "one or more subscriptions have unresolved reset redemptions; recover them before disconnecting",
1634
- });
1635
- }
1636
- await signalCodexCapacityTargets(deps, mutation.wakeTargets);
1637
- return c.json({ disconnected: result.removed > 0 });
1488
+ await codexRouteDisposition(deps, grant.accountId);
1489
+
1490
+ return await coreCodexDisconnectAll(c, deps, {
1491
+ accountId: grant.accountId,
1492
+ workspaceId,
1493
+ subjectId: grant.subjectId,
1494
+ });
1638
1495
  });
1639
1496
 
1640
1497
  // Back-compat: remaining usage / limits for the ACTIVE account only. Repointed
@@ -1642,33 +1499,20 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1642
1499
  // stale access token. Deprecated in favor of the /accounts + /usage/refresh pair.
1643
1500
  app.get("/v1/workspaces/:workspaceId/codex/usage", async (c) => {
1644
1501
  const workspaceId = c.req.param("workspaceId");
1645
- await requireAccessGrant(c, deps, workspaceId, "workspace:read");
1646
- const status = await getCodexCredentialStatus(db, workspaceId);
1647
- if (!status?.credentialId) {
1648
- throw new HTTPException(404, {
1649
- message: "codex subscription is not connected",
1650
- });
1651
- }
1652
- const payload = await fetchCodexUsageForAccount(db, settings, workspaceId, status.credentialId);
1653
- await signalPendingCodexCapacityTargets(deps, workspaceId);
1654
- return c.json(codexUsageJson(payload));
1502
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
1503
+ await codexRouteDisposition(deps, grant.accountId);
1504
+
1505
+ return await coreCodexUsage(c, deps, grant, workspaceId, null);
1655
1506
  });
1656
1507
 
1657
1508
  // Single-account LIVE usage read (per-row manual refresh): refresh THIS account's
1658
1509
  // bearer, hit /wham/usage, write the cache columns, return the normalized payload.
1659
1510
  app.get("/v1/workspaces/:workspaceId/codex/accounts/:accountId/usage", async (c) => {
1660
1511
  const workspaceId = c.req.param("workspaceId");
1661
- await requireAccessGrant(c, deps, workspaceId, "workspace:read");
1662
- const accountId = c.req.param("accountId");
1663
- // Constrain to a real account in this workspace (RLS already scopes, but a 404
1664
- // for an unknown id is friendlier than an opaque needs_relogin payload).
1665
- const accounts = await listCodexAccountStatuses(db, workspaceId);
1666
- if (!accounts.some((account) => account.id === accountId)) {
1667
- throw new HTTPException(404, { message: "codex account not found" });
1668
- }
1669
- const payload = await fetchCodexUsageForAccount(db, settings, workspaceId, accountId);
1670
- await signalPendingCodexCapacityTargets(deps, workspaceId);
1671
- return c.json(codexUsageJson(payload));
1512
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
1513
+ await codexRouteDisposition(deps, grant.accountId);
1514
+
1515
+ return await coreCodexUsage(c, deps, grant, workspaceId, c.req.param("accountId"));
1672
1516
  });
1673
1517
 
1674
1518
  // Batched LIVE refresh across every connected account, keyed by credential id.
@@ -1678,42 +1522,10 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1678
1522
  // staleness check call — NEVER a browser interval.
1679
1523
  app.post("/v1/workspaces/:workspaceId/codex/usage/refresh", async (c) => {
1680
1524
  const workspaceId = c.req.param("workspaceId");
1681
- await requireAccessGrant(c, deps, workspaceId, "workspace:read");
1682
- const accounts = await listCodexAccountStatuses(db, workspaceId);
1683
- const usage: Record<string, { status: CodexUsagePayload["status"]; usage: CodexUsagePayload }> =
1684
- {};
1685
- const queue = [...accounts];
1686
- const CONCURRENCY = 4;
1687
- const worker = async (): Promise<void> => {
1688
- for (;;) {
1689
- const account = queue.shift();
1690
- if (!account) return;
1691
- const settled = await Promise.allSettled([
1692
- fetchCodexUsageForAccount(db, settings, workspaceId, account.id),
1693
- ]);
1694
- const result = settled[0];
1695
- usage[account.id] =
1696
- result.status === "fulfilled"
1697
- ? codexUsageJson(result.value)
1698
- : {
1699
- status: "error",
1700
- usage: {
1701
- status: "error",
1702
- planType: null,
1703
- fiveHour: null,
1704
- weekly: null,
1705
- limitReached: false,
1706
- fetchedAt: new Date().toISOString(),
1707
- rateLimitResetCredits: null,
1708
- },
1709
- };
1710
- }
1711
- };
1712
- await Promise.all(
1713
- Array.from({ length: Math.min(CONCURRENCY, Math.max(1, accounts.length)) }, () => worker()),
1714
- );
1715
- await signalPendingCodexCapacityTargets(deps, workspaceId);
1716
- return c.json({ usage });
1525
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
1526
+ await codexRouteDisposition(deps, grant.accountId);
1527
+
1528
+ return await coreCodexUsageRefresh(c, deps, grant, workspaceId);
1717
1529
  });
1718
1530
 
1719
1531
  // Trustworthy live overview: usage and reset details settle independently per
@@ -1722,132 +1534,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1722
1534
  app.get("/v1/workspaces/:workspaceId/codex/overview", async (c) => {
1723
1535
  const workspaceId = c.req.param("workspaceId");
1724
1536
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
1725
- const human = await managedCookieHuman(c, deps);
1726
- const accounts = await listCodexAccountStatuses(db, workspaceId);
1727
- const ownerRecoveries =
1728
- human &&
1729
- human.subjectId === grant.subjectId &&
1730
- hasPermission(grant.permissions, "connections:write")
1731
- ? await listCodexResetRedemptionRecoveries(db, {
1732
- accountId: grant.accountId,
1733
- workspaceId,
1734
- subjectId: human.subjectId,
1735
- })
1736
- : [];
1737
- const overview: Record<string, Awaited<ReturnType<typeof fetchCodexAccountOverview>>> = {};
1738
- const queue = [...accounts];
1739
- // Usage + detailed inventory are two independent calls per account. Limit
1740
- // the actual provider calls, not merely account workers, so aggregate
1741
- // concurrency never exceeds four.
1742
- const providerCall = createProviderCallLimiter(4);
1743
- let routeTimedOut = false;
1744
- const worker = async (): Promise<void> => {
1745
- for (;;) {
1746
- if (routeTimedOut) return;
1747
- const account = queue.shift();
1748
- if (!account) return;
1749
- const canResumeRedemption = Boolean(
1750
- account.source === "workspace" &&
1751
- human &&
1752
- human.subjectId === grant.subjectId &&
1753
- human.subjectId === account.connectedBySubjectId &&
1754
- hasPermission(grant.permissions, "connections:write"),
1755
- );
1756
- const canRedeem = canResumeRedemption && account.status === "active";
1757
- const redemptionAccess: CodexRedemptionAccess =
1758
- account.source === "organization"
1759
- ? {
1760
- ownership: "managed_human_unavailable",
1761
- canClaimUnownedViaReconnect: false,
1762
- }
1763
- : codexRedemptionAccess({
1764
- connectedBySubjectId: account.connectedBySubjectId,
1765
- grantSubjectId: grant.subjectId,
1766
- managedHumanSubjectId: human?.subjectId ?? null,
1767
- canManage: hasPermission(grant.permissions, "connections:write"),
1768
- });
1769
- overview[account.id] = await fetchCodexAccountOverview(
1770
- deps,
1771
- workspaceId,
1772
- account,
1773
- redemptionAccess,
1774
- canRedeem,
1775
- canResumeRedemption,
1776
- canResumeRedemption
1777
- ? ownerRecoveries.filter((recovery) => recovery.credentialId === account.id)
1778
- : [],
1779
- providerCall,
1780
- );
1781
- }
1782
- };
1783
- const workers = Promise.all(
1784
- Array.from({ length: Math.min(4, Math.max(1, accounts.length)) }, () => worker()),
1785
- );
1786
- let deadline: ReturnType<typeof setTimeout> | undefined;
1787
- await Promise.race([
1788
- workers,
1789
- new Promise<void>((resolve) => {
1790
- deadline = setTimeout(() => {
1791
- routeTimedOut = true;
1792
- queue.length = 0;
1793
- resolve();
1794
- }, CODEX_OVERVIEW_ROUTE_TIMEOUT_MS);
1795
- }),
1796
- ]);
1797
- if (deadline) clearTimeout(deadline);
1798
- if (routeTimedOut) {
1799
- // Fill every unscheduled account from persisted cache without performing
1800
- // more provider work. In-flight account operations remain rejection-
1801
- // handled and are themselves bounded; they cannot delay this response or
1802
- // leave limiter waiters permanently queued.
1803
- const unavailableProviderCall: CodexProviderCall = async () => {
1804
- throw new Error("Codex overview route deadline reached");
1805
- };
1806
- await Promise.all(
1807
- accounts
1808
- .filter((account) => overview[account.id] == null)
1809
- .map(async (account) => {
1810
- const canResumeRedemption = Boolean(
1811
- account.source === "workspace" &&
1812
- human &&
1813
- human.subjectId === grant.subjectId &&
1814
- human.subjectId === account.connectedBySubjectId &&
1815
- hasPermission(grant.permissions, "connections:write"),
1816
- );
1817
- const fallback = await fetchCodexAccountOverview(
1818
- deps,
1819
- workspaceId,
1820
- account,
1821
- account.source === "organization"
1822
- ? {
1823
- ownership: "managed_human_unavailable",
1824
- canClaimUnownedViaReconnect: false,
1825
- }
1826
- : codexRedemptionAccess({
1827
- connectedBySubjectId: account.connectedBySubjectId,
1828
- grantSubjectId: grant.subjectId,
1829
- managedHumanSubjectId: human?.subjectId ?? null,
1830
- canManage: hasPermission(grant.permissions, "connections:write"),
1831
- }),
1832
- false,
1833
- canResumeRedemption,
1834
- canResumeRedemption
1835
- ? ownerRecoveries.filter((recovery) => recovery.credentialId === account.id)
1836
- : [],
1837
- unavailableProviderCall,
1838
- );
1839
- // A bounded in-flight worker may have completed while fallback was
1840
- // assembled; prefer that fresh truth when present.
1841
- overview[account.id] ??= fallback;
1842
- }),
1843
- );
1844
- void workers.catch(() => undefined);
1845
- }
1846
- // Overview writes the same authoritative usage snapshots as the explicit
1847
- // refresh routes. Deliver any committed capacity outbox entries instead of
1848
- // leaving quota-recovered waiters dormant until a later unrelated refresh.
1849
- void signalPendingCodexCapacityTargets(deps, workspaceId).catch(() => undefined);
1850
- return c.json({ accounts: overview });
1537
+ await codexRouteDisposition(deps, grant.accountId);
1538
+
1539
+ return await coreCodexOverview(c, deps, grant, workspaceId);
1851
1540
  });
1852
1541
 
1853
1542
  // Mint a five-minute HMAC confirmation bound to the actual Better Auth
@@ -1860,335 +1549,804 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1860
1549
  const workspaceId = c.req.param("workspaceId");
1861
1550
  const credentialId = c.req.param("accountId");
1862
1551
  const { human, accountId } = await requireRedemptionHuman(c, deps, workspaceId);
1863
- c.header("cache-control", "no-store");
1864
- const parsed = redemptionPrepareBody.safeParse(await c.req.json().catch(() => null));
1865
- if (!parsed.success) {
1866
- throw new HTTPException(400, {
1867
- message: "attemptId and creditId are required",
1868
- });
1869
- }
1870
- const accounts = await listCodexAccountStatuses(db, workspaceId);
1871
- const account = accounts.find((candidate) => candidate.id === credentialId);
1872
- if (!account) throw new HTTPException(404, { message: "codex account not found" });
1873
- if (account.source === "organization") {
1874
- throw new HTTPException(409, {
1875
- message: "organization Codex subscriptions are managed in Organization settings",
1876
- });
1877
- }
1878
- let existing = await getCodexResetRedemptionAttempt(db, workspaceId, parsed.data.attemptId);
1879
- if (account.connectedBySubjectId !== human.subjectId) {
1880
- throw new HTTPException(403, {
1881
- message: "only the human who connected this subscription may redeem its reset credits",
1882
- });
1883
- }
1884
- if (
1885
- existing &&
1886
- (existing.credentialId !== credentialId ||
1887
- existing.creditId !== parsed.data.creditId ||
1888
- existing.subjectId !== human.subjectId)
1889
- ) {
1890
- throw new HTTPException(409, {
1891
- message: "logical redemption attempt identity mismatch",
1892
- });
1893
- }
1894
- if (existing) {
1895
- const adoption = await adoptCodexResetRedemptionAttempt(db, {
1896
- accountId,
1897
- workspaceId,
1898
- attemptId: existing.id,
1899
- credentialId,
1900
- creditId: existing.creditId,
1901
- subjectId: human.subjectId,
1902
- browserSessionHash: human.browserSessionHash,
1903
- });
1904
- if (adoption.kind === "in_progress") {
1905
- throw new HTTPException(409, {
1906
- message: "this redemption is still in progress in another browser request",
1907
- });
1908
- }
1909
- if (adoption.kind === "not_found") {
1910
- throw new HTTPException(409, {
1911
- message: "redemption recovery state changed",
1912
- });
1913
- }
1914
- if (adoption.kind === "forbidden") {
1915
- throw new HTTPException(403, {
1916
- message: "redemption owner is unavailable",
1917
- });
1918
- }
1919
- if (adoption.kind === "conflict") {
1920
- throw new HTTPException(409, {
1921
- message: "logical redemption attempt identity mismatch",
1922
- });
1923
- }
1924
- existing = adoption.attempt;
1925
- }
1926
- // Starting or retrying provider work requires a healthy credential. A
1927
- // completed attempt is different: its provider outcome is durable truth,
1928
- // and a lost HTTP response must remain replayable after a later health
1929
- // transition without another consume call.
1930
- if (account.status !== "active" && existing?.status !== "completed") {
1931
- throw new HTTPException(403, {
1932
- message: "redemption credential is unavailable",
1933
- });
1934
- }
1935
- const secret = settings.betterAuthSecret;
1936
- if (!secret) {
1937
- throw new HTTPException(503, {
1938
- message: "managed browser confirmation is unavailable",
1939
- });
1940
- }
1941
- const expiresAt = Math.floor(Date.now() / 1000) + CODEX_REDEMPTION_CONFIRMATION_SECONDS;
1942
- const confirmationToken = await signCodexRedemptionConfirmation(secret, {
1943
- version: 1,
1944
- attemptId: parsed.data.attemptId,
1552
+ await codexRouteDisposition(deps, accountId);
1553
+
1554
+ return await coreCodexResetPrepare(c, deps, {
1555
+ human,
1556
+ accountId,
1945
1557
  workspaceId,
1946
1558
  credentialId,
1947
- creditId: parsed.data.creditId,
1948
- subjectId: human.subjectId,
1949
- browserSessionHash: human.browserSessionHash,
1950
- expiresAt,
1951
- });
1952
- return c.json({
1953
- attemptId: parsed.data.attemptId,
1954
- confirmationToken,
1955
- expiresAt: new Date(expiresAt * 1000).toISOString(),
1956
- // A completed attempt may have lost its HTTP response after its outcome
1957
- // committed. Keep that exact logical id replayable without another
1958
- // provider consume call, just like an ambiguous provider_started attempt.
1959
- resumable: existing?.status === "provider_started" || existing?.status === "completed",
1960
- recoveryStatus:
1961
- existing?.status === "provider_started" || existing?.status === "completed"
1962
- ? existing.status
1963
- : null,
1964
1559
  });
1965
1560
  },
1966
1561
  );
1967
1562
 
1968
- // The only OpenGeni reset-credit mutation route. There is intentionally no
1969
- // SDK/MCP/worker/scheduled/background equivalent.
1563
+ // The only Opengeni reset-credit mutation route: the person in the web app,
1564
+ // or an agent they signed in acting as them. Nothing redeems automatically
1565
+ // (no worker, scheduled, allocator or rotation path).
1970
1566
  app.post(
1971
1567
  "/v1/workspaces/:workspaceId/codex/accounts/:accountId/reset-credits/redeem",
1972
1568
  async (c) => {
1973
1569
  const workspaceId = c.req.param("workspaceId");
1974
1570
  const credentialId = c.req.param("accountId");
1975
1571
  const { human, accountId } = await requireRedemptionHuman(c, deps, workspaceId);
1976
- c.header("cache-control", "no-store");
1977
- const parsed = redemptionBody.safeParse(await c.req.json().catch(() => null));
1978
- if (!parsed.success) {
1979
- throw new HTTPException(400, {
1980
- message: "explicit redemption confirmation is required",
1981
- });
1982
- }
1983
- const secret = settings.betterAuthSecret;
1984
- if (!secret) {
1985
- throw new HTTPException(503, {
1986
- message: "managed browser confirmation is unavailable",
1987
- });
1988
- }
1989
- const claims = await verifyCodexRedemptionConfirmation(secret, parsed.data.confirmationToken);
1990
- if (
1991
- !claims ||
1992
- claims.attemptId !== parsed.data.attemptId ||
1993
- claims.workspaceId !== workspaceId ||
1994
- claims.credentialId !== credentialId ||
1995
- claims.creditId !== parsed.data.creditId ||
1996
- claims.subjectId !== human.subjectId ||
1997
- claims.browserSessionHash !== human.browserSessionHash
1998
- ) {
1999
- throw new HTTPException(403, {
2000
- message: "redemption confirmation is invalid or expired",
2001
- });
1572
+ await codexRouteDisposition(deps, accountId);
1573
+
1574
+ return await coreCodexResetRedeem(c, deps, { human, accountId, workspaceId, credentialId });
1575
+ },
1576
+ );
1577
+ }
1578
+
1579
+ // ---------------------------------------------------------------------------
1580
+ // Reset credits and the overview on the shared subscription core (M3 PR 2c).
1581
+ // Same routes, payloads, HMAC confirmation, single-use ledger fences and
1582
+ // ambiguous-outcome recovery as the historical handlers; authority is the
1583
+ // design 6.3 rule enforced by `subscription_codex_reset_authority` (an
1584
+ // organization administrator or an administrator of the managing workspace,
1585
+ // from a same-origin managed browser or an agent acting as them), and the
1586
+ // credential is read and refreshed through the core connection seam.
1587
+
1588
+ type CoreRedemptionInput = {
1589
+ human: ManagedCookieHuman;
1590
+ accountId: string;
1591
+ /** The workspace whose redemption ledger files the attempt. */
1592
+ workspaceId: string;
1593
+ credentialId: string;
1594
+ };
1595
+
1596
+ /**
1597
+ * The account a redemption targets, and how its provider calls are made. A
1598
+ * workspace page reaches it through the workspace's pool; the organization
1599
+ * page reaches an organization-managed account directly, as an organization
1600
+ * administrator, whether or not any workspace uses it.
1601
+ */
1602
+ type RedemptionTarget = {
1603
+ credentialId: string;
1604
+ organizationManaged: boolean;
1605
+ provider: () => {
1606
+ getToken: () => Promise<{
1607
+ accessToken: string;
1608
+ chatgptAccountId: string | null;
1609
+ isFedramp: boolean;
1610
+ }>;
1611
+ fetch: CodexFetch;
1612
+ };
1613
+ };
1614
+
1615
+ /** Ledger and authority reads run as the browser human, never the ambient actor. */
1616
+ function asRedemptionHuman<T>(human: ManagedCookieHuman, fn: () => Promise<T>): Promise<T> {
1617
+ return withSessionRlsActorContext({ subjectId: human.subjectId }, fn);
1618
+ }
1619
+
1620
+ /**
1621
+ * Canonical core connection for a route id (canonical or legacy alias) in the
1622
+ * workspace's pool. An organization account is redeemable only by an
1623
+ * organization administrator (M3 PR 3b); everyone else gets the legacy 409.
1624
+ */
1625
+ async function coreRedemptionTarget(deps: ApiRouteDeps, input: CoreRedemptionInput) {
1626
+ const canonical = z.uuid().safeParse(input.credentialId).success
1627
+ ? await resolveSubscriptionCoreCodexConnectionId(deps.db, {
1628
+ accountId: input.accountId,
1629
+ workspaceId: input.workspaceId,
1630
+ connectionId: input.credentialId,
1631
+ })
1632
+ : null;
1633
+ const { accounts } = await getSubscriptionCoreCodexWorkspaceProjection(deps.db, input);
1634
+ const account = canonical ? accounts.find((candidate) => candidate.id === canonical) : undefined;
1635
+ if (!canonical || !account) throw new HTTPException(404, { message: "codex account not found" });
1636
+ return { credentialId: canonical, account };
1637
+ }
1638
+
1639
+ async function workspaceRedemptionTarget(
1640
+ deps: ApiRouteDeps,
1641
+ input: CoreRedemptionInput,
1642
+ ): Promise<RedemptionTarget> {
1643
+ const { credentialId, account } = await coreRedemptionTarget(deps, input);
1644
+ const scope = coreCodexConnectionScope(input, input.human.subjectId);
1645
+ return {
1646
+ credentialId,
1647
+ organizationManaged: account.source === "organization",
1648
+ provider: () => ({
1649
+ getToken: () =>
1650
+ buildSubscriptionCoreCodexConnectionTokenResolver(
1651
+ deps.db,
1652
+ deps.settings,
1653
+ scope,
1654
+ credentialId,
1655
+ null,
1656
+ ).getToken(),
1657
+ fetch: buildSubscriptionCoreCodexOperationFetch(
1658
+ deps.db,
1659
+ scope,
1660
+ null,
1661
+ credentialId,
1662
+ (deps.codexFetch ?? fetch) as CodexFetch,
1663
+ ),
1664
+ }),
1665
+ };
1666
+ }
1667
+
1668
+ /** An organization-managed account, read by an organization administrator. */
1669
+ async function organizationRedemptionTarget(
1670
+ deps: ApiRouteDeps,
1671
+ input: { organizationId: string; subjectId: string; credentialId: string },
1672
+ ): Promise<RedemptionTarget> {
1673
+ const found = z.uuid().safeParse(input.credentialId).success
1674
+ ? await findOrganizationCodexConnectionForAdministrator(deps.db, deps.settings, {
1675
+ organizationId: input.organizationId,
1676
+ actorSubjectId: input.subjectId,
1677
+ credentialId: input.credentialId,
1678
+ })
1679
+ : null;
1680
+ if (!found) throw new HTTPException(404, { message: "codex account not found" });
1681
+ return {
1682
+ credentialId: found.credentialId,
1683
+ organizationManaged: true,
1684
+ provider: () => ({
1685
+ getToken: () =>
1686
+ buildOrganizationCodexAdministratorTokenResolver(deps.db, deps.settings, {
1687
+ organizationId: input.organizationId,
1688
+ actorSubjectId: input.subjectId,
1689
+ credentialId: found.credentialId,
1690
+ }).getToken(),
1691
+ fetch: (deps.codexFetch ?? fetch) as CodexFetch,
1692
+ }),
1693
+ };
1694
+ }
1695
+
1696
+ function organizationManagedRedemption(): never {
1697
+ throw new HTTPException(409, {
1698
+ message: "organization Codex subscriptions are managed in Organization settings",
1699
+ });
1700
+ }
1701
+
1702
+ /**
1703
+ * An organization administrator redeeming from the organization's Models
1704
+ * page: the person in their own managed browser, or an agent acting as them
1705
+ * (never an API key or a delegated bearer), and the workspace whose ledger
1706
+ * files the attempt. An agent's confirmation binds to its stable per-person
1707
+ * stand-in session, which never equals a browser session.
1708
+ */
1709
+ async function requireOrganizationRedemptionHuman(
1710
+ c: Context,
1711
+ deps: ApiRouteDeps,
1712
+ organizationId: string,
1713
+ ): Promise<{ human: ManagedCookieHuman; workspaceId: string }> {
1714
+ if (deps.settings.productAccessMode !== "managed") {
1715
+ throw new HTTPException(403, { message: "reset redemption requires managed product mode" });
1716
+ }
1717
+ const agent = isAgentActingAsPerson(c);
1718
+ if (!agent && c.req.header("authorization")) {
1719
+ throw new HTTPException(403, {
1720
+ message: "authorization bearer is not allowed for redemption",
1721
+ });
1722
+ }
1723
+ requireSameOriginBrowserMutation(c, deps);
1724
+ const browser = agent ? null : await managedCookieHuman(c, deps);
1725
+ if (!agent && !browser) {
1726
+ throw new HTTPException(401, { message: "managed browser session required" });
1727
+ }
1728
+ const human = await requireOrganizationCodexHuman(c, deps, organizationId);
1729
+ if (browser && human.subjectId !== browser.subjectId) {
1730
+ throw new HTTPException(403, { message: "managed browser identity mismatch" });
1731
+ }
1732
+ const workspaceId = await resolveOrganizationCodexRedemptionWorkspace(deps.db, {
1733
+ organizationId,
1734
+ actorSubjectId: human.subjectId,
1735
+ });
1736
+ if (!workspaceId) {
1737
+ throw new HTTPException(409, {
1738
+ message: "create a workspace first: usage limit reset redemptions are recorded in one",
1739
+ });
1740
+ }
1741
+ return { human, workspaceId };
1742
+ }
1743
+
1744
+ /**
1745
+ * One organization-managed account's usage and usage limit resets on the
1746
+ * organization's Models page, whether or not any workspace uses it. Anyone
1747
+ * who may manage the organization's Codex accounts can read it (an agent
1748
+ * acting as an administrator included); an organization administrator, or an
1749
+ * agent acting as them, gets redemption flags and their recoverable attempts.
1750
+ */
1751
+ async function coreOrganizationCodexOverview(
1752
+ c: Context,
1753
+ deps: ApiRouteDeps,
1754
+ organizationId: string,
1755
+ human: ManagedCookieHuman,
1756
+ rawCredentialId: string,
1757
+ ) {
1758
+ c.header("cache-control", "no-store");
1759
+ const mode = await codexRouteDisposition(deps, organizationId);
1760
+ const target = await organizationRedemptionTarget(deps, {
1761
+ organizationId,
1762
+ subjectId: human.subjectId,
1763
+ credentialId: rawCredentialId,
1764
+ });
1765
+ const credentialId = target.credentialId;
1766
+ const { accounts } = await getSubscriptionCoreOrganizationCodexProjection(deps.db, {
1767
+ organizationId,
1768
+ subjectId: human.subjectId,
1769
+ });
1770
+ const row = accounts.find((account) => account.id === credentialId);
1771
+ if (!row) throw new HTTPException(404, { message: "codex account not found" });
1772
+ const browser = isAgentActingAsPerson(c) ? human : await managedCookieHuman(c, deps);
1773
+ const browserHuman =
1774
+ deps.settings.productAccessMode === "managed" && browser?.subjectId === human.subjectId
1775
+ ? browser
1776
+ : null;
1777
+ const workspaceId = browserHuman
1778
+ ? await resolveOrganizationCodexRedemptionWorkspace(deps.db, {
1779
+ organizationId,
1780
+ actorSubjectId: browserHuman.subjectId,
1781
+ })
1782
+ : null;
1783
+ const authority =
1784
+ browserHuman && workspaceId
1785
+ ? await asRedemptionHuman(browserHuman, () =>
1786
+ readSubscriptionCoreCodexResetAuthority(deps.db, {
1787
+ accountId: organizationId,
1788
+ workspaceId,
1789
+ credentialId,
1790
+ subjectId: browserHuman.subjectId,
1791
+ }),
1792
+ ).catch(() => null)
1793
+ : null;
1794
+ const canResumeRedemption = authority?.owned === true;
1795
+ const redemptions =
1796
+ canResumeRedemption && browserHuman && workspaceId
1797
+ ? (
1798
+ await asRedemptionHuman(browserHuman, () =>
1799
+ listSubscriptionCoreCodexResetRedemptionRecoveries(deps.db, {
1800
+ accountId: organizationId,
1801
+ workspaceId,
1802
+ subjectId: browserHuman.subjectId,
1803
+ }),
1804
+ )
1805
+ ).filter((recovery) => recovery.credentialId === credentialId)
1806
+ : [];
1807
+ const redemptionAccess: CodexRedemptionAccess = {
1808
+ ownership: !browserHuman
1809
+ ? "managed_human_unavailable"
1810
+ : canResumeRedemption
1811
+ ? "current_human"
1812
+ : "different_human",
1813
+ canClaimUnownedViaReconnect: false,
1814
+ };
1815
+ const sources: CodexOverviewSources = {
1816
+ usage: () =>
1817
+ fetchOrganizationCodexUsageForAccount(
1818
+ deps.db,
1819
+ deps.settings,
1820
+ { organizationId, actorSubjectId: human.subjectId, credentialId, mode },
1821
+ deps.codexFetch ?? fetch,
1822
+ ),
1823
+ details: async () => {
1824
+ const provider = target.provider();
1825
+ let token;
1826
+ try {
1827
+ token = await provider.getToken();
1828
+ } catch (error) {
1829
+ return {
1830
+ ok: false as const,
1831
+ status: 0,
1832
+ reason:
1833
+ error instanceof CodexReloginRequired
1834
+ ? ("needs_relogin" as const)
1835
+ : ("network_error" as const),
1836
+ };
2002
1837
  }
1838
+ return await fetchCodexRateLimitResetCredits(
1839
+ {
1840
+ accessToken: token.accessToken,
1841
+ chatgptAccountId: token.chatgptAccountId,
1842
+ isFedramp: token.isFedramp,
1843
+ clientVersion: CODEX_CLIENT_VERSION,
1844
+ },
1845
+ provider.fetch,
1846
+ );
1847
+ },
1848
+ };
1849
+ return c.json(
1850
+ await fetchCodexAccountOverview(
1851
+ deps,
1852
+ workspaceId ?? organizationId,
1853
+ row,
1854
+ redemptionAccess,
1855
+ canResumeRedemption && row.status === "active",
1856
+ canResumeRedemption,
1857
+ redemptions,
1858
+ createProviderCallLimiter(2),
1859
+ sources,
1860
+ ),
1861
+ );
1862
+ }
2003
1863
 
2004
- const claimHolderId = crypto.randomUUID();
2005
- const claimed = await claimCodexResetRedemption(db, {
2006
- id: parsed.data.attemptId,
1864
+ function coreCodexConnectionScope(
1865
+ input: { accountId: string; workspaceId: string },
1866
+ subjectId: string,
1867
+ ) {
1868
+ return {
1869
+ kind: "workspace" as const,
1870
+ accountId: input.accountId,
1871
+ workspaceId: input.workspaceId,
1872
+ subjectId,
1873
+ };
1874
+ }
1875
+
1876
+ async function coreCodexResetPrepare(
1877
+ c: Context,
1878
+ deps: ApiRouteDeps,
1879
+ input: CoreRedemptionInput,
1880
+ resolveTarget: () => Promise<RedemptionTarget> = () => workspaceRedemptionTarget(deps, input),
1881
+ ) {
1882
+ c.header("cache-control", "no-store");
1883
+ const parsed = redemptionPrepareBody.safeParse(await c.req.json().catch(() => null));
1884
+ if (!parsed.success) {
1885
+ throw new HTTPException(400, { message: "attemptId and creditId are required" });
1886
+ }
1887
+ const { human, accountId, workspaceId } = input;
1888
+ const { credentialId, organizationManaged } = await resolveTarget();
1889
+ return await asRedemptionHuman(human, async () => {
1890
+ const authority = await readSubscriptionCoreCodexResetAuthority(deps.db, {
1891
+ accountId,
1892
+ workspaceId,
1893
+ credentialId,
1894
+ subjectId: human.subjectId,
1895
+ });
1896
+ if (organizationManaged && !authority?.owned) organizationManagedRedemption();
1897
+ if (!authority) throw new HTTPException(404, { message: "codex account not found" });
1898
+ let existing = await getCodexResetRedemptionAttempt(
1899
+ deps.db,
1900
+ workspaceId,
1901
+ parsed.data.attemptId,
1902
+ );
1903
+ if (!existing && authority.owned) {
1904
+ // The same person's attempt filed in another workspace (an
1905
+ // organization account redeemed from several workspaces, or a ledger
1906
+ // row the cutover kept in its legacy workspace) is moved here so it is
1907
+ // recovered on its one upstream key; another workspace's hold on this
1908
+ // credit refuses a second logical redemption.
1909
+ const fence = await fenceSubscriptionCoreCodexResetCredit(deps.db, {
2007
1910
  accountId,
2008
1911
  workspaceId,
2009
1912
  credentialId,
2010
1913
  subjectId: human.subjectId,
2011
- browserSessionHash: human.browserSessionHash,
2012
1914
  creditId: parsed.data.creditId,
2013
- confirmationExpiresAt: new Date(claims.expiresAt * 1000),
2014
- claimHolderId,
1915
+ attemptId: parsed.data.attemptId,
2015
1916
  });
2016
- if (claimed.kind === "not_found") {
2017
- throw new HTTPException(404, { message: "codex account not found" });
2018
- }
2019
- if (claimed.kind === "forbidden") {
2020
- throw new HTTPException(403, {
2021
- message: "redemption owner or credential is unavailable",
1917
+ if (fence === "held_elsewhere") {
1918
+ throw new HTTPException(409, {
1919
+ message: "this reset credit is already being redeemed from another workspace",
2022
1920
  });
2023
1921
  }
2024
- if (claimed.kind === "conflict") {
1922
+ if (fence === "refiled") {
1923
+ existing = await getCodexResetRedemptionAttempt(
1924
+ deps.db,
1925
+ workspaceId,
1926
+ parsed.data.attemptId,
1927
+ );
1928
+ }
1929
+ }
1930
+ if (!authority.owned) {
1931
+ throw new HTTPException(403, {
1932
+ message:
1933
+ "only an organization administrator or an administrator of the workspace that manages this subscription may redeem its reset credits",
1934
+ });
1935
+ }
1936
+ if (
1937
+ existing &&
1938
+ (existing.credentialId !== credentialId ||
1939
+ existing.creditId !== parsed.data.creditId ||
1940
+ existing.subjectId !== human.subjectId)
1941
+ ) {
1942
+ throw new HTTPException(409, { message: "logical redemption attempt identity mismatch" });
1943
+ }
1944
+ if (existing) {
1945
+ const adoption = await adoptCodexResetRedemptionAttempt(
1946
+ deps.db,
1947
+ {
1948
+ accountId,
1949
+ workspaceId,
1950
+ attemptId: existing.id,
1951
+ credentialId,
1952
+ creditId: existing.creditId,
1953
+ subjectId: human.subjectId,
1954
+ browserSessionHash: human.browserSessionHash,
1955
+ },
1956
+ subscriptionCoreCodexResetAuthority,
1957
+ );
1958
+ if (adoption.kind === "in_progress") {
2025
1959
  throw new HTTPException(409, {
2026
- message: "logical redemption attempt identity mismatch",
1960
+ message: "this redemption is still in progress in another browser request",
2027
1961
  });
2028
1962
  }
2029
- if (claimed.kind === "in_progress") {
2030
- return c.json({ status: "in_progress", attemptId: parsed.data.attemptId }, 409);
1963
+ if (adoption.kind === "not_found") {
1964
+ throw new HTTPException(409, { message: "redemption recovery state changed" });
2031
1965
  }
2032
-
2033
- const finishResponse = (outcome: string) =>
2034
- c.json({
2035
- status: "completed",
2036
- attemptId: parsed.data.attemptId,
2037
- outcome,
2038
- // Durable provider truth must never wait for best-effort provider
2039
- // readback. The browser refreshes overview independently after this
2040
- // response; a hung account cannot suppress a completed outcome.
2041
- overview: null,
2042
- });
2043
- if (claimed.kind === "completed") {
2044
- return finishResponse(claimed.attempt.outcome!);
1966
+ if (adoption.kind === "forbidden") {
1967
+ throw new HTTPException(403, { message: "redemption owner is unavailable" });
1968
+ }
1969
+ if (adoption.kind === "conflict") {
1970
+ throw new HTTPException(409, { message: "logical redemption attempt identity mismatch" });
2045
1971
  }
1972
+ existing = adoption.attempt;
1973
+ }
1974
+ if (authority.status !== "active" && existing?.status !== "completed") {
1975
+ throw new HTTPException(403, { message: "redemption credential is unavailable" });
1976
+ }
1977
+ const secret = deps.settings.betterAuthSecret;
1978
+ if (!secret) {
1979
+ throw new HTTPException(503, { message: "managed browser confirmation is unavailable" });
1980
+ }
1981
+ const expiresAt = Math.floor(Date.now() / 1000) + CODEX_REDEMPTION_CONFIRMATION_SECONDS;
1982
+ const confirmationToken = await signCodexRedemptionConfirmation(secret, {
1983
+ version: 1,
1984
+ attemptId: parsed.data.attemptId,
1985
+ workspaceId,
1986
+ credentialId,
1987
+ creditId: parsed.data.creditId,
1988
+ subjectId: human.subjectId,
1989
+ browserSessionHash: human.browserSessionHash,
1990
+ expiresAt,
1991
+ });
1992
+ return c.json({
1993
+ attemptId: parsed.data.attemptId,
1994
+ confirmationToken,
1995
+ expiresAt: new Date(expiresAt * 1000).toISOString(),
1996
+ resumable: existing?.status === "provider_started" || existing?.status === "completed",
1997
+ recoveryStatus:
1998
+ existing?.status === "provider_started" || existing?.status === "completed"
1999
+ ? existing.status
2000
+ : null,
2001
+ });
2002
+ });
2003
+ }
2046
2004
 
2047
- const attempt = claimed.attempt;
2048
- const fetchImpl = (deps.codexFetch ?? fetch) as CodexFetch;
2005
+ async function coreCodexResetRedeem(
2006
+ c: Context,
2007
+ deps: ApiRouteDeps,
2008
+ input: CoreRedemptionInput,
2009
+ resolveTarget: () => Promise<RedemptionTarget> = () => workspaceRedemptionTarget(deps, input),
2010
+ ) {
2011
+ c.header("cache-control", "no-store");
2012
+ const parsed = redemptionBody.safeParse(await c.req.json().catch(() => null));
2013
+ if (!parsed.success) {
2014
+ throw new HTTPException(400, { message: "explicit redemption confirmation is required" });
2015
+ }
2016
+ const secret = deps.settings.betterAuthSecret;
2017
+ if (!secret) {
2018
+ throw new HTTPException(503, { message: "managed browser confirmation is unavailable" });
2019
+ }
2020
+ const { human, accountId, workspaceId } = input;
2021
+ const target = await resolveTarget();
2022
+ const { credentialId } = target;
2023
+ const claims = await verifyCodexRedemptionConfirmation(secret, parsed.data.confirmationToken);
2024
+ if (
2025
+ !claims ||
2026
+ claims.attemptId !== parsed.data.attemptId ||
2027
+ claims.workspaceId !== workspaceId ||
2028
+ claims.credentialId !== credentialId ||
2029
+ claims.creditId !== parsed.data.creditId ||
2030
+ claims.subjectId !== human.subjectId ||
2031
+ claims.browserSessionHash !== human.browserSessionHash
2032
+ ) {
2033
+ throw new HTTPException(403, { message: "redemption confirmation is invalid or expired" });
2034
+ }
2035
+ const db = deps.db;
2036
+ return await asRedemptionHuman(human, async () => {
2037
+ const claimHolderId = crypto.randomUUID();
2038
+ const claimed = await claimCodexResetRedemption(
2039
+ db,
2040
+ {
2041
+ id: parsed.data.attemptId,
2042
+ accountId,
2043
+ workspaceId,
2044
+ credentialId,
2045
+ subjectId: human.subjectId,
2046
+ browserSessionHash: human.browserSessionHash,
2047
+ creditId: parsed.data.creditId,
2048
+ confirmationExpiresAt: new Date(claims.expiresAt * 1000),
2049
+ claimHolderId,
2050
+ },
2051
+ subscriptionCoreCodexResetAuthority,
2052
+ subscriptionCoreCodexResetCreditFence,
2053
+ );
2054
+ if (claimed.kind === "not_found") {
2055
+ throw new HTTPException(404, { message: "codex account not found" });
2056
+ }
2057
+ if (claimed.kind === "forbidden") {
2058
+ if (target.organizationManaged) organizationManagedRedemption();
2059
+ throw new HTTPException(403, { message: "redemption owner or credential is unavailable" });
2060
+ }
2061
+ if (claimed.kind === "conflict") {
2062
+ throw new HTTPException(409, { message: "logical redemption attempt identity mismatch" });
2063
+ }
2064
+ if (claimed.kind === "in_progress") {
2065
+ return c.json({ status: "in_progress", attemptId: parsed.data.attemptId }, 409);
2066
+ }
2067
+ const finishResponse = (outcome: string) =>
2068
+ c.json({ status: "completed", attemptId: parsed.data.attemptId, outcome, overview: null });
2069
+ if (claimed.kind === "completed") return finishResponse(claimed.attempt.outcome!);
2070
+
2071
+ const attempt = claimed.attempt;
2072
+ const ledger = { accountId, workspaceId, attemptId: attempt.id, claimHolderId };
2073
+ const provider = target.provider();
2074
+ let token: Awaited<ReturnType<typeof provider.getToken>>;
2075
+ try {
2076
+ token = await provider.getToken();
2077
+ } catch {
2049
2078
  if (attempt.status === "processing") {
2050
- const details = await fetchCodexRateLimitResetCreditsForAccount(
2051
- db,
2052
- settings,
2053
- workspaceId,
2054
- credentialId,
2055
- fetchImpl,
2079
+ await abandonCodexResetRedemptionBeforeProvider(db, ledger);
2080
+ return c.json(
2081
+ { status: "preflight_unavailable", attemptId: attempt.id, retryable: true },
2082
+ 503,
2056
2083
  );
2057
- if (!details.ok) {
2058
- await abandonCodexResetRedemptionBeforeProvider(db, {
2059
- accountId,
2060
- workspaceId,
2061
- attemptId: attempt.id,
2062
- claimHolderId,
2063
- });
2064
- return c.json(
2065
- {
2066
- status: "preflight_unavailable",
2067
- attemptId: attempt.id,
2068
- retryable: true,
2069
- },
2070
- 503,
2071
- );
2072
- }
2073
- if (!freshActionableCredit(details.details, attempt.creditId)) {
2074
- await abandonCodexResetRedemptionBeforeProvider(db, {
2075
- accountId,
2076
- workspaceId,
2077
- attemptId: attempt.id,
2078
- claimHolderId,
2079
- });
2080
- return c.json(
2081
- {
2082
- status: "not_actionable",
2083
- attemptId: attempt.id,
2084
- retryable: false,
2085
- },
2086
- 409,
2087
- );
2088
- }
2089
2084
  }
2090
-
2091
- let token: Awaited<ReturnType<ReturnType<typeof buildCodexTokenResolver>["getToken"]>>;
2092
- try {
2093
- token = await buildCodexTokenResolver(db, settings, workspaceId, credentialId).getToken();
2094
- } catch {
2095
- if (attempt.status === "processing") {
2096
- await abandonCodexResetRedemptionBeforeProvider(db, {
2097
- accountId,
2098
- workspaceId,
2099
- attemptId: attempt.id,
2100
- claimHolderId,
2101
- });
2102
- } else {
2103
- await releaseCodexResetRedemptionClaim(db, {
2104
- accountId,
2105
- workspaceId,
2106
- attemptId: attempt.id,
2107
- claimHolderId,
2108
- failureKind: "provider_auth_unavailable",
2109
- });
2110
- }
2085
+ await releaseCodexResetRedemptionClaim(db, {
2086
+ ...ledger,
2087
+ failureKind: "provider_auth_unavailable",
2088
+ });
2089
+ return c.json(
2090
+ { status: "provider_unavailable", attemptId: attempt.id, retryable: true },
2091
+ 503,
2092
+ );
2093
+ }
2094
+ const auth = {
2095
+ accessToken: token.accessToken,
2096
+ chatgptAccountId: token.chatgptAccountId,
2097
+ isFedramp: token.isFedramp,
2098
+ clientVersion: CODEX_CLIENT_VERSION,
2099
+ };
2100
+ const requestFetch = provider.fetch;
2101
+ if (attempt.status === "processing") {
2102
+ const details = await fetchCodexRateLimitResetCredits(auth, requestFetch);
2103
+ if (!details.ok) {
2104
+ await abandonCodexResetRedemptionBeforeProvider(db, ledger);
2111
2105
  return c.json(
2112
- {
2113
- status: "provider_unavailable",
2114
- attemptId: attempt.id,
2115
- retryable: true,
2116
- },
2106
+ { status: "preflight_unavailable", attemptId: attempt.id, retryable: true },
2117
2107
  503,
2118
2108
  );
2119
2109
  }
2120
- const fenced = await fenceCodexResetRedemptionSend(db, {
2121
- accountId,
2122
- workspaceId,
2123
- attemptId: attempt.id,
2124
- claimHolderId,
2110
+ if (!freshActionableCredit(details.details, attempt.creditId)) {
2111
+ await abandonCodexResetRedemptionBeforeProvider(db, ledger);
2112
+ return c.json({ status: "not_actionable", attemptId: attempt.id, retryable: false }, 409);
2113
+ }
2114
+ }
2115
+ const fenced = await fenceCodexResetRedemptionSend(
2116
+ db,
2117
+ {
2118
+ ...ledger,
2125
2119
  credentialId,
2126
2120
  subjectId: human.subjectId,
2127
2121
  browserSessionHash: human.browserSessionHash,
2122
+ },
2123
+ subscriptionCoreCodexResetAuthority,
2124
+ );
2125
+ if (fenced.kind !== "ready") {
2126
+ if (fenced.reason === "confirmation_expired") {
2127
+ return c.json(
2128
+ { status: "confirmation_expired", attemptId: attempt.id, retryable: true },
2129
+ 403,
2130
+ );
2131
+ }
2132
+ if (fenced.reason === "credential_unavailable") {
2133
+ return c.json(
2134
+ { status: "provider_unavailable", attemptId: attempt.id, retryable: true },
2135
+ 503,
2136
+ );
2137
+ }
2138
+ return c.json({ status: "in_progress", attemptId: attempt.id }, 409);
2139
+ }
2140
+ const sendAttempt = fenced.attempt;
2141
+ // The one upstream idempotency key of this logical attempt: an ambiguous
2142
+ // send is recovered with the same key, never reissued with a new one.
2143
+ const consumed = await consumeCodexRateLimitResetCredit(
2144
+ auth,
2145
+ { idempotencyKey: sendAttempt.upstreamIdempotencyKey, creditId: sendAttempt.creditId },
2146
+ requestFetch,
2147
+ );
2148
+ if (!consumed.ok) {
2149
+ await releaseCodexResetRedemptionClaim(db, {
2150
+ ...ledger,
2151
+ failureKind: `provider_${consumed.reason}`,
2128
2152
  });
2129
- if (fenced.kind !== "ready") {
2130
- if (fenced.reason === "confirmation_expired") {
2131
- return c.json(
2132
- {
2133
- status: "confirmation_expired",
2134
- attemptId: attempt.id,
2135
- retryable: true,
2136
- },
2137
- 403,
2138
- );
2139
- }
2140
- if (fenced.reason === "credential_unavailable") {
2141
- return c.json(
2142
- {
2143
- status: "provider_unavailable",
2144
- attemptId: attempt.id,
2145
- retryable: true,
2146
- },
2147
- 503,
2148
- );
2153
+ return c.json({ status: "ambiguous", attemptId: attempt.id, retryable: true }, 503);
2154
+ }
2155
+ const completion = await completeSubscriptionCoreCodexResetRedemption(db, {
2156
+ ...ledger,
2157
+ outcome: consumed.result.outcome,
2158
+ });
2159
+ if (!completion.attempt) {
2160
+ return c.json({ status: "in_progress", attemptId: attempt.id }, 409);
2161
+ }
2162
+ void deliverSubscriptionCoreCodexWake(db, completion.wake).catch(() => undefined);
2163
+ return finishResponse(completion.attempt.outcome!);
2164
+ });
2165
+ }
2166
+
2167
+ /**
2168
+ * The overview on the core: per-account usage and reset details settle
2169
+ * independently through the connection seam, with at most four provider
2170
+ * calls at a time and the legacy route deadline. Redemption flags follow the
2171
+ * 6.3 authority for the person in their browser or an agent acting as them.
2172
+ */
2173
+ async function coreCodexOverview(
2174
+ c: Context,
2175
+ deps: ApiRouteDeps,
2176
+ grant: Awaited<ReturnType<typeof requireAccessGrant>>,
2177
+ workspaceId: string,
2178
+ ) {
2179
+ const human = await managedHumanOrAgent(c, deps);
2180
+ const browserHuman =
2181
+ human &&
2182
+ human.subjectId === grant.subjectId &&
2183
+ hasPermission(grant.permissions, "connections:write")
2184
+ ? human
2185
+ : null;
2186
+ const scope = coreCodexConnectionScope(
2187
+ { accountId: grant.accountId, workspaceId },
2188
+ grant.subjectId,
2189
+ );
2190
+ const { accounts } = await getSubscriptionCoreCodexWorkspaceProjection(deps.db, {
2191
+ accountId: grant.accountId,
2192
+ workspaceId,
2193
+ });
2194
+ const recoveries = browserHuman
2195
+ ? await asRedemptionHuman(browserHuman, () =>
2196
+ listSubscriptionCoreCodexResetRedemptionRecoveries(deps.db, {
2197
+ accountId: grant.accountId,
2198
+ workspaceId,
2199
+ subjectId: browserHuman.subjectId,
2200
+ }),
2201
+ )
2202
+ : [];
2203
+ const fetchImpl = (deps.codexFetch ?? fetch) as CodexFetch;
2204
+ const sources: CodexOverviewSources = {
2205
+ usage: async (connectionId) => {
2206
+ const { usage, recovered } = await fetchSubscriptionCoreCodexUsage(
2207
+ deps.db,
2208
+ deps.settings,
2209
+ scope,
2210
+ connectionId,
2211
+ fetchImpl,
2212
+ );
2213
+ if (recovered) {
2214
+ void deliverSubscriptionCoreCodexWake(deps.db, {
2215
+ accountId: grant.accountId,
2216
+ reason: "usage_recovered",
2217
+ }).catch(() => undefined);
2218
+ }
2219
+ return usage;
2220
+ },
2221
+ details: async (connectionId) => {
2222
+ let token;
2223
+ try {
2224
+ token = await buildSubscriptionCoreCodexConnectionTokenResolver(
2225
+ deps.db,
2226
+ deps.settings,
2227
+ scope,
2228
+ connectionId,
2229
+ null,
2230
+ ).getToken();
2231
+ } catch (error) {
2232
+ // Not readable in this workspace context: reset details are not
2233
+ // available here (reported as unsupported, not as a failure).
2234
+ if (error instanceof SubscriptionCoreCodexOperationUnavailableError) {
2235
+ return { ok: false as const, status: 404, reason: "http_error" as const };
2149
2236
  }
2150
- return c.json({ status: "in_progress", attemptId: attempt.id }, 409);
2237
+ return {
2238
+ ok: false as const,
2239
+ status: 0,
2240
+ reason:
2241
+ error instanceof CodexReloginRequired
2242
+ ? ("needs_relogin" as const)
2243
+ : ("network_error" as const),
2244
+ };
2151
2245
  }
2152
-
2153
- const sendAttempt = fenced.attempt;
2154
- const consumed = await consumeCodexRateLimitResetCredit(
2246
+ return await fetchCodexRateLimitResetCredits(
2155
2247
  {
2156
2248
  accessToken: token.accessToken,
2157
2249
  chatgptAccountId: token.chatgptAccountId,
2158
2250
  isFedramp: token.isFedramp,
2159
2251
  clientVersion: CODEX_CLIENT_VERSION,
2160
2252
  },
2161
- {
2162
- idempotencyKey: sendAttempt.upstreamIdempotencyKey,
2163
- creditId: sendAttempt.creditId,
2164
- },
2165
- fetchImpl,
2253
+ buildSubscriptionCoreCodexOperationFetch(deps.db, scope, null, connectionId, fetchImpl),
2166
2254
  );
2167
- if (!consumed.ok) {
2168
- await releaseCodexResetRedemptionClaim(db, {
2169
- accountId,
2170
- workspaceId,
2171
- attemptId: attempt.id,
2172
- claimHolderId,
2173
- failureKind: `provider_${consumed.reason}`,
2174
- });
2175
- return c.json({ status: "ambiguous", attemptId: attempt.id, retryable: true }, 503);
2176
- }
2177
- const completion = await completeCodexResetRedemption(db, {
2178
- accountId,
2179
- workspaceId,
2180
- attemptId: attempt.id,
2181
- claimHolderId,
2182
- outcome: consumed.result.outcome,
2183
- });
2184
- const completed = completion.result;
2185
- if (!completed) {
2186
- return c.json({ status: "in_progress", attemptId: attempt.id }, 409);
2187
- }
2188
- // The outbox is durable; signaling is best-effort and must not hold the
2189
- // owning human's already-completed provider outcome hostage.
2190
- void signalCodexCapacityTargets(deps, completion.wakeTargets).catch(() => undefined);
2191
- return finishResponse(completed.outcome!);
2192
2255
  },
2256
+ };
2257
+ const accessFor = async (account: CodexAccountStatus) => {
2258
+ // Organization accounts are redeemable by an organization administrator
2259
+ // (M3 PR 3b); the SQL authority decides for both sources.
2260
+ const authority = browserHuman
2261
+ ? await asRedemptionHuman(browserHuman, () =>
2262
+ readSubscriptionCoreCodexResetAuthority(deps.db, {
2263
+ accountId: grant.accountId,
2264
+ workspaceId,
2265
+ credentialId: account.id,
2266
+ subjectId: browserHuman.subjectId,
2267
+ }),
2268
+ ).catch(() => null)
2269
+ : null;
2270
+ const canResumeRedemption = authority?.owned === true;
2271
+ const redemptionAccess: CodexRedemptionAccess =
2272
+ !browserHuman || (account.source === "organization" && !canResumeRedemption)
2273
+ ? { ownership: "managed_human_unavailable", canClaimUnownedViaReconnect: false }
2274
+ : {
2275
+ ownership: canResumeRedemption ? "current_human" : "different_human",
2276
+ canClaimUnownedViaReconnect: false,
2277
+ };
2278
+ return {
2279
+ redemptionAccess,
2280
+ canRedeem: canResumeRedemption && account.status === "active",
2281
+ canResumeRedemption,
2282
+ redemptions: canResumeRedemption
2283
+ ? recoveries.filter((recovery) => recovery.credentialId === account.id)
2284
+ : [],
2285
+ };
2286
+ };
2287
+ const overview: Record<string, Awaited<ReturnType<typeof fetchCodexAccountOverview>>> = {};
2288
+ const queue = [...accounts];
2289
+ const providerCall = createProviderCallLimiter(4);
2290
+ let routeTimedOut = false;
2291
+ const worker = async (): Promise<void> => {
2292
+ for (;;) {
2293
+ if (routeTimedOut) return;
2294
+ const account = queue.shift();
2295
+ if (!account) return;
2296
+ const access = await accessFor(account);
2297
+ overview[account.id] = await fetchCodexAccountOverview(
2298
+ deps,
2299
+ workspaceId,
2300
+ account,
2301
+ access.redemptionAccess,
2302
+ access.canRedeem,
2303
+ access.canResumeRedemption,
2304
+ access.redemptions,
2305
+ providerCall,
2306
+ sources,
2307
+ );
2308
+ }
2309
+ };
2310
+ const workers = Promise.all(
2311
+ Array.from({ length: Math.min(4, Math.max(1, accounts.length)) }, () => worker()),
2193
2312
  );
2313
+ let deadline: ReturnType<typeof setTimeout> | undefined;
2314
+ await Promise.race([
2315
+ workers,
2316
+ new Promise<void>((resolve) => {
2317
+ deadline = setTimeout(() => {
2318
+ routeTimedOut = true;
2319
+ queue.length = 0;
2320
+ resolve();
2321
+ }, CODEX_OVERVIEW_ROUTE_TIMEOUT_MS);
2322
+ }),
2323
+ ]);
2324
+ if (deadline) clearTimeout(deadline);
2325
+ if (routeTimedOut) {
2326
+ // Unscheduled accounts come from the core's persisted quota only.
2327
+ const unavailableProviderCall: CodexProviderCall = async () => {
2328
+ throw new Error("Codex overview route deadline reached");
2329
+ };
2330
+ await Promise.all(
2331
+ accounts
2332
+ .filter((account) => overview[account.id] == null)
2333
+ .map(async (account) => {
2334
+ const access = await accessFor(account);
2335
+ const fallback = await fetchCodexAccountOverview(
2336
+ deps,
2337
+ workspaceId,
2338
+ account,
2339
+ access.redemptionAccess,
2340
+ false,
2341
+ access.canResumeRedemption,
2342
+ access.redemptions,
2343
+ unavailableProviderCall,
2344
+ sources,
2345
+ );
2346
+ overview[account.id] ??= fallback;
2347
+ }),
2348
+ );
2349
+ void workers.catch(() => undefined);
2350
+ }
2351
+ return c.json({ accounts: overview });
2194
2352
  }