@ixo/oracle-runtime-workers 0.2.0

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 (215) hide show
  1. package/README.md +110 -0
  2. package/package.json +77 -0
  3. package/src/attachments/classify.ts +114 -0
  4. package/src/attachments/content-blocks.ts +51 -0
  5. package/src/attachments/download.ts +235 -0
  6. package/src/attachments/extract.ts +241 -0
  7. package/src/attachments/index.ts +84 -0
  8. package/src/attachments/magic.ts +133 -0
  9. package/src/attachments/pipeline.ts +409 -0
  10. package/src/attachments/retention.ts +205 -0
  11. package/src/attachments/route.ts +41 -0
  12. package/src/attachments/sandbox-archive.ts +90 -0
  13. package/src/attachments/sanitize.ts +35 -0
  14. package/src/attachments/types.ts +86 -0
  15. package/src/attachments/view.ts +107 -0
  16. package/src/core/env.ts +200 -0
  17. package/src/core/index.ts +513 -0
  18. package/src/core/llm.ts +842 -0
  19. package/src/core/main-agent-types.ts +123 -0
  20. package/src/core/main-agent.ts +399 -0
  21. package/src/core/manifest.ts +365 -0
  22. package/src/core/meta-tools.ts +232 -0
  23. package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
  24. package/src/core/middlewares/capability-gate.ts +76 -0
  25. package/src/core/middlewares/index.ts +18 -0
  26. package/src/core/middlewares/page-context.ts +98 -0
  27. package/src/core/middlewares/safety-guardrail.ts +117 -0
  28. package/src/core/middlewares/summarization.ts +99 -0
  29. package/src/core/middlewares/tool-repetition-guard.ts +125 -0
  30. package/src/core/middlewares/tool-validation.ts +83 -0
  31. package/src/core/middlewares/work-status.ts +57 -0
  32. package/src/core/openrouter-pricing.ts +124 -0
  33. package/src/core/plugin-loader.ts +192 -0
  34. package/src/core/plugins/index.ts +6 -0
  35. package/src/core/plugins/skills/index.ts +8 -0
  36. package/src/core/plugins/skills/skills-tools.ts +356 -0
  37. package/src/core/plugins/skills/skills.plugin.ts +113 -0
  38. package/src/core/plugins/weather/index.ts +8 -0
  39. package/src/core/plugins/weather/weather-client.ts +153 -0
  40. package/src/core/plugins/weather/weather-middleware.ts +27 -0
  41. package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
  42. package/src/core/plugins/weather/weather-tools.ts +103 -0
  43. package/src/core/plugins/weather/weather.plugin.ts +227 -0
  44. package/src/core/prompt-composer.ts +469 -0
  45. package/src/core/registries.ts +642 -0
  46. package/src/core/runtime-context.ts +619 -0
  47. package/src/core/state.ts +108 -0
  48. package/src/core/sub-agent-fallback.ts +187 -0
  49. package/src/core/subagent-as-tool.ts +322 -0
  50. package/src/core/template.ts +245 -0
  51. package/src/core/utils.ts +246 -0
  52. package/src/core/wrap-plugin-tool.ts +100 -0
  53. package/src/do/ambient.ts +266 -0
  54. package/src/do/contracts.ts +562 -0
  55. package/src/do/debug-timers.ts +120 -0
  56. package/src/do/fresh-stub.ts +32 -0
  57. package/src/do/gateway-retry.ts +73 -0
  58. package/src/do/secrets-adapter.ts +32 -0
  59. package/src/do/size-alerts.ts +76 -0
  60. package/src/do/sse-stream.ts +472 -0
  61. package/src/do/transcript.ts +192 -0
  62. package/src/do/turn-body.ts +84 -0
  63. package/src/do/turn-metadata.ts +146 -0
  64. package/src/do/ucan-service.ts +339 -0
  65. package/src/do/user-oracle-do.ts +3035 -0
  66. package/src/gateway-worker.ts +82 -0
  67. package/src/index.ts +237 -0
  68. package/src/llm/byo-adapter.ts +59 -0
  69. package/src/llm/byo-catalog.ts +520 -0
  70. package/src/llm/byo-client.ts +280 -0
  71. package/src/llm/byo-routes.ts +255 -0
  72. package/src/llm/byo-service.ts +836 -0
  73. package/src/llm/chatgpt-oauth.ts +430 -0
  74. package/src/llm/provider-error.ts +283 -0
  75. package/src/matrix/gateway-do.ts +918 -0
  76. package/src/matrix/ingest.ts +209 -0
  77. package/src/matrix/replay-format.ts +34 -0
  78. package/src/matrix/reply-chain.ts +109 -0
  79. package/src/matrix/room-state-codec.ts +115 -0
  80. package/src/matrix/user-homeserver.ts +106 -0
  81. package/src/matrix/work-status.ts +294 -0
  82. package/src/memory/session-history-indexer.ts +328 -0
  83. package/src/owner-store/ixo-vfs-store.ts +570 -0
  84. package/src/owner-store/matrix-media-store.ts +136 -0
  85. package/src/owner-store/migrating-store.ts +185 -0
  86. package/src/owner-store/owner-copy-errors.ts +175 -0
  87. package/src/owner-store/retry.ts +45 -0
  88. package/src/owner-store/tus-upload.ts +333 -0
  89. package/src/owner-store/types.ts +0 -0
  90. package/src/plugin-api/define-plugin.ts +45 -0
  91. package/src/plugin-api/index.ts +56 -0
  92. package/src/plugin-api/oracle-plugin.ts +145 -0
  93. package/src/plugin-api/tool-helper.ts +82 -0
  94. package/src/plugin-api/types.ts +816 -0
  95. package/src/plugin-api/ucan-errors.ts +16 -0
  96. package/src/plugins/agui/agui-agent.ts +115 -0
  97. package/src/plugins/agui/agui.plugin.ts +148 -0
  98. package/src/plugins/agui/index.ts +2 -0
  99. package/src/plugins/attachments/attachments.plugin.ts +54 -0
  100. package/src/plugins/attachments/index.ts +6 -0
  101. package/src/plugins/attachments/view-attachment-tool.ts +91 -0
  102. package/src/plugins/base64.ts +26 -0
  103. package/src/plugins/composio/composio-tools.ts +365 -0
  104. package/src/plugins/composio/composio-ucan.ts +43 -0
  105. package/src/plugins/composio/composio.plugin.ts +214 -0
  106. package/src/plugins/composio/index.ts +15 -0
  107. package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
  108. package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
  109. package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
  110. package/src/plugins/domain-indexer/index.ts +3 -0
  111. package/src/plugins/editor/blocknote-bridge.ts +120 -0
  112. package/src/plugins/editor/content-session.ts +331 -0
  113. package/src/plugins/editor/content-tools.ts +686 -0
  114. package/src/plugins/editor/document-model.ts +0 -0
  115. package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
  116. package/src/plugins/editor/editor-agent.ts +34 -0
  117. package/src/plugins/editor/editor-config.ts +64 -0
  118. package/src/plugins/editor/editor-mx.ts +128 -0
  119. package/src/plugins/editor/editor.plugin.ts +143 -0
  120. package/src/plugins/editor/failures.ts +154 -0
  121. package/src/plugins/editor/index.ts +31 -0
  122. package/src/plugins/editor/jsdom-shim.ts +55 -0
  123. package/src/plugins/editor/prompts.ts +59 -0
  124. package/src/plugins/editor/prop-policy.ts +147 -0
  125. package/src/plugins/editor/provider.ts +386 -0
  126. package/src/plugins/editor/room-membership.ts +95 -0
  127. package/src/plugins/editor/standalone-editor-tool.ts +200 -0
  128. package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
  129. package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
  130. package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
  131. package/src/plugins/firecrawl/index.ts +15 -0
  132. package/src/plugins/flows/action-metadata.ts +3505 -0
  133. package/src/plugins/flows/actions.ts +190 -0
  134. package/src/plugins/flows/edit.ts +314 -0
  135. package/src/plugins/flows/errors.ts +38 -0
  136. package/src/plugins/flows/explain.ts +43 -0
  137. package/src/plugins/flows/flow-doc.ts +111 -0
  138. package/src/plugins/flows/flows.plugin.ts +129 -0
  139. package/src/plugins/flows/forms.ts +330 -0
  140. package/src/plugins/flows/index.ts +2 -0
  141. package/src/plugins/flows/linkage.ts +147 -0
  142. package/src/plugins/flows/port-types.ts +53 -0
  143. package/src/plugins/flows/prompts.ts +65 -0
  144. package/src/plugins/flows/read.ts +392 -0
  145. package/src/plugins/flows/references.ts +66 -0
  146. package/src/plugins/flows/test-support.ts +165 -0
  147. package/src/plugins/flows/tools/authoring.ts +516 -0
  148. package/src/plugins/flows/tools/discovery.ts +119 -0
  149. package/src/plugins/flows/tools/forms.ts +142 -0
  150. package/src/plugins/flows/tools/inspect.ts +165 -0
  151. package/src/plugins/flows/tools/linkage.ts +117 -0
  152. package/src/plugins/flows/tools/settings.ts +184 -0
  153. package/src/plugins/flows/translator.ts +328 -0
  154. package/src/plugins/flows/types.ts +210 -0
  155. package/src/plugins/flows/ydoc-helpers.ts +543 -0
  156. package/src/plugins/index.ts +70 -0
  157. package/src/plugins/mcp-call-timeout.ts +38 -0
  158. package/src/plugins/mcp-tool-adapter.ts +87 -0
  159. package/src/plugins/memory/index.ts +17 -0
  160. package/src/plugins/memory/memory-tools.ts +463 -0
  161. package/src/plugins/memory/memory-ucan.ts +51 -0
  162. package/src/plugins/memory/memory.plugin.ts +122 -0
  163. package/src/plugins/memory/types.ts +147 -0
  164. package/src/plugins/portal/action-log.ts +34 -0
  165. package/src/plugins/portal/index.ts +2 -0
  166. package/src/plugins/portal/portal.plugin.ts +203 -0
  167. package/src/plugins/sandbox/index.ts +34 -0
  168. package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
  169. package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
  170. package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
  171. package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
  172. package/src/plugins/tasks/index.ts +17 -0
  173. package/src/plugins/tasks/manifest.ts +81 -0
  174. package/src/plugins/tasks/middleware.ts +219 -0
  175. package/src/plugins/tasks/tasks-tools.ts +414 -0
  176. package/src/plugins/tasks/tasks.plugin.ts +74 -0
  177. package/src/plugins/ucan-failure.ts +133 -0
  178. package/src/plugins/user-preferences/index.ts +22 -0
  179. package/src/plugins/user-preferences/schema.ts +31 -0
  180. package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
  181. package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
  182. package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
  183. package/src/plugins/vfs/index.ts +40 -0
  184. package/src/plugins/vfs/vfs-auth.ts +56 -0
  185. package/src/plugins/vfs/vfs-client.ts +655 -0
  186. package/src/plugins/vfs/vfs-content.ts +181 -0
  187. package/src/plugins/vfs/vfs-errors.ts +163 -0
  188. package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
  189. package/src/plugins/vfs/vfs-tools.ts +635 -0
  190. package/src/plugins/vfs/vfs.plugin.ts +201 -0
  191. package/src/realtime/frontend-call-registry.ts +163 -0
  192. package/src/realtime/realtime-endpoint.ts +619 -0
  193. package/src/realtime/session-socket-hub.ts +249 -0
  194. package/src/realtime/socket-io-codec.ts +187 -0
  195. package/src/secrets/jwe.ts +74 -0
  196. package/src/secrets/pin-cipher.ts +43 -0
  197. package/src/secrets/secrets-service.ts +319 -0
  198. package/src/shell/app.ts +564 -0
  199. package/src/shell/auth.ts +290 -0
  200. package/src/sqlite/blob-codec.ts +100 -0
  201. package/src/sqlite/blob-compactor.ts +146 -0
  202. package/src/sqlite/cache-config.ts +45 -0
  203. package/src/sqlite/database.ts +643 -0
  204. package/src/sqlite/do-vfs.ts +1485 -0
  205. package/src/sqlite/index.ts +61 -0
  206. package/src/sqlite/serialization.ts +370 -0
  207. package/src/sqlite/sessions-store.ts +311 -0
  208. package/src/sqlite/sqlite-saver.ts +919 -0
  209. package/src/sqlite/vacuum-policy.ts +77 -0
  210. package/src/sqlite/wa-sqlite-loader.ts +123 -0
  211. package/src/sqlite/wasm.d.ts +10 -0
  212. package/src/tasks/schedule.ts +174 -0
  213. package/src/tasks/scheduler.ts +753 -0
  214. package/src/tasks/spec.ts +125 -0
  215. package/src/tasks/store.ts +351 -0
