@giovannijecha/jecode 0.1.9 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +36 -18
  2. package/dist/account-lock.js +112 -0
  3. package/dist/accounts.js +100 -0
  4. package/dist/cli-info.js +2 -2
  5. package/dist/commands.js +1 -1
  6. package/dist/controller.js +46 -11
  7. package/dist/credential-commands.js +32 -4
  8. package/dist/credential-safety.js +10 -1
  9. package/dist/effort.js +25 -0
  10. package/dist/external-browser.js +53 -0
  11. package/dist/oauth-http.js +114 -0
  12. package/dist/openai-account-command.js +124 -0
  13. package/dist/openai-account.js +94 -0
  14. package/dist/openai-oauth-callback.js +186 -0
  15. package/dist/openai-oauth-tokens.js +65 -0
  16. package/dist/openai-oauth.js +203 -0
  17. package/dist/provider-commands.js +79 -38
  18. package/dist/provider-label.js +10 -0
  19. package/dist/providers/anthropic-stream.js +8 -2
  20. package/dist/providers/anthropic.js +22 -6
  21. package/dist/providers/index.js +2 -1
  22. package/dist/providers/ollama-stream.js +3 -0
  23. package/dist/providers/ollama.js +4 -1
  24. package/dist/providers/openai-codex.js +157 -0
  25. package/dist/providers/openai-stream.js +13 -9
  26. package/dist/providers/openai-wire.js +4 -9
  27. package/dist/providers/openai.js +36 -15
  28. package/dist/providers/sse.js +1 -1
  29. package/dist/settings-command.js +64 -9
  30. package/dist/settings.js +2 -1
  31. package/dist/tools/args.js +4 -3
  32. package/dist/tools/fs.js +8 -3
  33. package/dist/tools/index.js +2 -2
  34. package/dist/tools/shell.js +2 -0
  35. package/dist/tui/app-workflows.js +5 -0
  36. package/dist/tui/components/messages.js +26 -5
  37. package/dist/tui/feedback.js +10 -6
  38. package/dist/tui/session-view.js +10 -4
  39. package/dist/tui/turn.js +4 -1
  40. package/dist/version.js +14 -0
  41. package/docs/assets/brand/jeco-256.png +0 -0
  42. package/package.json +5 -1
