@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.
- package/README.md +110 -0
- package/package.json +77 -0
- package/src/attachments/classify.ts +114 -0
- package/src/attachments/content-blocks.ts +51 -0
- package/src/attachments/download.ts +235 -0
- package/src/attachments/extract.ts +241 -0
- package/src/attachments/index.ts +84 -0
- package/src/attachments/magic.ts +133 -0
- package/src/attachments/pipeline.ts +409 -0
- package/src/attachments/retention.ts +205 -0
- package/src/attachments/route.ts +41 -0
- package/src/attachments/sandbox-archive.ts +90 -0
- package/src/attachments/sanitize.ts +35 -0
- package/src/attachments/types.ts +86 -0
- package/src/attachments/view.ts +107 -0
- package/src/core/env.ts +200 -0
- package/src/core/index.ts +513 -0
- package/src/core/llm.ts +842 -0
- package/src/core/main-agent-types.ts +123 -0
- package/src/core/main-agent.ts +399 -0
- package/src/core/manifest.ts +365 -0
- package/src/core/meta-tools.ts +232 -0
- package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
- package/src/core/middlewares/capability-gate.ts +76 -0
- package/src/core/middlewares/index.ts +18 -0
- package/src/core/middlewares/page-context.ts +98 -0
- package/src/core/middlewares/safety-guardrail.ts +117 -0
- package/src/core/middlewares/summarization.ts +99 -0
- package/src/core/middlewares/tool-repetition-guard.ts +125 -0
- package/src/core/middlewares/tool-validation.ts +83 -0
- package/src/core/middlewares/work-status.ts +57 -0
- package/src/core/openrouter-pricing.ts +124 -0
- package/src/core/plugin-loader.ts +192 -0
- package/src/core/plugins/index.ts +6 -0
- package/src/core/plugins/skills/index.ts +8 -0
- package/src/core/plugins/skills/skills-tools.ts +356 -0
- package/src/core/plugins/skills/skills.plugin.ts +113 -0
- package/src/core/plugins/weather/index.ts +8 -0
- package/src/core/plugins/weather/weather-client.ts +153 -0
- package/src/core/plugins/weather/weather-middleware.ts +27 -0
- package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
- package/src/core/plugins/weather/weather-tools.ts +103 -0
- package/src/core/plugins/weather/weather.plugin.ts +227 -0
- package/src/core/prompt-composer.ts +469 -0
- package/src/core/registries.ts +642 -0
- package/src/core/runtime-context.ts +619 -0
- package/src/core/state.ts +108 -0
- package/src/core/sub-agent-fallback.ts +187 -0
- package/src/core/subagent-as-tool.ts +322 -0
- package/src/core/template.ts +245 -0
- package/src/core/utils.ts +246 -0
- package/src/core/wrap-plugin-tool.ts +100 -0
- package/src/do/ambient.ts +266 -0
- package/src/do/contracts.ts +562 -0
- package/src/do/debug-timers.ts +120 -0
- package/src/do/fresh-stub.ts +32 -0
- package/src/do/gateway-retry.ts +73 -0
- package/src/do/secrets-adapter.ts +32 -0
- package/src/do/size-alerts.ts +76 -0
- package/src/do/sse-stream.ts +472 -0
- package/src/do/transcript.ts +192 -0
- package/src/do/turn-body.ts +84 -0
- package/src/do/turn-metadata.ts +146 -0
- package/src/do/ucan-service.ts +339 -0
- package/src/do/user-oracle-do.ts +3035 -0
- package/src/gateway-worker.ts +82 -0
- package/src/index.ts +237 -0
- package/src/llm/byo-adapter.ts +59 -0
- package/src/llm/byo-catalog.ts +520 -0
- package/src/llm/byo-client.ts +280 -0
- package/src/llm/byo-routes.ts +255 -0
- package/src/llm/byo-service.ts +836 -0
- package/src/llm/chatgpt-oauth.ts +430 -0
- package/src/llm/provider-error.ts +283 -0
- package/src/matrix/gateway-do.ts +918 -0
- package/src/matrix/ingest.ts +209 -0
- package/src/matrix/replay-format.ts +34 -0
- package/src/matrix/reply-chain.ts +109 -0
- package/src/matrix/room-state-codec.ts +115 -0
- package/src/matrix/user-homeserver.ts +106 -0
- package/src/matrix/work-status.ts +294 -0
- package/src/memory/session-history-indexer.ts +328 -0
- package/src/owner-store/ixo-vfs-store.ts +570 -0
- package/src/owner-store/matrix-media-store.ts +136 -0
- package/src/owner-store/migrating-store.ts +185 -0
- package/src/owner-store/owner-copy-errors.ts +175 -0
- package/src/owner-store/retry.ts +45 -0
- package/src/owner-store/tus-upload.ts +333 -0
- package/src/owner-store/types.ts +0 -0
- package/src/plugin-api/define-plugin.ts +45 -0
- package/src/plugin-api/index.ts +56 -0
- package/src/plugin-api/oracle-plugin.ts +145 -0
- package/src/plugin-api/tool-helper.ts +82 -0
- package/src/plugin-api/types.ts +816 -0
- package/src/plugin-api/ucan-errors.ts +16 -0
- package/src/plugins/agui/agui-agent.ts +115 -0
- package/src/plugins/agui/agui.plugin.ts +148 -0
- package/src/plugins/agui/index.ts +2 -0
- package/src/plugins/attachments/attachments.plugin.ts +54 -0
- package/src/plugins/attachments/index.ts +6 -0
- package/src/plugins/attachments/view-attachment-tool.ts +91 -0
- package/src/plugins/base64.ts +26 -0
- package/src/plugins/composio/composio-tools.ts +365 -0
- package/src/plugins/composio/composio-ucan.ts +43 -0
- package/src/plugins/composio/composio.plugin.ts +214 -0
- package/src/plugins/composio/index.ts +15 -0
- package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
- package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
- package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
- package/src/plugins/domain-indexer/index.ts +3 -0
- package/src/plugins/editor/blocknote-bridge.ts +120 -0
- package/src/plugins/editor/content-session.ts +331 -0
- package/src/plugins/editor/content-tools.ts +686 -0
- package/src/plugins/editor/document-model.ts +0 -0
- package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
- package/src/plugins/editor/editor-agent.ts +34 -0
- package/src/plugins/editor/editor-config.ts +64 -0
- package/src/plugins/editor/editor-mx.ts +128 -0
- package/src/plugins/editor/editor.plugin.ts +143 -0
- package/src/plugins/editor/failures.ts +154 -0
- package/src/plugins/editor/index.ts +31 -0
- package/src/plugins/editor/jsdom-shim.ts +55 -0
- package/src/plugins/editor/prompts.ts +59 -0
- package/src/plugins/editor/prop-policy.ts +147 -0
- package/src/plugins/editor/provider.ts +386 -0
- package/src/plugins/editor/room-membership.ts +95 -0
- package/src/plugins/editor/standalone-editor-tool.ts +200 -0
- package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
- package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
- package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
- package/src/plugins/firecrawl/index.ts +15 -0
- package/src/plugins/flows/action-metadata.ts +3505 -0
- package/src/plugins/flows/actions.ts +190 -0
- package/src/plugins/flows/edit.ts +314 -0
- package/src/plugins/flows/errors.ts +38 -0
- package/src/plugins/flows/explain.ts +43 -0
- package/src/plugins/flows/flow-doc.ts +111 -0
- package/src/plugins/flows/flows.plugin.ts +129 -0
- package/src/plugins/flows/forms.ts +330 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/linkage.ts +147 -0
- package/src/plugins/flows/port-types.ts +53 -0
- package/src/plugins/flows/prompts.ts +65 -0
- package/src/plugins/flows/read.ts +392 -0
- package/src/plugins/flows/references.ts +66 -0
- package/src/plugins/flows/test-support.ts +165 -0
- package/src/plugins/flows/tools/authoring.ts +516 -0
- package/src/plugins/flows/tools/discovery.ts +119 -0
- package/src/plugins/flows/tools/forms.ts +142 -0
- package/src/plugins/flows/tools/inspect.ts +165 -0
- package/src/plugins/flows/tools/linkage.ts +117 -0
- package/src/plugins/flows/tools/settings.ts +184 -0
- package/src/plugins/flows/translator.ts +328 -0
- package/src/plugins/flows/types.ts +210 -0
- package/src/plugins/flows/ydoc-helpers.ts +543 -0
- package/src/plugins/index.ts +70 -0
- package/src/plugins/mcp-call-timeout.ts +38 -0
- package/src/plugins/mcp-tool-adapter.ts +87 -0
- package/src/plugins/memory/index.ts +17 -0
- package/src/plugins/memory/memory-tools.ts +463 -0
- package/src/plugins/memory/memory-ucan.ts +51 -0
- package/src/plugins/memory/memory.plugin.ts +122 -0
- package/src/plugins/memory/types.ts +147 -0
- package/src/plugins/portal/action-log.ts +34 -0
- package/src/plugins/portal/index.ts +2 -0
- package/src/plugins/portal/portal.plugin.ts +203 -0
- package/src/plugins/sandbox/index.ts +34 -0
- package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
- package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
- package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
- package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
- package/src/plugins/tasks/index.ts +17 -0
- package/src/plugins/tasks/manifest.ts +81 -0
- package/src/plugins/tasks/middleware.ts +219 -0
- package/src/plugins/tasks/tasks-tools.ts +414 -0
- package/src/plugins/tasks/tasks.plugin.ts +74 -0
- package/src/plugins/ucan-failure.ts +133 -0
- package/src/plugins/user-preferences/index.ts +22 -0
- package/src/plugins/user-preferences/schema.ts +31 -0
- package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
- package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
- package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
- package/src/plugins/vfs/index.ts +40 -0
- package/src/plugins/vfs/vfs-auth.ts +56 -0
- package/src/plugins/vfs/vfs-client.ts +655 -0
- package/src/plugins/vfs/vfs-content.ts +181 -0
- package/src/plugins/vfs/vfs-errors.ts +163 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
- package/src/plugins/vfs/vfs-tools.ts +635 -0
- package/src/plugins/vfs/vfs.plugin.ts +201 -0
- package/src/realtime/frontend-call-registry.ts +163 -0
- package/src/realtime/realtime-endpoint.ts +619 -0
- package/src/realtime/session-socket-hub.ts +249 -0
- package/src/realtime/socket-io-codec.ts +187 -0
- package/src/secrets/jwe.ts +74 -0
- package/src/secrets/pin-cipher.ts +43 -0
- package/src/secrets/secrets-service.ts +319 -0
- package/src/shell/app.ts +564 -0
- package/src/shell/auth.ts +290 -0
- package/src/sqlite/blob-codec.ts +100 -0
- package/src/sqlite/blob-compactor.ts +146 -0
- package/src/sqlite/cache-config.ts +45 -0
- package/src/sqlite/database.ts +643 -0
- package/src/sqlite/do-vfs.ts +1485 -0
- package/src/sqlite/index.ts +61 -0
- package/src/sqlite/serialization.ts +370 -0
- package/src/sqlite/sessions-store.ts +311 -0
- package/src/sqlite/sqlite-saver.ts +919 -0
- package/src/sqlite/vacuum-policy.ts +77 -0
- package/src/sqlite/wa-sqlite-loader.ts +123 -0
- package/src/sqlite/wasm.d.ts +10 -0
- package/src/tasks/schedule.ts +174 -0
- package/src/tasks/scheduler.ts +753 -0
- package/src/tasks/spec.ts +125 -0
- 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
|
+
}
|