@@ -0,0 +1,430 @@
1
+ /**
2
+ * OAuth client for connecting a user's ChatGPT subscription — the Workers
3
+ * port of the Node runtime's `modules/byo-llm/chatgpt-oauth.ts` (the same
4
+ * public client the Codex CLI uses). Two connect paths:
5
+ *
6
+ * - **Device flow** (preferred): OpenAI's custom device-auth protocol (NOT
7
+ * RFC 8628) — start returns `{device_auth_id, user_code}`, the user enters
8
+ * the code at auth.openai.com/codex/device, and a successful poll returns
9
+ * an authorization code WITH a server-minted PKCE verifier, exchanged
10
+ * against the device-auth redirect URI.
11
+ * - **Pasted redirect** (fallback): classic authorization-code + PKCE where
12
+ * the browser dead-ends on the registered localhost redirect and the user
13
+ * pastes that URL back; the client mints the PKCE pair.
14
+ *
15
+ * Refresh notes: the token endpoint takes a JSON body for refresh (the
16
+ * auth-code exchange is form-encoded), all response fields are optional so
17
+ * results must be MERGED over the previous tokens, and refresh tokens rotate —
18
+ * `refresh_token_expired|reused|invalidated` are permanent failures that
19
+ * require a re-connect.
20
+ *
21
+ * Pure HTTP + parsing; no storage. `WorkersByoService` owns persistence and
22
+ * refresh scheduling. Crypto is bare WebCrypto (no `node:*`), which makes
23
+ * `createPkcePair` async — the only signature difference from the Node file.
24
+ */
25
+
26
+ import type { ChatGptOAuthTokens } from './byo-catalog';
27
+
28
+ export const DEFAULT_CHATGPT_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
29
+
30
+ const AUTH_BASE_URL = 'https://auth.openai.com';
31
+ const AUTHORIZE_URL = `${AUTH_BASE_URL}/oauth/authorize`;
32
+ const TOKEN_URL = `${AUTH_BASE_URL}/oauth/token`;
33
+ const DEVICE_USERCODE_URL = `${AUTH_BASE_URL}/api/accounts/deviceauth/usercode`;
34
+ const DEVICE_TOKEN_URL = `${AUTH_BASE_URL}/api/accounts/deviceauth/token`;
35
+
36
+ /** Where the user types the device user-code. */
37
+ export const DEVICE_VERIFICATION_URL = `${AUTH_BASE_URL}/codex/device`;
38
+
39
+ /** Redirect URI the device-flow authorization code is exchanged against. */
40
+ const DEVICE_REDIRECT_URI = `${AUTH_BASE_URL}/deviceauth/callback`;
41
+
42
+ /**
43
+ * The localhost redirect registered for the public client — the pasted-URL
44
+ * fallback dead-ends here after login and the user copies the address bar.
45
+ */
46
+ export const CHATGPT_REDIRECT_URI = 'http://localhost:1455/auth/callback';
47
+
48
+ const OAUTH_SCOPE =
49
+ 'openid profile email offline_access api.connectors.read api.connectors.invoke';
50
+
51
+ /**
52
+ * Refresh this many ms before the access token's nominal expiry (mirrors the
53
+ * Codex client's 5-minute window) so an in-flight turn never races expiry.
54
+ */
55
+ export const TOKEN_REFRESH_SKEW_MS = 300_000;
56
+
57
+ /** Device codes are client-capped at 15 minutes. */
58
+ export const DEVICE_FLOW_EXPIRES_IN_SEC = 900;
59
+
60
+ /**
61
+ * Hard timeout on every auth-endpoint call so a stalled upstream never pins
62
+ * a request (or a disconnect awaiting an in-flight refresh) for minutes.
63
+ */
64
+ const AUTH_HTTP_TIMEOUT_MS = 15_000;
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // base64url helpers (bare WebCrypto / no Buffer)
68
+ // ---------------------------------------------------------------------------
69
+
70
+ function base64UrlEncode(bytes: Uint8Array): string {
71
+ let binary = '';
72
+ for (const byte of bytes) binary += String.fromCharCode(byte);
73
+ return btoa(binary)
74
+ .replace(/\+/g, '-')
75
+ .replace(/\//g, '_')
76
+ .replace(/=+$/, '');
77
+ }
78
+
79
+ function base64UrlDecodeToString(segment: string): string | null {
80
+ try {
81
+ const b64 = segment.replace(/-/g, '+').replace(/_/g, '/');
82
+ const padded =
83
+ b64.length % 4 === 0 ? b64 : b64 + '='.repeat(4 - (b64.length % 4));
84
+ const binary = atob(padded);
85
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
86
+ return new TextDecoder().decode(bytes);
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ export interface PkcePair {
93
+ codeVerifier: string;
94
+ codeChallenge: string;
95
+ }
96
+
97
+ /** 64 random bytes, base64url unpadded — matches the Codex client. */
98
+ export async function createPkcePair(): Promise<PkcePair> {
99
+ const bytes = new Uint8Array(64);
100
+ crypto.getRandomValues(bytes);
101
+ const codeVerifier = base64UrlEncode(bytes);
102
+ const digest = await crypto.subtle.digest(
103
+ 'SHA-256',
104
+ new TextEncoder().encode(codeVerifier),
105
+ );
106
+ return {
107
+ codeVerifier,
108
+ codeChallenge: base64UrlEncode(new Uint8Array(digest)),
109
+ };
110
+ }
111
+
112
+ /** 32 random bytes, base64url — the `state` for the authorize URL. */
113
+ export function createOAuthState(): string {
114
+ const bytes = new Uint8Array(32);
115
+ crypto.getRandomValues(bytes);
116
+ return base64UrlEncode(bytes);
117
+ }
118
+
119
+ export function buildAuthorizeUrl(params: {
120
+ clientId: string;
121
+ codeChallenge: string;
122
+ state: string;
123
+ }): string {
124
+ const url = new URL(AUTHORIZE_URL);
125
+ url.searchParams.set('response_type', 'code');
126
+ url.searchParams.set('client_id', params.clientId);
127
+ url.searchParams.set('redirect_uri', CHATGPT_REDIRECT_URI);
128
+ url.searchParams.set('scope', OAUTH_SCOPE);
129
+ url.searchParams.set('code_challenge', params.codeChallenge);
130
+ url.searchParams.set('code_challenge_method', 'S256');
131
+ url.searchParams.set('id_token_add_organizations', 'true');
132
+ url.searchParams.set('codex_cli_simplified_flow', 'true');
133
+ url.searchParams.set('originator', 'codex_cli_rs');
134
+ url.searchParams.set('state', params.state);
135
+ return url.toString();
136
+ }
137
+
138
+ export class ChatGptOAuthError extends Error {
139
+ constructor(
140
+ message: string,
141
+ readonly code:
142
+ | 'device_flow_unavailable'
143
+ | 'exchange_failed'
144
+ | 'refresh_failed'
145
+ | 'reconnect_required',
146
+ ) {
147
+ super(message);
148
+ this.name = 'ChatGptOAuthError';
149
+ }
150
+ }
151
+
152
+ interface HttpJsonResult {
153
+ ok: boolean;
154
+ status: number;
155
+ body: Record<string, unknown>;
156
+ }
157
+
158
+ async function postJson(
159
+ url: string,
160
+ payload: Record<string, string>,
161
+ ): Promise<HttpJsonResult> {
162
+ const res = await fetch(url, {
163
+ method: 'POST',
164
+ headers: { 'Content-Type': 'application/json' },
165
+ body: JSON.stringify(payload),
166
+ signal: AbortSignal.timeout(AUTH_HTTP_TIMEOUT_MS),
167
+ });
168
+ return { ok: res.ok, status: res.status, body: await safeJson(res) };
169
+ }
170
+
171
+ async function postForm(
172
+ url: string,
173
+ form: Record<string, string>,
174
+ ): Promise<HttpJsonResult> {
175
+ const res = await fetch(url, {
176
+ method: 'POST',
177
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
178
+ body: new URLSearchParams(form).toString(),
179
+ signal: AbortSignal.timeout(AUTH_HTTP_TIMEOUT_MS),
180
+ });
181
+ return { ok: res.ok, status: res.status, body: await safeJson(res) };
182
+ }
183
+
184
+ async function safeJson(res: Response): Promise<Record<string, unknown>> {
185
+ try {
186
+ const parsed: unknown = await res.json();
187
+ return parsed && typeof parsed === 'object'
188
+ ? (parsed as Record<string, unknown>)
189
+ : {};
190
+ } catch {
191
+ return {};
192
+ }
193
+ }
194
+
195
+ export interface DeviceAuthorization {
196
+ deviceAuthId: string;
197
+ userCode: string;
198
+ verificationUri: string;
199
+ /** Seconds until the code expires (client-capped). */
200
+ expiresIn: number;
201
+ /** Suggested polling interval, seconds. */
202
+ interval: number;
203
+ }
204
+
205
+ export async function startDeviceAuthorization(
206
+ clientId: string,
207
+ ): Promise<DeviceAuthorization> {
208
+ const { ok, status, body } = await postJson(DEVICE_USERCODE_URL, {
209
+ client_id: clientId,
210
+ });
211
+ if (
212
+ !ok ||
213
+ typeof body.device_auth_id !== 'string' ||
214
+ typeof body.user_code !== 'string'
215
+ ) {
216
+ throw new ChatGptOAuthError(
217
+ `Device authorization unavailable (HTTP ${status})`,
218
+ 'device_flow_unavailable',
219
+ );
220
+ }
221
+ // `interval` arrives as a JSON *string* (e.g. "5").
222
+ const interval = Number(body.interval);
223
+ return {
224
+ deviceAuthId: body.device_auth_id,
225
+ userCode: body.user_code,
226
+ verificationUri: DEVICE_VERIFICATION_URL,
227
+ expiresIn: DEVICE_FLOW_EXPIRES_IN_SEC,
228
+ interval: Number.isFinite(interval) && interval > 0 ? interval : 5,
229
+ };
230
+ }
231
+
232
+ export type DevicePollResult =
233
+ | { status: 'pending' }
234
+ | { status: 'complete'; tokens: ChatGptOAuthTokens }
235
+ | { status: 'failed'; error: string };
236
+
237
+ /**
238
+ * Single poll of the device grant — the connect UI drives the loop. HTTP
239
+ * 403/404 mean "still pending" in this protocol. A 200 carries the
240
+ * authorization code plus the server-minted PKCE verifier, which is exchanged
241
+ * here immediately so callers get tokens in one step.
242
+ */
243
+ export async function pollDeviceToken(params: {
244
+ clientId: string;
245
+ deviceAuthId: string;
246
+ userCode: string;
247
+ }): Promise<DevicePollResult> {
248
+ const { ok, status, body } = await postJson(DEVICE_TOKEN_URL, {
249
+ device_auth_id: params.deviceAuthId,
250
+ user_code: params.userCode,
251
+ });
252
+ if (!ok) {
253
+ if (status === 403 || status === 404) return { status: 'pending' };
254
+ return { status: 'failed', error: extractErrorMessage(body, status) };
255
+ }
256
+ const code = body.authorization_code;
257
+ const verifier = body.code_verifier;
258
+ if (typeof code !== 'string' || typeof verifier !== 'string') {
259
+ return { status: 'failed', error: 'Malformed device-auth token response' };
260
+ }
261
+ const tokens = await exchangeAuthorizationCode({
262
+ clientId: params.clientId,
263
+ code,
264
+ codeVerifier: verifier,
265
+ redirectUri: DEVICE_REDIRECT_URI,
266
+ });
267
+ return { status: 'complete', tokens };
268
+ }
269
+
270
+ /** Authorization-code + PKCE exchange (form-encoded). */
271
+ export async function exchangeAuthorizationCode(params: {
272
+ clientId: string;
273
+ code: string;
274
+ codeVerifier: string;
275
+ /** Defaults to the localhost redirect (pasted-URL fallback path). */
276
+ redirectUri?: string;
277
+ }): Promise<ChatGptOAuthTokens> {
278
+ const { ok, status, body } = await postForm(TOKEN_URL, {
279
+ grant_type: 'authorization_code',
280
+ code: params.code,
281
+ redirect_uri: params.redirectUri ?? CHATGPT_REDIRECT_URI,
282
+ client_id: params.clientId,
283
+ code_verifier: params.codeVerifier,
284
+ });
285
+ if (!ok) {
286
+ throw new ChatGptOAuthError(
287
+ `Authorization-code exchange failed: ${extractErrorMessage(body, status)}`,
288
+ 'exchange_failed',
289
+ );
290
+ }
291
+ return mergeTokenResponse(body, undefined);
292
+ }
293
+
294
+ /**
295
+ * Refresh (JSON body — deliberately different from the code exchange). The
296
+ * response's fields are all optional and the refresh token rotates, so the
297
+ * result is merged over `previous`. Permanent refresh-token errors surface as
298
+ * `reconnect_required` so callers can distinguish "retry later" from "the
299
+ * user must sign in again".
300
+ */
301
+ export async function refreshChatGptTokens(params: {
302
+ clientId: string;
303
+ previous: ChatGptOAuthTokens;
304
+ }): Promise<ChatGptOAuthTokens> {
305
+ const { ok, status, body } = await postJson(TOKEN_URL, {
306
+ client_id: params.clientId,
307
+ grant_type: 'refresh_token',
308
+ refresh_token: params.previous.refreshToken,
309
+ });
310
+ if (!ok) {
311
+ const message = extractErrorMessage(body, status);
312
+ const code = extractErrorCode(body);
313
+ const permanent =
314
+ code === 'refresh_token_expired' ||
315
+ code === 'refresh_token_reused' ||
316
+ code === 'refresh_token_invalidated';
317
+ throw new ChatGptOAuthError(
318
+ `Token refresh failed: ${message}`,
319
+ permanent ? 'reconnect_required' : 'refresh_failed',
320
+ );
321
+ }
322
+ return mergeTokenResponse(body, params.previous);
323
+ }
324
+
325
+ /**
326
+ * Normalize a token-endpoint response over the previous tokens (if any). The
327
+ * ChatGPT account id (the `ChatGPT-Account-ID` header on backend calls) lives
328
+ * in the id-token's namespaced claims; kept from `previous` when the response
329
+ * carries no id token.
330
+ */
331
+ function mergeTokenResponse(
332
+ body: Record<string, unknown>,
333
+ previous: ChatGptOAuthTokens | undefined,
334
+ ): ChatGptOAuthTokens {
335
+ const accessToken =
336
+ typeof body.access_token === 'string' && body.access_token.length > 0
337
+ ? body.access_token
338
+ : (previous?.accessToken ?? '');
339
+ const refreshToken =
340
+ typeof body.refresh_token === 'string' && body.refresh_token.length > 0
341
+ ? body.refresh_token
342
+ : (previous?.refreshToken ?? '');
343
+ const idToken = typeof body.id_token === 'string' ? body.id_token : undefined;
344
+
345
+ const accountId =
346
+ (idToken ? extractAccountId(idToken) : undefined) ??
347
+ (accessToken ? extractAccountId(accessToken) : undefined) ??
348
+ previous?.accountId ??
349
+ '';
350
+
351
+ return {
352
+ accessToken,
353
+ refreshToken,
354
+ accountId,
355
+ expiresAt: resolveExpiry(body, accessToken),
356
+ };
357
+ }
358
+
359
+ /**
360
+ * Expiry: prefer the access token's own JWT `exp` (the authoritative value),
361
+ * fall back to `expires_in`, then a conservative 30 minutes.
362
+ */
363
+ function resolveExpiry(
364
+ body: Record<string, unknown>,
365
+ accessToken: string,
366
+ ): number {
367
+ const claims = accessToken ? decodeJwtClaims(accessToken) : null;
368
+ if (claims && typeof claims.exp === 'number') {
369
+ return claims.exp * 1000;
370
+ }
371
+ if (typeof body.expires_in === 'number') {
372
+ return Date.now() + body.expires_in * 1000;
373
+ }
374
+ return Date.now() + 30 * 60 * 1000;
375
+ }
376
+
377
+ function extractErrorMessage(
378
+ body: Record<string, unknown>,
379
+ status: number,
380
+ ): string {
381
+ const error = body.error;
382
+ if (typeof error === 'string') return error;
383
+ if (error && typeof error === 'object' && 'message' in error) {
384
+ const { message } = error;
385
+ if (typeof message === 'string') return message;
386
+ }
387
+ if (typeof body.detail === 'string') return body.detail;
388
+ return `HTTP ${status}`;
389
+ }
390
+
391
+ function extractErrorCode(body: Record<string, unknown>): string | undefined {
392
+ const error = body.error;
393
+ if (error && typeof error === 'object' && 'code' in error) {
394
+ const { code } = error;
395
+ if (typeof code === 'string') return code;
396
+ }
397
+ return typeof error === 'string' ? error : undefined;
398
+ }
399
+
400
+ function extractAccountId(jwt: string): string | undefined {
401
+ const claims = decodeJwtClaims(jwt);
402
+ if (!claims) return undefined;
403
+ const authClaim = claims['https://api.openai.com/auth'];
404
+ if (
405
+ authClaim &&
406
+ typeof authClaim === 'object' &&
407
+ 'chatgpt_account_id' in authClaim
408
+ ) {
409
+ const { chatgpt_account_id: id } = authClaim;
410
+ if (typeof id === 'string' && id.length > 0) return id;
411
+ }
412
+ const direct = claims.chatgpt_account_id;
413
+ return typeof direct === 'string' && direct.length > 0 ? direct : undefined;
414
+ }
415
+
416
+ function decodeJwtClaims(jwt: string): Record<string, unknown> | null {
417
+ const segments = jwt.split('.');
418
+ const payloadSegment = segments[1];
419
+ if (segments.length !== 3 || !payloadSegment) return null;
420
+ const payload = base64UrlDecodeToString(payloadSegment);
421
+ if (payload === null) return null;
422
+ try {
423
+ const parsed: unknown = JSON.parse(payload);
424
+ return parsed && typeof parsed === 'object'
425
+ ? (parsed as Record<string, unknown>)
426
+ : null;
427
+ } catch {
428
+ return null;
429
+ }
430
+ }