@@ -0,0 +1,114 @@
1
+ // A narrow HTTP boundary for OAuth authority requests.
2
+ //
3
+ // These requests never redirect, never retry, and never retain an unbounded
4
+ // response. Authorization codes and refresh tokens must not leak into errors.
5
+ const AUTH_ORIGIN = "https://auth.openai.com";
6
+ const TIMEOUT_MS = 15_000;
7
+ const MAX_BODY_CHARS = 64_000;
8
+ export async function oauthRequest(url, body, signal, accepted = [200]) {
9
+ const target = new URL(url);
10
+ if (target.origin !== AUTH_ORIGIN || target.username !== "" || target.password !== "") {
11
+ throw new Error("OAuth request target is not allowed");
12
+ }
13
+ const timeout = new AbortController();
14
+ const timer = setTimeout(() => timeout.abort(new Error("OpenAI sign-in timed out")), TIMEOUT_MS);
15
+ const combined = signal === undefined
16
+ ? timeout.signal
17
+ : AbortSignal.any([signal, timeout.signal]);
18
+ const secrets = bodySecrets(body);
19
+ let response;
20
+ let text;
21
+ try {
22
+ response = await fetch(target, {
23
+ method: "POST",
24
+ headers: { "content-type": body.contentType, accept: "application/json" },
25
+ body: body.contentType === "application/json"
26
+ ? JSON.stringify(body.value)
27
+ : body.value.toString(),
28
+ cache: "no-store",
29
+ redirect: "manual",
30
+ signal: combined,
31
+ });
32
+ if (response.status >= 300 && response.status < 400) {
33
+ await response.body?.cancel().catch(() => undefined);
34
+ throw new Error(`OpenAI sign-in redirect rejected (${response.status})`);
35
+ }
36
+ text = await boundedText(response);
37
+ }
38
+ catch (error) {
39
+ if (timeout.signal.aborted)
40
+ throw timeout.signal.reason;
41
+ if (signal?.aborted === true)
42
+ throw abortReason(signal);
43
+ if (error instanceof Error && error.message.startsWith("OpenAI sign-in"))
44
+ throw error;
45
+ const detail = error instanceof Error ? error.message : String(error);
46
+ throw new Error(`OpenAI sign-in network error: ${detail}`);
47
+ }
48
+ finally {
49
+ clearTimeout(timer);
50
+ }
51
+ const value = parse(text);
52
+ if (!accepted.includes(response.status)) {
53
+ throw new Error(`OpenAI sign-in failed (${response.status})${errorDetail(value, secrets)}`);
54
+ }
55
+ return { status: response.status, value };
56
+ }
57
+ async function boundedText(response) {
58
+ if (response.body === null)
59
+ return "";
60
+ const reader = response.body.getReader();
61
+ const decoder = new TextDecoder();
62
+ let text = "";
63
+ try {
64
+ while (true) {
65
+ const { done, value } = await reader.read();
66
+ if (done)
67
+ return text + decoder.decode();
68
+ text += decoder.decode(value, { stream: true });
69
+ if (text.length > MAX_BODY_CHARS) {
70
+ await reader.cancel().catch(() => undefined);
71
+ throw new Error("OpenAI sign-in returned too much data");
72
+ }
73
+ }
74
+ }
75
+ finally {
76
+ reader.releaseLock();
77
+ }
78
+ }
79
+ function parse(text) {
80
+ if (text.trim() === "")
81
+ return {};
82
+ try {
83
+ return JSON.parse(text);
84
+ }
85
+ catch {
86
+ throw new Error("OpenAI sign-in returned an invalid response");
87
+ }
88
+ }
89
+ function errorDetail(value, secrets) {
90
+ if (!record(value))
91
+ return "";
92
+ const detail = [value["error_description"], value["message"], value["error"]]
93
+ .find((entry) => typeof entry === "string");
94
+ if (typeof detail !== "string" || detail.trim() === "")
95
+ return "";
96
+ let safe = detail.replace(/[\r\n]+/g, " ");
97
+ for (const secret of secrets)
98
+ safe = safe.replaceAll(secret, "[credential redacted]");
99
+ return ` · ${safe.slice(0, 300)}`;
100
+ }
101
+ function bodySecrets(body) {
102
+ const values = body.contentType === "application/x-www-form-urlencoded"
103
+ ? [...body.value.values()]
104
+ : record(body.value)
105
+ ? Object.values(body.value).filter((value) => typeof value === "string")
106
+ : [];
107
+ return values.filter((value) => value.length >= 8);
108
+ }
109
+ function abortReason(signal) {
110
+ return signal.reason instanceof Error ? signal.reason : new Error("cancelled");
111
+ }
112
+ function record(value) {
113
+ return typeof value === "object" && value !== null && !Array.isArray(value);
114
+ }
@@ -0,0 +1,124 @@
1
+ // Interactive ChatGPT account management for the OpenAI Codex provider.
2
+ import { openAICodexAccount } from "./accounts.js";
3
+ import { headlessEnvironment, openExternal } from "./external-browser.js";
4
+ import { openAIAccountHint, removeOpenAIAccount, saveOpenAIAccount, } from "./openai-account.js";
5
+ import { beginBrowserLogin, beginDeviceLogin, } from "./openai-oauth.js";
6
+ import { heading } from "./tui/picker.js";
7
+ const DEFAULT_DEPENDENCIES = {
8
+ beginBrowser: beginBrowserLogin,
9
+ beginDevice: beginDeviceLogin,
10
+ openUrl: openExternal,
11
+ headless: headlessEnvironment,
12
+ };
13
+ export async function openAIAccountCommand(session, host, dependencies = DEFAULT_DEPENDENCIES) {
14
+ if (host.choose === undefined)
15
+ return false;
16
+ const connected = openAICodexAccount() !== undefined;
17
+ const actions = connected
18
+ ? [
19
+ { label: "reconnect ChatGPT", hint: "replace the current sign-in", key: "r" },
20
+ { label: "sign out", hint: "remove the saved account", key: "s" },
21
+ ]
22
+ : [{ label: "connect ChatGPT", hint: "sign in with OpenAI", key: "c" }];
23
+ const action = await host.choose({
24
+ title: heading("ChatGPT", openAIAccountHint(), session.palette),
25
+ options: actions,
26
+ index: 0,
27
+ });
28
+ if (action === undefined)
29
+ return false;
30
+ if (connected && actions[action]?.key === "s") {
31
+ const result = await removeOpenAIAccount(host.signal);
32
+ host.emit({
33
+ kind: "notice",
34
+ text: result.revokeFailed ? "ChatGPT disconnected · remote sign-out could not be confirmed" : "ChatGPT disconnected",
35
+ tone: result.revokeFailed ? "warn" : "info",
36
+ });
37
+ return result.removed;
38
+ }
39
+ return connectOpenAIAccount(session, host, dependencies);
40
+ }
41
+ export async function ensureOpenAIAccount(session, host, dependencies = DEFAULT_DEPENDENCIES) {
42
+ if (openAICodexAccount() !== undefined)
43
+ return true;
44
+ return connectOpenAIAccount(session, host, dependencies);
45
+ }
46
+ async function connectOpenAIAccount(session, host, dependencies) {
47
+ if (host.choose === undefined)
48
+ return false;
49
+ const methods = [
50
+ { label: "sign in with browser", hint: "desktop terminal", key: "b" },
51
+ { label: "sign in with device code", hint: "WSL, SSH, or headless", key: "d" },
52
+ ];
53
+ const choice = await host.choose({
54
+ title: heading("connect ChatGPT", "OpenAI OAuth", session.palette),
55
+ description: "Choose the flow for this terminal. Your password stays on OpenAI's website.",
56
+ options: methods,
57
+ index: dependencies.headless() ? 1 : 0,
58
+ });
59
+ if (choice === undefined)
60
+ return false;
61
+ host.status?.("Starting ChatGPT sign-in");
62
+ let login;
63
+ try {
64
+ login = choice === 1
65
+ ? await dependencies.beginDevice(host.signal)
66
+ : await dependencies.beginBrowser();
67
+ }
68
+ finally {
69
+ host.status?.(undefined);
70
+ }
71
+ return waitForLogin(login, session, host, dependencies.openUrl);
72
+ }
73
+ async function waitForLogin(login, session, host, openUrl) {
74
+ if (host.choose === undefined)
75
+ return false;
76
+ const local = new AbortController();
77
+ const signal = host.signal === undefined
78
+ ? local.signal
79
+ : AbortSignal.any([host.signal, local.signal]);
80
+ const completion = login.complete(signal).then((account) => ({ kind: "account", account }), (error) => ({ kind: "error", error: error }));
81
+ await openUrl(login.url);
82
+ try {
83
+ while (true) {
84
+ const selection = host.choose(waitingPicker(login, session));
85
+ const outcome = await Promise.race([
86
+ completion,
87
+ selection.then((index) => ({ kind: "selection", index })),
88
+ ]);
89
+ if (outcome.kind === "account") {
90
+ host.dismiss?.();
91
+ await saveOpenAIAccount(outcome.account, host.signal);
92
+ host.emit({ kind: "notice", text: "ChatGPT connected", tone: "info" });
93
+ return true;
94
+ }
95
+ if (outcome.kind === "error") {
96
+ host.dismiss?.();
97
+ throw outcome.error;
98
+ }
99
+ if (outcome.index === 0) {
100
+ await openUrl(login.url);
101
+ continue;
102
+ }
103
+ local.abort(new Error("ChatGPT sign-in cancelled"));
104
+ return false;
105
+ }
106
+ }
107
+ finally {
108
+ await login.close();
109
+ }
110
+ }
111
+ function waitingPicker(login, session) {
112
+ const detail = login.code === undefined
113
+ ? "Finish sign-in in your browser. Jecode will continue automatically."
114
+ : `Enter ${login.code} on OpenAI's device page. Jecode will continue automatically.`;
115
+ return {
116
+ title: heading("ChatGPT sign-in", login.code ?? "waiting for browser", session.palette),
117
+ description: detail,
118
+ options: [
119
+ { label: "open browser again", key: "o" },
120
+ { label: "cancel sign-in", key: "c" },
121
+ ],
122
+ index: 0,
123
+ };
124
+ }
@@ -0,0 +1,94 @@
1
+ // The live ChatGPT account: persistence, proactive refresh, and logout.
2
+ import { openAICodexAccount, updateOpenAICodexAccount } from "./accounts.js";
3
+ import { refreshOpenAITokens, revokeOpenAITokens } from "./openai-oauth.js";
4
+ const REFRESH_EARLY_MS = 5 * 60_000;
5
+ let refreshing;
6
+ export function openAIAccountHint() {
7
+ const account = openAICodexAccount();
8
+ if (account === undefined)
9
+ return "not connected";
10
+ const identity = account.email ?? "connected";
11
+ return account.plan === undefined ? identity : `${identity} · ${account.plan}`;
12
+ }
13
+ export async function saveOpenAIAccount(account, signal) {
14
+ await updateOpenAICodexAccount(async () => account, signal);
15
+ }
16
+ export async function removeOpenAIAccount(signal) {
17
+ let account;
18
+ await updateOpenAICodexAccount(async (current) => {
19
+ account = current;
20
+ return undefined;
21
+ }, signal);
22
+ if (account === undefined)
23
+ return { removed: false, revokeFailed: false };
24
+ let revokeFailed = false;
25
+ try {
26
+ await revokeOpenAITokens(account, signal);
27
+ }
28
+ catch {
29
+ revokeFailed = true;
30
+ }
31
+ return { removed: true, revokeFailed };
32
+ }
33
+ export async function openAIAuthorization(forceToken, signal, onStatus) {
34
+ const account = openAICodexAccount();
35
+ if (account === undefined)
36
+ throw new Error("ChatGPT account is not connected");
37
+ const mustRefresh = forceToken !== undefined || expiresSoon(account);
38
+ const ready = mustRefresh
39
+ ? await refreshAccount(forceToken, signal, onStatus)
40
+ : account;
41
+ return { accessToken: ready.accessToken, accountId: ready.accountId };
42
+ }
43
+ async function refreshAccount(forceToken, signal, onStatus) {
44
+ if (refreshing !== undefined)
45
+ return abortable(refreshing, signal);
46
+ if (signal?.aborted === true)
47
+ throw abortReason(signal);
48
+ onStatus?.("Refreshing ChatGPT sign-in");
49
+ const task = updateOpenAICodexAccount(async (current) => {
50
+ if (current === undefined)
51
+ throw new Error("ChatGPT account is not connected");
52
+ if (forceToken !== undefined && current.accessToken !== forceToken)
53
+ return current;
54
+ if (forceToken === undefined && !expiresSoon(current))
55
+ return current;
56
+ return refreshOpenAITokens(current);
57
+ }).then((account) => {
58
+ if (account === undefined)
59
+ throw new Error("ChatGPT account is not connected");
60
+ return account;
61
+ });
62
+ refreshing = task;
63
+ void task.then(() => {
64
+ if (refreshing === task)
65
+ refreshing = undefined;
66
+ }, () => {
67
+ if (refreshing === task)
68
+ refreshing = undefined;
69
+ });
70
+ return abortable(task, signal);
71
+ }
72
+ function expiresSoon(account) {
73
+ return account.expiresAt - Date.now() <= REFRESH_EARLY_MS;
74
+ }
75
+ function abortable(promise, signal) {
76
+ if (signal === undefined)
77
+ return promise;
78
+ if (signal.aborted)
79
+ return Promise.reject(abortReason(signal));
80
+ return new Promise((resolve, reject) => {
81
+ const onAbort = () => reject(abortReason(signal));
82
+ signal.addEventListener("abort", onAbort, { once: true });
83
+ promise.then((value) => {
84
+ signal.removeEventListener("abort", onAbort);
85
+ resolve(value);
86
+ }, (error) => {
87
+ signal.removeEventListener("abort", onAbort);
88
+ reject(error);
89
+ });
90
+ });
91
+ }
92
+ function abortReason(signal) {
93
+ return signal.reason instanceof Error ? signal.reason : new Error("cancelled");
94
+ }
@@ -0,0 +1,186 @@
1
+ // Loopback callback used by the browser OAuth flow.
2
+ import { timingSafeEqual } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+ import { createServer } from "node:http";
5
+ const CALLBACK_PORTS = [1455, 1457];
6
+ export const OPENAI_CALLBACK_PATH = "/auth/callback";
7
+ export async function openAICallback(state) {
8
+ let resolveCode = () => { };
9
+ let rejectCode = () => { };
10
+ const code = new Promise((resolve, reject) => {
11
+ resolveCode = resolve;
12
+ rejectCode = reject;
13
+ });
14
+ // A bad callback can arrive before the TUI starts awaiting `complete()`.
15
+ // Mark the rejection handled here while preserving it for the real waiter.
16
+ void code.catch(() => undefined);
17
+ let response;
18
+ let finished = false;
19
+ const handler = (request, outgoing) => {
20
+ if (finished) {
21
+ outgoing.writeHead(409).end("Sign-in already completed.");
22
+ return;
23
+ }
24
+ if (request.method !== "GET" || request.url === undefined || request.url.length > 4_096) {
25
+ outgoing.writeHead(400).end("Invalid sign-in callback.");
26
+ return;
27
+ }
28
+ const incoming = new URL(request.url, "http://localhost");
29
+ if (incoming.pathname !== OPENAI_CALLBACK_PATH) {
30
+ outgoing.writeHead(404).end("Not found.");
31
+ return;
32
+ }
33
+ const receivedState = incoming.searchParams.get("state");
34
+ // A stale browser tab or another local client can reach the fixed callback
35
+ // port. Reject that one request without letting it terminate this login.
36
+ if (!sameState(state, receivedState)) {
37
+ outgoing.writeHead(400, { "cache-control": "no-store" }).end("Invalid sign-in state.");
38
+ return;
39
+ }
40
+ finished = true;
41
+ response = outgoing;
42
+ const authError = incoming.searchParams.get("error_description") ?? incoming.searchParams.get("error");
43
+ const authorizationCode = incoming.searchParams.get("code");
44
+ if (authError !== null) {
45
+ rejectCode(new Error(`ChatGPT sign-in was rejected · ${authError.slice(0, 300)}`));
46
+ }
47
+ else if (authorizationCode === null || authorizationCode === "") {
48
+ rejectCode(new Error("ChatGPT sign-in returned no authorization code"));
49
+ }
50
+ else {
51
+ resolveCode(authorizationCode);
52
+ }
53
+ };
54
+ const listening = await firstAvailableServer(handler);
55
+ return {
56
+ port: listening.port,
57
+ code,
58
+ async respond(success) {
59
+ if (response === undefined || response.writableEnded)
60
+ return;
61
+ const flushed = new Promise((resolve) => {
62
+ response?.once("finish", resolve);
63
+ response?.once("close", resolve);
64
+ });
65
+ response.writeHead(success ? 200 : 400, {
66
+ "cache-control": "no-store",
67
+ connection: "close",
68
+ "content-security-policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
69
+ "content-type": "text/html; charset=utf-8",
70
+ "referrer-policy": "no-referrer",
71
+ "x-content-type-options": "nosniff",
72
+ });
73
+ response.end(resultPage(success));
74
+ await flushed;
75
+ },
76
+ close: () => closeServer(listening.server),
77
+ };
78
+ }
79
+ async function firstAvailableServer(handler) {
80
+ for (const port of CALLBACK_PORTS) {
81
+ const server = createServer(handler);
82
+ server.maxHeadersCount = 40;
83
+ server.headersTimeout = 5_000;
84
+ server.keepAliveTimeout = 500;
85
+ server.requestTimeout = 5_000;
86
+ try {
87
+ await listen(server, port);
88
+ return { server, port };
89
+ }
90
+ catch (error) {
91
+ await closeServer(server);
92
+ if (error.code !== "EADDRINUSE")
93
+ throw error;
94
+ }
95
+ }
96
+ throw new Error("ChatGPT sign-in could not open callback ports 1455 or 1457");
97
+ }
98
+ function listen(server, port) {
99
+ return new Promise((resolve, reject) => {
100
+ const onError = (error) => reject(error);
101
+ server.once("error", onError);
102
+ server.listen(port, "127.0.0.1", () => {
103
+ server.off("error", onError);
104
+ resolve();
105
+ });
106
+ });
107
+ }
108
+ function closeServer(server) {
109
+ return new Promise((resolve) => {
110
+ if (!server.listening) {
111
+ resolve();
112
+ return;
113
+ }
114
+ let settled = false;
115
+ let fallback;
116
+ const finish = () => {
117
+ if (settled)
118
+ return;
119
+ settled = true;
120
+ if (fallback !== undefined)
121
+ clearTimeout(fallback);
122
+ resolve();
123
+ };
124
+ fallback = setTimeout(() => {
125
+ server.closeAllConnections();
126
+ finish();
127
+ }, 500);
128
+ server.close(finish);
129
+ server.closeIdleConnections();
130
+ });
131
+ }
132
+ function resultPage(success) {
133
+ const title = success ? "Signed in to Jecode" : "Jecode sign-in failed";
134
+ const status = success ? "Authentication complete" : "Authentication stopped";
135
+ const detail = success
136
+ ? "Return to your terminal. Jecode will continue automatically."
137
+ : "Return to your terminal to see what stopped the connection.";
138
+ const state = success ? "success" : "failure";
139
+ return `<!doctype html>
140
+ <html lang="en">
141
+ <head>
142
+ <meta charset="utf-8">
143
+ <meta name="viewport" content="width=device-width, initial-scale=1">
144
+ <meta name="color-scheme" content="dark">
145
+ <title>${title}</title>
146
+ <style>
147
+ :root{--night:#000;--steel:#669bd2;--steel-soft:#8db4dd;--bright:#ebeff4;--danger:#e87070}
148
+ *{box-sizing:border-box}
149
+ body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--night);color:var(--steel-soft);font-family:"Segoe UI",system-ui,sans-serif}
150
+ main{width:min(34rem,calc(100vw - 3rem));padding:3rem 1.5rem;text-align:center}
151
+ img{display:block;width:clamp(7.5rem,20vw,10rem);height:auto;margin:0 auto 1.75rem;filter:drop-shadow(0 1.25rem 2rem rgba(102,155,210,.16))}
152
+ .rail{width:min(18rem,70vw);height:1px;margin:0 auto 1.5rem;background:linear-gradient(90deg,transparent,var(--steel),transparent)}
153
+ .status{margin:0 0 .75rem;color:var(--steel);font:600 .72rem/1.2 ui-monospace,"Cascadia Mono",monospace;letter-spacing:.14em;text-transform:uppercase}
154
+ h1{margin:0;color:var(--steel);font-size:clamp(2rem,6vw,3.25rem);font-weight:720;letter-spacing:-.04em;line-height:1.05}
155
+ p:last-of-type{max-width:30rem;margin:1.25rem auto 0;color:var(--steel-soft);font-size:1.05rem;line-height:1.6}
156
+ .failure h1,.failure .status{color:var(--danger)}
157
+ @media (prefers-reduced-motion:no-preference){main{animation:arrive .45s ease-out both}@keyframes arrive{from{opacity:0;transform:translateY(.6rem)}to{opacity:1;transform:none}}}
158
+ </style>
159
+ </head>
160
+ <body>
161
+ <main class="${state}">
162
+ <img src="${mascotDataUri()}" alt="Jeco, the Jecode gecko">
163
+ <div class="rail" aria-hidden="true"></div>
164
+ <p class="status">${status}</p>
165
+ <h1>${title}</h1>
166
+ <p>${detail}</p>
167
+ </main>
168
+ <script>history.replaceState(null,"","/auth/complete")</script>
169
+ </body>
170
+ </html>`;
171
+ }
172
+ let mascot;
173
+ function mascotDataUri() {
174
+ if (mascot === undefined) {
175
+ const file = new URL("../docs/assets/brand/jeco-256.png", import.meta.url);
176
+ mascot = `data:image/png;base64,${readFileSync(file).toString("base64")}`;
177
+ }
178
+ return mascot;
179
+ }
180
+ function sameState(expected, received) {
181
+ if (received === null)
182
+ return false;
183
+ const left = Buffer.from(expected);
184
+ const right = Buffer.from(received);
185
+ return left.length === right.length && timingSafeEqual(left, right);
186
+ }
@@ -0,0 +1,65 @@
1
+ // Validate OAuth token responses and extract the ChatGPT account claims.
2
+ const CLAIMS = "https://api.openai.com/auth";
3
+ export function openAITokenReply(value, previousRefresh) {
4
+ if (!record(value))
5
+ throw new Error("OpenAI sign-in returned an invalid token response");
6
+ const accessToken = required(value["access_token"], "access token");
7
+ const refreshToken = optional(value["refresh_token"]) ?? previousRefresh;
8
+ const expiresIn = value["expires_in"];
9
+ if (refreshToken === undefined)
10
+ throw new Error("OpenAI sign-in did not return a refresh token");
11
+ if (typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0) {
12
+ throw new Error("OpenAI sign-in did not return a valid token lifetime");
13
+ }
14
+ const idToken = optional(value["id_token"]);
15
+ return {
16
+ accessToken,
17
+ refreshToken,
18
+ expiresAt: Date.now() + Math.floor(expiresIn * 1_000),
19
+ ...(idToken === undefined ? {} : { idToken }),
20
+ };
21
+ }
22
+ export function openAIAccountFromTokens(token) {
23
+ const access = jwt(token.accessToken);
24
+ const identity = token.idToken === undefined ? undefined : jwt(token.idToken);
25
+ const auth = record(access[CLAIMS]) ? access[CLAIMS] : {};
26
+ const accountId = optional(auth["chatgpt_account_id"]) ?? optional(identity?.["chatgpt_account_id"]);
27
+ if (accountId === undefined)
28
+ throw new Error("OpenAI sign-in did not identify a ChatGPT account");
29
+ const email = optional(identity?.["email"]);
30
+ const plan = optional(auth["chatgpt_plan_type"]) ?? optional(identity?.["chatgpt_plan_type"]);
31
+ return {
32
+ accessToken: token.accessToken,
33
+ refreshToken: token.refreshToken,
34
+ expiresAt: token.expiresAt,
35
+ accountId,
36
+ ...(email === undefined ? {} : { email }),
37
+ ...(plan === undefined ? {} : { plan }),
38
+ };
39
+ }
40
+ function jwt(token) {
41
+ const part = token.split(".")[1];
42
+ if (part === undefined)
43
+ throw new Error("OpenAI sign-in returned an unreadable token");
44
+ try {
45
+ const value = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
46
+ if (!record(value))
47
+ throw new Error("invalid payload");
48
+ return value;
49
+ }
50
+ catch {
51
+ throw new Error("OpenAI sign-in returned an unreadable token");
52
+ }
53
+ }
54
+ function required(value, label) {
55
+ const found = optional(value);
56
+ if (found === undefined)
57
+ throw new Error(`OpenAI sign-in returned no ${label}`);
58
+ return found;
59
+ }
60
+ function optional(value) {
61
+ return typeof value === "string" && value !== "" ? value : undefined;
62
+ }
63
+ function record(value) {
64
+ return typeof value === "object" && value !== null && !Array.isArray(value);
65
+ }