termi-kids 0.1.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/LICENSE +34 -0
- package/README.md +148 -0
- package/SAFETY.md +187 -0
- package/bin/termi.js +22 -0
- package/dist/agent/context.js +126 -0
- package/dist/agent/loop.js +172 -0
- package/dist/agent/prompts/system.js +45 -0
- package/dist/agent/tools.js +335 -0
- package/dist/auth/keychain.js +146 -0
- package/dist/auth/oauth.js +375 -0
- package/dist/auth/tokens.js +219 -0
- package/dist/cli.js +258 -0
- package/dist/config/paths.js +92 -0
- package/dist/config/pin.js +150 -0
- package/dist/config/settings.js +131 -0
- package/dist/grownups/panel.js +483 -0
- package/dist/learn/lessons.js +490 -0
- package/dist/learn/runner.js +193 -0
- package/dist/preview/server.js +407 -0
- package/dist/projects/create.js +103 -0
- package/dist/projects/ideas.js +182 -0
- package/dist/projects/quests.js +277 -0
- package/dist/projects/scaffolds/art.js +484 -0
- package/dist/projects/scaffolds/biggames.js +554 -0
- package/dist/projects/scaffolds/characters.js +580 -0
- package/dist/projects/scaffolds/games.js +516 -0
- package/dist/projects/scaffolds/index.js +24 -0
- package/dist/projects/scaffolds/music.js +528 -0
- package/dist/projects/scaffolds/pets.js +567 -0
- package/dist/projects/scaffolds/quizzes.js +757 -0
- package/dist/projects/scaffolds/stories.js +620 -0
- package/dist/projects/scaffolds/vendor/KAPLAY-LICENSE.txt +35 -0
- package/dist/projects/scaffolds/vendor/kaplay.mjs +57 -0
- package/dist/projects/scaffolds/websites.js +474 -0
- package/dist/projects/snapshots.js +203 -0
- package/dist/projects/store.js +325 -0
- package/dist/providers/errors.js +207 -0
- package/dist/providers/index.js +316 -0
- package/dist/providers/models.js +38 -0
- package/dist/safety/audit.js +195 -0
- package/dist/safety/blocks.js +29 -0
- package/dist/safety/classifier.js +337 -0
- package/dist/safety/codescan.js +168 -0
- package/dist/safety/guarddownload.js +79 -0
- package/dist/safety/guardrunner.js +125 -0
- package/dist/safety/localguard.js +227 -0
- package/dist/safety/modelstore.js +127 -0
- package/dist/safety/prefilter.js +214 -0
- package/dist/safety/session.js +118 -0
- package/dist/safety/taxonomy.js +246 -0
- package/dist/safety/textextract.js +193 -0
- package/dist/setup/launcher.js +65 -0
- package/dist/setup/wizard.js +469 -0
- package/dist/surfaces/chat.js +439 -0
- package/dist/surfaces/commands.js +206 -0
- package/dist/surfaces/home.js +438 -0
- package/dist/types.js +5 -0
- package/dist/ui/banner.js +35 -0
- package/dist/ui/celebrate.js +141 -0
- package/dist/ui/errors.js +97 -0
- package/dist/ui/mascot.js +223 -0
- package/dist/ui/text.js +156 -0
- package/dist/ui/theme.js +92 -0
- package/package.json +67 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret storage for Termi.
|
|
3
|
+
*
|
|
4
|
+
* Primary backend: the OS keychain via @napi-rs/keyring (service "termi-cli").
|
|
5
|
+
* Fallback backend: a JSON file at TERMI_HOME/secrets.json (mode 0o600,
|
|
6
|
+
* atomic writes). The fallback activates in two ways:
|
|
7
|
+
*
|
|
8
|
+
* 1. Forced: env TERMI_KEYRING=file. Tests and CI use this so they never
|
|
9
|
+
* touch a real keychain.
|
|
10
|
+
* 2. Tripped: the native keyring throws a platform error (for example a
|
|
11
|
+
* headless Linux box with no secret service). We then fall back
|
|
12
|
+
* transparently for the rest of the process.
|
|
13
|
+
*
|
|
14
|
+
* Accounts in use across Termi: "pin-hash", "setup-marker", "hmac-key",
|
|
15
|
+
* "api-key-openai-api", "api-key-anthropic", "api-key-xai", "install-id".
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { atomicWriteFileSync, termiHome } from '../config/paths.js';
|
|
21
|
+
export const KEYCHAIN_SERVICE = 'termi-cli';
|
|
22
|
+
/** Keychain accounts that hold provider API keys. Wiped on PIN reset. */
|
|
23
|
+
export const API_KEY_ACCOUNTS = [
|
|
24
|
+
'api-key-openai-api',
|
|
25
|
+
'api-key-anthropic',
|
|
26
|
+
'api-key-xai',
|
|
27
|
+
];
|
|
28
|
+
/** undefined = not loaded yet; null = native module unavailable. */
|
|
29
|
+
let nativeEntryCtor;
|
|
30
|
+
/** Set once the native keyring fails at runtime; sticky for the process. */
|
|
31
|
+
let fallbackTripped = false;
|
|
32
|
+
function forcedFallback() {
|
|
33
|
+
return (process.env.TERMI_KEYRING ?? '').trim().toLowerCase() === 'file';
|
|
34
|
+
}
|
|
35
|
+
/** True when secrets are going to the JSON fallback file, not the OS keychain. */
|
|
36
|
+
export function isFallbackActive() {
|
|
37
|
+
return forcedFallback() || fallbackTripped;
|
|
38
|
+
}
|
|
39
|
+
function loadNativeCtor() {
|
|
40
|
+
if (nativeEntryCtor !== undefined) {
|
|
41
|
+
return nativeEntryCtor;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const requireNative = createRequire(import.meta.url);
|
|
45
|
+
const mod = requireNative('@napi-rs/keyring');
|
|
46
|
+
nativeEntryCtor = mod.Entry;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
nativeEntryCtor = null;
|
|
50
|
+
fallbackTripped = true;
|
|
51
|
+
}
|
|
52
|
+
return nativeEntryCtor;
|
|
53
|
+
}
|
|
54
|
+
/** "No matching entry" means the secret is absent, not that the platform broke. */
|
|
55
|
+
function isNoEntryError(err) {
|
|
56
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
57
|
+
return /no matching entry/i.test(message) || /noentry/i.test(message);
|
|
58
|
+
}
|
|
59
|
+
function secretsFilePath() {
|
|
60
|
+
return path.join(termiHome(), 'secrets.json');
|
|
61
|
+
}
|
|
62
|
+
function readFallbackStore() {
|
|
63
|
+
try {
|
|
64
|
+
const raw = fs.readFileSync(secretsFilePath(), 'utf8');
|
|
65
|
+
const parsed = JSON.parse(raw);
|
|
66
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
67
|
+
const store = {};
|
|
68
|
+
for (const [account, value] of Object.entries(parsed)) {
|
|
69
|
+
if (typeof value === 'string') {
|
|
70
|
+
store[account] = value;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return store;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Missing or unreadable file means an empty store.
|
|
78
|
+
}
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
function writeFallbackStore(store) {
|
|
82
|
+
atomicWriteFileSync(secretsFilePath(), JSON.stringify(store, null, 2), 0o600);
|
|
83
|
+
}
|
|
84
|
+
/** Returns the stored secret, or null when there is none. */
|
|
85
|
+
export function getSecret(account) {
|
|
86
|
+
if (!isFallbackActive()) {
|
|
87
|
+
const Ctor = loadNativeCtor();
|
|
88
|
+
if (Ctor) {
|
|
89
|
+
try {
|
|
90
|
+
const entry = new Ctor(KEYCHAIN_SERVICE, account);
|
|
91
|
+
return entry.getPassword() ?? null;
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
if (isNoEntryError(err)) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
fallbackTripped = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return readFallbackStore()[account] ?? null;
|
|
102
|
+
}
|
|
103
|
+
/** Stores or replaces a secret. */
|
|
104
|
+
export function setSecret(account, value) {
|
|
105
|
+
if (!isFallbackActive()) {
|
|
106
|
+
const Ctor = loadNativeCtor();
|
|
107
|
+
if (Ctor) {
|
|
108
|
+
try {
|
|
109
|
+
const entry = new Ctor(KEYCHAIN_SERVICE, account);
|
|
110
|
+
entry.setPassword(value);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
fallbackTripped = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const store = readFallbackStore();
|
|
119
|
+
store[account] = value;
|
|
120
|
+
writeFallbackStore(store);
|
|
121
|
+
}
|
|
122
|
+
/** Deletes a secret. Returns true when something was actually removed. */
|
|
123
|
+
export function deleteSecret(account) {
|
|
124
|
+
if (!isFallbackActive()) {
|
|
125
|
+
const Ctor = loadNativeCtor();
|
|
126
|
+
if (Ctor) {
|
|
127
|
+
try {
|
|
128
|
+
const entry = new Ctor(KEYCHAIN_SERVICE, account);
|
|
129
|
+
return entry.deleteCredential();
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
if (isNoEntryError(err)) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
fallbackTripped = true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const store = readFallbackStore();
|
|
140
|
+
if (!(account in store)) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
delete store[account];
|
|
144
|
+
writeFallbackStore(store);
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatGPT sign-in flow (OAuth 2.0 + PKCE) for the openai-chatgpt provider.
|
|
3
|
+
*
|
|
4
|
+
* The authorize URL, token endpoints, and wire values follow the OAuth
|
|
5
|
+
* protocol spec for OpenAI's public CLI app client id. The issuer base is
|
|
6
|
+
* injectable so tests can point every endpoint at a local mock server.
|
|
7
|
+
* Token values never appear in error messages or logs.
|
|
8
|
+
*/
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import http from 'node:http';
|
|
11
|
+
import { saveTokens } from './tokens.js';
|
|
12
|
+
/** OAuth client id published by OpenAI for its CLI app. Protocol constant. */
|
|
13
|
+
export const OPENAI_PUBLIC_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
|
14
|
+
/** Default OAuth issuer. Tests override this with a local mock server. */
|
|
15
|
+
export const DEFAULT_ISSUER_BASE = 'https://auth.openai.com';
|
|
16
|
+
/** Originator value the wire protocol requires on requests. */
|
|
17
|
+
export const PROTOCOL_ORIGINATOR = 'codex_cli_rs';
|
|
18
|
+
/** Fallback access token lifetime when the server does not say: 10 days. */
|
|
19
|
+
const DEFAULT_LIFETIME_MS = 10 * 24 * 60 * 60 * 1000;
|
|
20
|
+
const CALLBACK_PATH = '/auth/callback';
|
|
21
|
+
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000;
|
|
22
|
+
const PORT_FALLBACK_RANGE = 9;
|
|
23
|
+
/** Thrown by refreshTokens. kind "auth-dead" means sign in again is required. */
|
|
24
|
+
export class OAuthRefreshError extends Error {
|
|
25
|
+
kind;
|
|
26
|
+
reason;
|
|
27
|
+
constructor(kind, reason) {
|
|
28
|
+
super(`Token refresh failed (${reason})`);
|
|
29
|
+
this.name = 'OAuthRefreshError';
|
|
30
|
+
this.kind = kind;
|
|
31
|
+
this.reason = reason;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** RFC 7636 S256: base64url of the SHA-256 of the ASCII verifier. */
|
|
35
|
+
export function s256Challenge(verifier) {
|
|
36
|
+
return crypto.createHash('sha256').update(verifier, 'ascii').digest('base64url');
|
|
37
|
+
}
|
|
38
|
+
/** Random S256 verifier and challenge plus a random 32 byte base64url state. */
|
|
39
|
+
export function generatePkce() {
|
|
40
|
+
const verifier = crypto.randomBytes(64).toString('base64url');
|
|
41
|
+
return {
|
|
42
|
+
verifier,
|
|
43
|
+
challenge: s256Challenge(verifier),
|
|
44
|
+
state: crypto.randomBytes(32).toString('base64url'),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Builds the browser authorize URL for the ChatGPT sign-in. */
|
|
48
|
+
export function buildAuthorizeUrl(pkce, port, issuerBase = DEFAULT_ISSUER_BASE) {
|
|
49
|
+
const url = new URL('/oauth/authorize', issuerBase);
|
|
50
|
+
const params = url.searchParams;
|
|
51
|
+
params.set('response_type', 'code');
|
|
52
|
+
params.set('client_id', OPENAI_PUBLIC_CLIENT_ID);
|
|
53
|
+
params.set('redirect_uri', `http://localhost:${port}${CALLBACK_PATH}`);
|
|
54
|
+
params.set('scope', 'openid profile email offline_access');
|
|
55
|
+
params.set('code_challenge', pkce.challenge);
|
|
56
|
+
params.set('code_challenge_method', 'S256');
|
|
57
|
+
params.set('id_token_add_organizations', 'true');
|
|
58
|
+
params.set('codex_cli_simplified_flow', 'true');
|
|
59
|
+
params.set('state', pkce.state);
|
|
60
|
+
params.set('originator', PROTOCOL_ORIGINATOR);
|
|
61
|
+
return url.toString();
|
|
62
|
+
}
|
|
63
|
+
const SUCCESS_HTML = '<!doctype html><html><head><meta charset="utf-8"><title>Termi</title></head>' +
|
|
64
|
+
'<body style="font-family: sans-serif; text-align: center; padding-top: 4rem;">' +
|
|
65
|
+
'<h1>You are signed in!</h1><p>You can close this tab.</p></body></html>';
|
|
66
|
+
const FAILURE_HTML = '<!doctype html><html><head><meta charset="utf-8"><title>Termi</title></head>' +
|
|
67
|
+
'<body style="font-family: sans-serif; text-align: center; padding-top: 4rem;">' +
|
|
68
|
+
'<h1>That sign-in did not work.</h1><p>Close this tab and try again.</p></body></html>';
|
|
69
|
+
function tryListen(server, port) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const onError = (err) => {
|
|
72
|
+
server.removeListener('listening', onListening);
|
|
73
|
+
if (err.code === 'EADDRINUSE' || err.code === 'EACCES') {
|
|
74
|
+
resolve(null);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
reject(err);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const onListening = () => {
|
|
81
|
+
server.removeListener('error', onError);
|
|
82
|
+
const addr = server.address();
|
|
83
|
+
resolve(addr !== null && typeof addr === 'object' ? addr.port : port);
|
|
84
|
+
};
|
|
85
|
+
server.once('error', onError);
|
|
86
|
+
server.once('listening', onListening);
|
|
87
|
+
server.listen(port, '127.0.0.1');
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Starts the loopback callback server for the browser redirect.
|
|
92
|
+
* Tries preferredPort, then up to nine higher ports. Binds 127.0.0.1 only.
|
|
93
|
+
*/
|
|
94
|
+
export async function startCallbackServer(expectedState, preferredPort = 1455, timeoutMs = CALLBACK_TIMEOUT_MS) {
|
|
95
|
+
let settled = false;
|
|
96
|
+
let resolveCode;
|
|
97
|
+
let rejectCode;
|
|
98
|
+
const codePromise = new Promise((resolve, reject) => {
|
|
99
|
+
resolveCode = resolve;
|
|
100
|
+
rejectCode = reject;
|
|
101
|
+
});
|
|
102
|
+
// Keep an always-on handler so an unobserved rejection never crashes.
|
|
103
|
+
void codePromise.catch(() => undefined);
|
|
104
|
+
const settleResolve = (code) => {
|
|
105
|
+
if (!settled) {
|
|
106
|
+
settled = true;
|
|
107
|
+
resolveCode(code);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const settleReject = (err) => {
|
|
111
|
+
if (!settled) {
|
|
112
|
+
settled = true;
|
|
113
|
+
rejectCode(err);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const server = http.createServer((req, res) => {
|
|
117
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
118
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
119
|
+
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
120
|
+
res.end('Not found');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const errorParam = url.searchParams.get('error');
|
|
124
|
+
const state = url.searchParams.get('state');
|
|
125
|
+
const code = url.searchParams.get('code');
|
|
126
|
+
if (errorParam !== null) {
|
|
127
|
+
res.writeHead(400, { 'content-type': 'text/html' });
|
|
128
|
+
res.end(FAILURE_HTML, () => setImmediate(shutdown));
|
|
129
|
+
settleReject(new Error(`oauth-error:${errorParam}`));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (state === null || state !== expectedState || code === null || code.length === 0) {
|
|
133
|
+
res.writeHead(400, { 'content-type': 'text/html' });
|
|
134
|
+
res.end(FAILURE_HTML, () => setImmediate(shutdown));
|
|
135
|
+
settleReject(new Error('oauth-state-mismatch'));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
res.writeHead(200, { 'content-type': 'text/html' });
|
|
139
|
+
res.end(SUCCESS_HTML, () => setImmediate(shutdown));
|
|
140
|
+
settleResolve(code);
|
|
141
|
+
});
|
|
142
|
+
const timer = setTimeout(() => {
|
|
143
|
+
settleReject(new Error('oauth-callback-timeout'));
|
|
144
|
+
shutdown();
|
|
145
|
+
}, timeoutMs);
|
|
146
|
+
timer.unref();
|
|
147
|
+
function shutdown() {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
server.close();
|
|
150
|
+
server.closeAllConnections();
|
|
151
|
+
}
|
|
152
|
+
const candidates = preferredPort === 0
|
|
153
|
+
? [0]
|
|
154
|
+
: Array.from({ length: PORT_FALLBACK_RANGE + 1 }, (_, i) => preferredPort + i);
|
|
155
|
+
let boundPort = null;
|
|
156
|
+
for (const candidate of candidates) {
|
|
157
|
+
boundPort = await tryListen(server, candidate);
|
|
158
|
+
if (boundPort !== null) {
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (boundPort === null) {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
settleReject(new Error('oauth-callback-no-port'));
|
|
165
|
+
throw new Error('oauth-callback-no-port');
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
port: boundPort,
|
|
169
|
+
redirectUri: `http://localhost:${boundPort}${CALLBACK_PATH}`,
|
|
170
|
+
code: codePromise,
|
|
171
|
+
close() {
|
|
172
|
+
settleReject(new Error('oauth-callback-closed'));
|
|
173
|
+
shutdown();
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function asTokenSet(data) {
|
|
178
|
+
if (typeof data.access_token !== 'string' || data.access_token.length === 0) {
|
|
179
|
+
throw new Error('oauth-token-response-missing-access-token');
|
|
180
|
+
}
|
|
181
|
+
const set = { access_token: data.access_token };
|
|
182
|
+
if (typeof data.refresh_token === 'string' && data.refresh_token.length > 0) {
|
|
183
|
+
set.refresh_token = data.refresh_token;
|
|
184
|
+
}
|
|
185
|
+
if (typeof data.id_token === 'string' && data.id_token.length > 0) {
|
|
186
|
+
set.id_token = data.id_token;
|
|
187
|
+
}
|
|
188
|
+
if (typeof data.expires_in === 'number' && Number.isFinite(data.expires_in)) {
|
|
189
|
+
set.expires_in = data.expires_in;
|
|
190
|
+
}
|
|
191
|
+
return set;
|
|
192
|
+
}
|
|
193
|
+
/** Exchanges the authorization code for tokens. Form-encoded per the spec. */
|
|
194
|
+
export async function exchangeCode(code, pkce, redirectUri, fetchImpl = globalThis.fetch, issuerBase = DEFAULT_ISSUER_BASE) {
|
|
195
|
+
const body = new URLSearchParams({
|
|
196
|
+
grant_type: 'authorization_code',
|
|
197
|
+
code,
|
|
198
|
+
redirect_uri: redirectUri,
|
|
199
|
+
client_id: OPENAI_PUBLIC_CLIENT_ID,
|
|
200
|
+
code_verifier: pkce.verifier,
|
|
201
|
+
});
|
|
202
|
+
const res = await fetchImpl(`${issuerBase}/oauth/token`, {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
205
|
+
body: body.toString(),
|
|
206
|
+
});
|
|
207
|
+
if (!res.ok) {
|
|
208
|
+
throw new Error(`oauth-token-exchange-failed:http-${res.status}`);
|
|
209
|
+
}
|
|
210
|
+
const data = (await res.json());
|
|
211
|
+
return asTokenSet(data);
|
|
212
|
+
}
|
|
213
|
+
function jwtPayload(token) {
|
|
214
|
+
const segment = token.split('.')[1];
|
|
215
|
+
if (segment === undefined || segment.length === 0) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
const parsed = JSON.parse(Buffer.from(segment, 'base64url').toString('utf8'));
|
|
220
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
221
|
+
return parsed;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// Fall through to null.
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Plain base64url decode of the id_token payload. No signature check is
|
|
231
|
+
* needed client side: the token came straight from the issuer over TLS.
|
|
232
|
+
*/
|
|
233
|
+
export function decodeIdToken(idToken) {
|
|
234
|
+
const payload = jwtPayload(idToken);
|
|
235
|
+
if (payload === null) {
|
|
236
|
+
throw new Error('id-token-decode-failed');
|
|
237
|
+
}
|
|
238
|
+
const authClaim = payload['https://api.openai.com/auth'];
|
|
239
|
+
const claims = authClaim !== null && typeof authClaim === 'object' && !Array.isArray(authClaim)
|
|
240
|
+
? authClaim
|
|
241
|
+
: {};
|
|
242
|
+
return {
|
|
243
|
+
accountId: typeof claims.chatgpt_account_id === 'string' ? claims.chatgpt_account_id : '',
|
|
244
|
+
planType: typeof claims.chatgpt_plan_type === 'string' ? claims.chatgpt_plan_type : 'unknown',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const TERMINAL_REFRESH_PATTERN = /refresh_token_(expired|reused|invalidated)/;
|
|
248
|
+
/**
|
|
249
|
+
* Refreshes the token set. The refresh token rotates: callers must persist
|
|
250
|
+
* the new one before first use. Terminal failures throw kind "auth-dead".
|
|
251
|
+
*/
|
|
252
|
+
export async function refreshTokens(refreshToken, fetchImpl = globalThis.fetch, issuerBase = DEFAULT_ISSUER_BASE) {
|
|
253
|
+
const res = await fetchImpl(`${issuerBase}/oauth/token`, {
|
|
254
|
+
method: 'POST',
|
|
255
|
+
headers: { 'content-type': 'application/json' },
|
|
256
|
+
body: JSON.stringify({
|
|
257
|
+
client_id: OPENAI_PUBLIC_CLIENT_ID,
|
|
258
|
+
grant_type: 'refresh_token',
|
|
259
|
+
refresh_token: refreshToken,
|
|
260
|
+
}),
|
|
261
|
+
});
|
|
262
|
+
if (!res.ok) {
|
|
263
|
+
let bodyText = '';
|
|
264
|
+
try {
|
|
265
|
+
bodyText = await res.text();
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// Body is best effort; classification falls back to the status.
|
|
269
|
+
}
|
|
270
|
+
const terminal = bodyText.match(TERMINAL_REFRESH_PATTERN);
|
|
271
|
+
if (terminal !== null) {
|
|
272
|
+
throw new OAuthRefreshError('auth-dead', terminal[0]);
|
|
273
|
+
}
|
|
274
|
+
if (/invalid_grant/.test(bodyText)) {
|
|
275
|
+
throw new OAuthRefreshError('auth-dead', 'invalid_grant');
|
|
276
|
+
}
|
|
277
|
+
throw new OAuthRefreshError('transient', `http-${res.status}`);
|
|
278
|
+
}
|
|
279
|
+
const data = (await res.json());
|
|
280
|
+
return asTokenSet(data);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* RFC 8693 token exchange: tries to mint a platform API key from the
|
|
284
|
+
* id_token. Best effort only. Returns the key or null. Never throws.
|
|
285
|
+
*/
|
|
286
|
+
export async function mintApiKey(idToken, fetchImpl = globalThis.fetch, issuerBase = DEFAULT_ISSUER_BASE) {
|
|
287
|
+
try {
|
|
288
|
+
const body = new URLSearchParams({
|
|
289
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
|
|
290
|
+
client_id: OPENAI_PUBLIC_CLIENT_ID,
|
|
291
|
+
requested_token: 'openai-api-key',
|
|
292
|
+
subject_token: idToken,
|
|
293
|
+
subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
|
|
294
|
+
});
|
|
295
|
+
const res = await fetchImpl(`${issuerBase}/oauth/token`, {
|
|
296
|
+
method: 'POST',
|
|
297
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
298
|
+
body: body.toString(),
|
|
299
|
+
});
|
|
300
|
+
if (!res.ok) {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
const data = (await res.json());
|
|
304
|
+
for (const field of ['access_token', 'api_key', 'key']) {
|
|
305
|
+
const value = data[field];
|
|
306
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
307
|
+
return value;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
// Best effort: any failure simply means no minted key.
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
function computeExpiresAt(set, now) {
|
|
317
|
+
if (set.expires_in !== undefined) {
|
|
318
|
+
return now + set.expires_in * 1000;
|
|
319
|
+
}
|
|
320
|
+
const payload = jwtPayload(set.access_token);
|
|
321
|
+
const exp = payload?.exp;
|
|
322
|
+
if (typeof exp === 'number' && Number.isFinite(exp)) {
|
|
323
|
+
return exp * 1000;
|
|
324
|
+
}
|
|
325
|
+
return now + DEFAULT_LIFETIME_MS;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Full ChatGPT sign-in: callback server, authorize URL, browser, code
|
|
329
|
+
* exchange, best effort API key mint, then persist via tokens.ts.
|
|
330
|
+
*/
|
|
331
|
+
export async function loginWithChatGPT(opts = {}) {
|
|
332
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
333
|
+
const issuerBase = opts.issuerBase ?? DEFAULT_ISSUER_BASE;
|
|
334
|
+
const pkce = generatePkce();
|
|
335
|
+
const server = await startCallbackServer(pkce.state, opts.preferredPort ?? 1455, opts.timeoutMs ?? CALLBACK_TIMEOUT_MS);
|
|
336
|
+
try {
|
|
337
|
+
const authorizeUrl = buildAuthorizeUrl(pkce, server.port, issuerBase);
|
|
338
|
+
opts.onAuthorizeUrl?.(authorizeUrl);
|
|
339
|
+
if (opts.openBrowser !== false) {
|
|
340
|
+
try {
|
|
341
|
+
const mod = await import('open');
|
|
342
|
+
await mod.default(authorizeUrl);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
// The URL was already handed to onAuthorizeUrl; manual open still works.
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const code = await server.code;
|
|
349
|
+
const set = await exchangeCode(code, pkce, server.redirectUri, fetchImpl, issuerBase);
|
|
350
|
+
if (set.id_token === undefined) {
|
|
351
|
+
throw new Error('oauth-login-missing-id-token');
|
|
352
|
+
}
|
|
353
|
+
const info = decodeIdToken(set.id_token);
|
|
354
|
+
const minted = await mintApiKey(set.id_token, fetchImpl, issuerBase);
|
|
355
|
+
const now = Date.now();
|
|
356
|
+
const stored = {
|
|
357
|
+
provider: 'openai-chatgpt',
|
|
358
|
+
access_token: set.access_token,
|
|
359
|
+
refresh_token: set.refresh_token ?? '',
|
|
360
|
+
id_token: set.id_token,
|
|
361
|
+
account_id: info.accountId,
|
|
362
|
+
plan_type: info.planType,
|
|
363
|
+
expires_at: computeExpiresAt(set, now),
|
|
364
|
+
issued_at: now,
|
|
365
|
+
};
|
|
366
|
+
if (minted !== null) {
|
|
367
|
+
stored.minted_api_key = minted;
|
|
368
|
+
}
|
|
369
|
+
saveTokens(stored);
|
|
370
|
+
return { accountId: info.accountId, planType: info.planType };
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
server.close();
|
|
374
|
+
}
|
|
375
|
+
}
|