@karpeleslab/teamclaude 1.0.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/src/oauth.js ADDED
@@ -0,0 +1,220 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { randomBytes, createHash } from 'node:crypto';
4
+ import { exec } from 'node:child_process';
5
+ import http from 'node:http';
6
+
7
+ /**
8
+ * Import OAuth credentials from a Claude Code credentials file.
9
+ */
10
+ export async function importCredentials(filePath) {
11
+ const resolvedPath = filePath.replace(/^~/, homedir());
12
+ const raw = JSON.parse(await readFile(resolvedPath, 'utf-8'));
13
+
14
+ // Claude Code stores credentials nested under "claudeAiOauth"
15
+ const data = raw.claudeAiOauth || raw;
16
+ return {
17
+ accessToken: data.accessToken,
18
+ refreshToken: data.refreshToken,
19
+ expiresAt: data.expiresAt,
20
+ subscriptionType: data.subscriptionType,
21
+ rateLimitTier: data.rateLimitTier,
22
+ };
23
+ }
24
+
25
+ const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
26
+ const DEFAULT_TOKEN_ENDPOINT = 'https://platform.claude.com/v1/oauth/token';
27
+ const DEFAULT_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
28
+
29
+ /**
30
+ * Refresh an expired OAuth access token using the refresh token.
31
+ */
32
+ export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_ENDPOINT) {
33
+ const res = await fetch(endpoint, {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify({
37
+ grant_type: 'refresh_token',
38
+ refresh_token: refreshToken,
39
+ client_id: DEFAULT_CLIENT_ID,
40
+ }),
41
+ });
42
+
43
+ if (!res.ok) {
44
+ const text = await res.text();
45
+ throw new Error(`Token refresh failed (${res.status}): ${text}`);
46
+ }
47
+
48
+ const data = await res.json();
49
+ return {
50
+ accessToken: data.access_token,
51
+ refreshToken: data.refresh_token || refreshToken,
52
+ expiresAt: data.expires_at || (Date.now() + (data.expires_in || 3600) * 1000),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Check if an OAuth token is expiring within the given threshold.
58
+ */
59
+ export function isTokenExpiringSoon(expiresAt, thresholdMs = 5 * 60 * 1000) {
60
+ if (!expiresAt) return false;
61
+ return Date.now() + thresholdMs >= expiresAt;
62
+ }
63
+
64
+ /**
65
+ * Fetch account profile for an OAuth token.
66
+ * Returns { email, name, orgName, orgType } or null on failure.
67
+ */
68
+ export async function fetchProfile(accessToken) {
69
+ try {
70
+ const res = await fetch(PROFILE_URL, {
71
+ headers: { 'Authorization': `Bearer ${accessToken}` },
72
+ });
73
+ if (!res.ok) return null;
74
+ const data = await res.json();
75
+ return {
76
+ accountUuid: data.account?.uuid,
77
+ email: data.account?.email,
78
+ name: data.account?.display_name,
79
+ orgName: data.organization?.name,
80
+ orgType: data.organization?.organization_type,
81
+ hasClaudeMax: data.account?.has_claude_max,
82
+ hasClaudePro: data.account?.has_claude_pro,
83
+ };
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ // OAuth config (extracted from Claude Code)
90
+ const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
91
+ const OAUTH_AUTHORIZE = 'https://claude.ai/oauth/authorize';
92
+ const OAUTH_TOKEN = 'https://platform.claude.com/v1/oauth/token';
93
+ const OAUTH_SCOPES = 'org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload';
94
+
95
+ /**
96
+ * Perform OAuth login via browser with PKCE flow.
97
+ * Opens the user's browser, waits for the callback, exchanges the code for tokens.
98
+ */
99
+ export async function loginOAuth() {
100
+ // Generate PKCE
101
+ const codeVerifier = randomBytes(32).toString('base64url');
102
+ const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
103
+ const state = randomBytes(32).toString('base64url');
104
+
105
+ // Start local callback server on a random port
106
+ const { port, codePromise, server } = await startCallbackServer(state);
107
+ const redirectUri = `http://localhost:${port}/callback`;
108
+
109
+ // Build authorization URL
110
+ const authUrl = new URL(OAUTH_AUTHORIZE);
111
+ authUrl.searchParams.set('code', 'true');
112
+ authUrl.searchParams.set('client_id', OAUTH_CLIENT_ID);
113
+ authUrl.searchParams.set('response_type', 'code');
114
+ authUrl.searchParams.set('redirect_uri', redirectUri);
115
+ authUrl.searchParams.set('scope', OAUTH_SCOPES);
116
+ authUrl.searchParams.set('code_challenge', codeChallenge);
117
+ authUrl.searchParams.set('code_challenge_method', 'S256');
118
+ authUrl.searchParams.set('state', state);
119
+
120
+ // Open browser
121
+ console.log('Opening browser for authentication...');
122
+ console.log(`If it doesn't open, visit:\n ${authUrl.toString()}\n`);
123
+ openBrowser(authUrl.toString());
124
+
125
+ // Wait for the authorization code
126
+ let code;
127
+ try {
128
+ code = await codePromise;
129
+ } finally {
130
+ server.close();
131
+ }
132
+
133
+ // Exchange code for tokens
134
+ console.log('Exchanging authorization code for tokens...');
135
+ const tokenRes = await fetch(OAUTH_TOKEN, {
136
+ method: 'POST',
137
+ headers: { 'Content-Type': 'application/json' },
138
+ body: JSON.stringify({
139
+ code,
140
+ state,
141
+ grant_type: 'authorization_code',
142
+ client_id: OAUTH_CLIENT_ID,
143
+ redirect_uri: redirectUri,
144
+ code_verifier: codeVerifier,
145
+ }),
146
+ });
147
+
148
+ if (!tokenRes.ok) {
149
+ const text = await tokenRes.text();
150
+ throw new Error(`Token exchange failed (${tokenRes.status}): ${text}`);
151
+ }
152
+
153
+ const tokens = await tokenRes.json();
154
+ return {
155
+ accessToken: tokens.access_token,
156
+ refreshToken: tokens.refresh_token,
157
+ expiresAt: tokens.expires_at || (Date.now() + (tokens.expires_in || 3600) * 1000),
158
+ };
159
+ }
160
+
161
+ function startCallbackServer(expectedState) {
162
+ return new Promise((resolve, reject) => {
163
+ let resolveCode, rejectCode;
164
+ const codePromise = new Promise((res, rej) => { resolveCode = res; rejectCode = rej; });
165
+
166
+ const server = http.createServer((req, res) => {
167
+ const url = new URL(req.url, `http://localhost`);
168
+
169
+ if (url.pathname === '/callback') {
170
+ const code = url.searchParams.get('code');
171
+ const error = url.searchParams.get('error');
172
+ const state = url.searchParams.get('state');
173
+
174
+ if (error) {
175
+ res.writeHead(200, { 'Content-Type': 'text/html' });
176
+ res.end('<html><body><h2>Authentication failed</h2><p>You can close this tab.</p></body></html>');
177
+ rejectCode(new Error(`OAuth error: ${error} - ${url.searchParams.get('error_description') || ''}`));
178
+ return;
179
+ }
180
+
181
+ if (expectedState && state !== expectedState) {
182
+ res.writeHead(200, { 'Content-Type': 'text/html' });
183
+ res.end('<html><body><h2>Authentication failed</h2><p>State mismatch. You can close this tab.</p></body></html>');
184
+ rejectCode(new Error('OAuth state mismatch'));
185
+ return;
186
+ }
187
+
188
+ if (code) {
189
+ res.writeHead(302, { 'Location': 'https://platform.claude.com/oauth/code/success?app=claude-code' });
190
+ res.end();
191
+ resolveCode(code);
192
+ return;
193
+ }
194
+ }
195
+
196
+ res.writeHead(404);
197
+ res.end('Not found');
198
+ });
199
+
200
+ server.listen(0, () => {
201
+ resolve({ port: server.address().port, codePromise, server });
202
+ });
203
+ server.on('error', reject);
204
+
205
+ // Timeout after 2 minutes (unref so it doesn't keep the process alive)
206
+ const timer = setTimeout(() => {
207
+ rejectCode(new Error('Login timed out after 2 minutes'));
208
+ server.close();
209
+ }, 120_000);
210
+ timer.unref();
211
+ });
212
+ }
213
+
214
+ function openBrowser(url) {
215
+ const platform = process.platform;
216
+ const cmd = platform === 'darwin' ? 'open'
217
+ : platform === 'win32' ? 'start'
218
+ : 'xdg-open';
219
+ exec(`${cmd} ${JSON.stringify(url)}`, () => {});
220
+ }
package/src/server.js ADDED
@@ -0,0 +1,351 @@
1
+ import http from 'node:http';
2
+ import { writeFile, mkdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+
5
+ const HOP_BY_HOP_HEADERS = new Set([
6
+ 'host', 'connection', 'keep-alive', 'transfer-encoding',
7
+ 'te', 'trailer', 'upgrade', 'proxy-authorization', 'proxy-authenticate',
8
+ ]);
9
+
10
+ export function createProxyServer(accountManager, config, hooks = {}) {
11
+ const upstream = config.upstream || 'https://api.anthropic.com';
12
+ const proxyApiKey = config.proxy?.apiKey;
13
+ const logDir = config.logDir || null;
14
+ let requestCounter = 0;
15
+
16
+ if (logDir) {
17
+ mkdir(logDir, { recursive: true }).catch(() => {});
18
+ }
19
+
20
+ const server = http.createServer(async (req, res) => {
21
+ try {
22
+ // Auth check
23
+ const clientKey = req.headers['x-api-key'];
24
+ if (proxyApiKey && clientKey !== proxyApiKey) {
25
+ res.writeHead(401, { 'Content-Type': 'application/json' });
26
+ res.end(JSON.stringify({
27
+ type: 'error',
28
+ error: { type: 'authentication_error', message: 'Invalid proxy API key' },
29
+ }));
30
+ return;
31
+ }
32
+
33
+ // Status endpoint
34
+ if (req.method === 'GET' && req.url === '/teamclaude/status') {
35
+ res.writeHead(200, { 'Content-Type': 'application/json' });
36
+ res.end(JSON.stringify(accountManager.getStatus(), null, 2));
37
+ return;
38
+ }
39
+
40
+ // Track request
41
+ const reqId = ++requestCounter;
42
+ hooks.onRequestStart?.(reqId, { method: req.method, path: req.url });
43
+
44
+ // Buffer request body (needed for retry on 429)
45
+ const bodyChunks = [];
46
+ for await (const chunk of req) {
47
+ bodyChunks.push(chunk);
48
+ }
49
+ const body = Buffer.concat(bodyChunks);
50
+
51
+ const ctx = { account: null, status: null };
52
+ await forwardRequest(req, res, body, accountManager, upstream, 0, hooks, reqId, ctx, logDir);
53
+
54
+ hooks.onRequestEnd?.(reqId, {
55
+ method: req.method, path: req.url,
56
+ account: ctx.account, status: ctx.status,
57
+ });
58
+ } catch (err) {
59
+ console.error('[TeamClaude] Unhandled error:', err);
60
+ if (!res.headersSent) {
61
+ res.writeHead(502, { 'Content-Type': 'application/json' });
62
+ res.end(JSON.stringify({
63
+ type: 'error',
64
+ error: { type: 'proxy_error', message: 'Internal proxy error' },
65
+ }));
66
+ }
67
+ }
68
+ });
69
+
70
+ return server;
71
+ }
72
+
73
+ function logTimestamp() {
74
+ const d = new Date();
75
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
76
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
77
+ }
78
+
79
+ async function writeRequestLog(logDir, reqId, sections) {
80
+ if (!logDir) return;
81
+ const ts = logTimestamp();
82
+ const filename = `${ts}_${String(reqId).padStart(5, '0')}.log`;
83
+ try {
84
+ await writeFile(join(logDir, filename), sections.join('\n\n'), 'utf-8');
85
+ } catch (err) {
86
+ console.error(`[TeamClaude] Failed to write log: ${err.message}`);
87
+ }
88
+ }
89
+
90
+ function formatHeaders(headers) {
91
+ if (headers.entries) {
92
+ return [...headers.entries()].map(([k, v]) => ` ${k}: ${v}`).join('\n');
93
+ }
94
+ return Object.entries(headers).map(([k, v]) => ` ${k}: ${v}`).join('\n');
95
+ }
96
+
97
+ async function forwardRequest(req, res, body, accountManager, upstream, retryCount, hooks, reqId, ctx, logDir) {
98
+ const maxRetries = accountManager.accounts.length;
99
+
100
+ // Select account
101
+ const account = accountManager.getActiveAccount();
102
+ if (!account) {
103
+ ctx.status = 429;
104
+ const status = accountManager.getStatus();
105
+ const retryAfter = computeRetryAfter(status.accounts);
106
+ res.writeHead(429, {
107
+ 'Content-Type': 'application/json',
108
+ 'retry-after': String(retryAfter),
109
+ });
110
+ res.end(JSON.stringify({
111
+ type: 'error',
112
+ error: {
113
+ type: 'rate_limit_error',
114
+ message: `All ${accountManager.accounts.length} accounts exhausted. Retry in ${retryAfter}s.`,
115
+ },
116
+ }));
117
+ return;
118
+ }
119
+
120
+ // Track which account handles this request
121
+ ctx.account = account.name;
122
+ hooks.onRequestRouted?.(reqId, { account: account.name });
123
+
124
+ // Refresh OAuth token if needed
125
+ await accountManager.ensureTokenFresh(account.index);
126
+ if (account.status === 'error' && retryCount < maxRetries) {
127
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
128
+ }
129
+
130
+ // Build upstream request headers
131
+ const headers = {};
132
+ for (const [key, value] of Object.entries(req.headers)) {
133
+ const lk = key.toLowerCase();
134
+ if (HOP_BY_HOP_HEADERS.has(lk)) continue;
135
+ if (lk === 'x-api-key') continue;
136
+ // Strip accept-encoding: Node fetch auto-decompresses, which would
137
+ // mismatch the Content-Encoding header we forward to the client
138
+ if (lk === 'accept-encoding') continue;
139
+ headers[key] = value;
140
+ }
141
+ headers['x-api-key'] = account.credential;
142
+
143
+ const upstreamUrl = `${upstream}${req.url}`;
144
+ const method = req.method;
145
+
146
+ // Build log sections
147
+ const logSections = [];
148
+ if (logDir) {
149
+ const safeHeaders = { ...headers };
150
+ // Mask the credential in logs
151
+ if (safeHeaders['x-api-key']) {
152
+ safeHeaders['x-api-key'] = safeHeaders['x-api-key'].slice(0, 15) + '...';
153
+ }
154
+ logSections.push(
155
+ `=== REQUEST (account: ${account.name}, retry: ${retryCount}) ===\n${method} ${upstreamUrl}\n${formatHeaders(safeHeaders)}`,
156
+ );
157
+ if (body.length > 0) {
158
+ try {
159
+ logSections.push(`=== REQUEST BODY ===\n${JSON.stringify(JSON.parse(body.toString()), null, 2)}`);
160
+ } catch {
161
+ logSections.push(`=== REQUEST BODY (${body.length} bytes) ===\n${body.toString().slice(0, 4096)}`);
162
+ }
163
+ }
164
+ }
165
+
166
+ try {
167
+ const upstreamRes = await fetch(upstreamUrl, {
168
+ method,
169
+ headers,
170
+ body: ['GET', 'HEAD'].includes(method) ? undefined : body,
171
+ redirect: 'manual',
172
+ });
173
+
174
+ // Extract rate limit headers
175
+ const rateLimitHeaders = {};
176
+ for (const [key, value] of upstreamRes.headers.entries()) {
177
+ if (key.startsWith('anthropic-ratelimit-')) {
178
+ rateLimitHeaders[key] = value;
179
+ }
180
+ }
181
+ accountManager.updateQuota(account.index, rateLimitHeaders);
182
+
183
+ // Log response headers
184
+ if (logDir) {
185
+ logSections.push(`=== RESPONSE ${upstreamRes.status} ===\n${formatHeaders(upstreamRes.headers)}`);
186
+ }
187
+
188
+ // Handle 429 — retry with next account
189
+ if (upstreamRes.status === 429 && retryCount < maxRetries) {
190
+ const retryAfter = parseInt(upstreamRes.headers.get('retry-after') || '60', 10);
191
+ accountManager.markRateLimited(account.index, retryAfter);
192
+ const drainBuf = await upstreamRes.arrayBuffer();
193
+ if (logDir) {
194
+ logSections.push(`=== RESPONSE BODY (429) ===\n${Buffer.from(drainBuf).toString()}`);
195
+ logSections.push(`=== RETRYING with next account ===`);
196
+ writeRequestLog(logDir, reqId, logSections);
197
+ }
198
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
199
+ }
200
+
201
+ ctx.status = upstreamRes.status;
202
+
203
+ // Build response headers (skip hop-by-hop and encoding headers)
204
+ const responseHeaders = {};
205
+ for (const [key, value] of upstreamRes.headers.entries()) {
206
+ if (key === 'transfer-encoding' || key === 'connection') continue;
207
+ // Strip content-encoding/content-length since fetch may auto-decompress
208
+ if (key === 'content-encoding' || key === 'content-length') continue;
209
+ responseHeaders[key] = value;
210
+ }
211
+
212
+ res.writeHead(upstreamRes.status, responseHeaders);
213
+
214
+ if (!upstreamRes.body) {
215
+ if (logDir) {
216
+ logSections.push(`=== RESPONSE BODY ===\n(empty)`);
217
+ writeRequestLog(logDir, reqId, logSections);
218
+ }
219
+ res.end();
220
+ return;
221
+ }
222
+
223
+ const isStreaming = (upstreamRes.headers.get('content-type') || '').includes('text/event-stream');
224
+
225
+ if (isStreaming) {
226
+ const streamLog = logDir ? [] : null;
227
+ await streamResponse(upstreamRes.body, res, account.index, accountManager, streamLog);
228
+ if (logDir) {
229
+ logSections.push(`=== RESPONSE BODY (streamed) ===\n${streamLog.join('')}`);
230
+ writeRequestLog(logDir, reqId, logSections);
231
+ }
232
+ } else {
233
+ const buf = Buffer.from(await upstreamRes.arrayBuffer());
234
+ extractUsageFromBody(buf, account.index, accountManager);
235
+ if (logDir) {
236
+ try {
237
+ logSections.push(`=== RESPONSE BODY ===\n${JSON.stringify(JSON.parse(buf.toString()), null, 2)}`);
238
+ } catch {
239
+ logSections.push(`=== RESPONSE BODY (${buf.length} bytes) ===\n${buf.toString().slice(0, 8192)}`);
240
+ }
241
+ writeRequestLog(logDir, reqId, logSections);
242
+ }
243
+ res.end(buf);
244
+ }
245
+ } catch (err) {
246
+ console.error(`[TeamClaude] Upstream error (account "${account.name}"):`, err.message);
247
+
248
+ if (logDir) {
249
+ logSections.push(`=== ERROR ===\n${err.stack || err.message}`);
250
+ writeRequestLog(logDir, reqId, logSections);
251
+ }
252
+
253
+ if (retryCount < maxRetries) {
254
+ account.status = 'error';
255
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
256
+ }
257
+ ctx.status = 502;
258
+
259
+ if (!res.headersSent) {
260
+ res.writeHead(502, { 'Content-Type': 'application/json' });
261
+ res.end(JSON.stringify({
262
+ type: 'error',
263
+ error: { type: 'proxy_error', message: `Upstream error: ${err.message}` },
264
+ }));
265
+ }
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Stream an SSE response to the client, parsing usage data along the way.
271
+ */
272
+ async function streamResponse(webStream, res, accountIndex, accountManager, streamLog) {
273
+ const reader = webStream.getReader();
274
+ const decoder = new TextDecoder();
275
+ let sseBuffer = '';
276
+
277
+ try {
278
+ while (true) {
279
+ const { done, value } = await reader.read();
280
+ if (done) break;
281
+
282
+ // Forward chunk immediately
283
+ const ok = res.write(value);
284
+
285
+ const text = decoder.decode(value, { stream: true });
286
+
287
+ // Capture for logging
288
+ if (streamLog) streamLog.push(text);
289
+
290
+ // Parse SSE events for usage tracking
291
+ sseBuffer += text;
292
+ const events = sseBuffer.split('\n\n');
293
+ sseBuffer = events.pop(); // keep incomplete event
294
+
295
+ for (const event of events) {
296
+ parseSSEUsage(event, accountIndex, accountManager);
297
+ }
298
+
299
+ // Handle backpressure
300
+ if (!ok) {
301
+ await new Promise(resolve => res.once('drain', resolve));
302
+ }
303
+ }
304
+
305
+ // Parse any remaining buffer
306
+ if (sseBuffer.trim()) {
307
+ parseSSEUsage(sseBuffer, accountIndex, accountManager);
308
+ }
309
+ } finally {
310
+ res.end();
311
+ }
312
+ }
313
+
314
+ function parseSSEUsage(event, accountIndex, accountManager) {
315
+ const dataLine = event.split('\n').find(l => l.startsWith('data: '));
316
+ if (!dataLine) return;
317
+
318
+ try {
319
+ const data = JSON.parse(dataLine.slice(6));
320
+ if (data.type === 'message_start' && data.message?.usage) {
321
+ accountManager.updateUsage(accountIndex, data.message.usage.input_tokens, 0);
322
+ } else if (data.type === 'message_delta' && data.usage) {
323
+ accountManager.updateUsage(accountIndex, 0, data.usage.output_tokens);
324
+ }
325
+ } catch {
326
+ // not valid JSON, skip
327
+ }
328
+ }
329
+
330
+ function extractUsageFromBody(buffer, accountIndex, accountManager) {
331
+ try {
332
+ const json = JSON.parse(buffer.toString());
333
+ if (json.usage) {
334
+ accountManager.updateUsage(accountIndex, json.usage.input_tokens, json.usage.output_tokens);
335
+ }
336
+ } catch {
337
+ // not JSON or no usage
338
+ }
339
+ }
340
+
341
+ function computeRetryAfter(accounts) {
342
+ let soonest = Infinity;
343
+ for (const acct of accounts) {
344
+ const reset = acct.rateLimitedUntil || acct.quota.resetsAt;
345
+ if (reset) {
346
+ const ms = new Date(reset).getTime() - Date.now();
347
+ if (ms < soonest) soonest = ms;
348
+ }
349
+ }
350
+ return soonest === Infinity ? 60 : Math.max(1, Math.ceil(soonest / 1000));
351
+ }