@karpeleslab/teamclaude 1.0.6 → 1.0.8

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.
@@ -0,0 +1,65 @@
1
+ // Streaming JSON pretty-printer (no regex, no buffering of the whole body).
2
+ //
3
+ // Built on the same idea as the account_uuid patcher: walk the bytes once,
4
+ // tracking only enough state (nesting depth, in-string, escape) to know where
5
+ // we are, and re-emit with indentation as we go. This lets the request logger
6
+ // flush a readable body to disk *as it streams* — so when a request blocks
7
+ // mid-flight, the partial (pretty) body is already on disk and you can see how
8
+ // far it got. Bodies can be ~1M tokens, so we never hold more than the current
9
+ // chunk.
10
+ //
11
+ // Whitespace outside strings is dropped and re-inserted; strings (including any
12
+ // whitespace/escapes inside them) are copied verbatim. Operates on latin1 so a
13
+ // multi-byte UTF-8 sequence split across chunks is preserved byte-for-byte.
14
+ export class JsonStreamFormatter {
15
+ constructor(indent = 2) {
16
+ this.pad = ' '.repeat(indent);
17
+ this.depth = 0;
18
+ this.inStr = false;
19
+ this.esc = false;
20
+ this.freshContainer = false; // just opened { or [ — first element needs a newline+indent
21
+ this.started = false;
22
+ }
23
+
24
+ nl(depth) { return '\n' + this.pad.repeat(depth); }
25
+
26
+ // Feed a chunk; returns the formatted text for that chunk.
27
+ push(buf) {
28
+ const text = Buffer.isBuffer(buf) ? buf.toString('latin1') : String(buf);
29
+ let out = '';
30
+ for (let i = 0; i < text.length; i++) {
31
+ const ch = text[i];
32
+
33
+ if (this.inStr) {
34
+ out += ch;
35
+ if (this.esc) this.esc = false;
36
+ else if (ch === '\\') this.esc = true;
37
+ else if (ch === '"') this.inStr = false;
38
+ continue;
39
+ }
40
+
41
+ // Outside a string: collapse existing whitespace; we re-insert our own.
42
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') continue;
43
+
44
+ if (ch === '}' || ch === ']') {
45
+ this.depth--;
46
+ // Empty container: emit "{}" / "[]" with no inner newline.
47
+ if (this.freshContainer) { this.freshContainer = false; out += ch; }
48
+ else out += this.nl(this.depth) + ch;
49
+ continue;
50
+ }
51
+
52
+ // Any other token. If it's the first token inside a just-opened
53
+ // container, break the line and indent first.
54
+ if (this.freshContainer) { out += this.nl(this.depth); this.freshContainer = false; }
55
+
56
+ if (ch === '{' || ch === '[') { out += ch; this.depth++; this.freshContainer = true; continue; }
57
+ if (ch === ',') { out += ',' + this.nl(this.depth); continue; }
58
+ if (ch === ':') { out += ': '; continue; }
59
+ if (ch === '"') { this.inStr = true; out += ch; continue; }
60
+ out += ch; // number / true / false / null character
61
+ }
62
+ this.started = this.started || out.length > 0;
63
+ return out;
64
+ }
65
+ }
package/src/mitm.js ADDED
@@ -0,0 +1,387 @@
1
+ // MITM forward-proxy support: local cert lifecycle + transparent CONNECT relay.
2
+ //
3
+ // When a claude instance is launched with HTTPS_PROXY pointed at teamclaude it
4
+ // sends `CONNECT api.anthropic.com:443`. We act as a transparent MITM: dial the
5
+ // real upstream first (mirroring SNI + adopting its negotiated ALPN), present
6
+ // claude our locally-minted leaf advertising that same protocol, then relay the
7
+ // decrypted stream — rewriting ONLY the auth header (h2 via our HPACK codec, h1
8
+ // via a plaintext head edit) and reading quota from responses. Everything else
9
+ // is copied as-is. A host routing table decides per-CONNECT behavior:
10
+ // api.anthropic.com → rewrite, www.example.org → local test server,
11
+ // anything else → blind tunnel.
12
+
13
+ import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
14
+ import { X509Certificate } from 'node:crypto';
15
+ import { dirname, join } from 'node:path';
16
+ import net from 'node:net';
17
+ import tls from 'node:tls';
18
+ import { getConfigPath } from './config.js';
19
+ import { generateCertChain } from './x509.js';
20
+ import { h2Relay, h1Relay, rewriteH1Auth } from './h2/relay.js';
21
+ import { AccountUuidPatcher } from './account-uuid-rewrite.js';
22
+ import { makeMitmTap, makeActivityTap, combineTaps } from './request-log.js';
23
+ import { tunnelTls } from './sx.js';
24
+
25
+ const CA_CERT = 'teamclaude-ca.pem';
26
+ const LEAF_CERT = 'teamclaude-leaf.pem';
27
+ const LEAF_KEY = 'teamclaude-leaf.key';
28
+
29
+ // A built-in host the MITM proxy always intercepts and answers itself (never
30
+ // forwarded upstream). Lets you verify the proxy + CA end-to-end with no
31
+ // credentials, e.g.:
32
+ // curl --proxy http://localhost:3456 --cacert <ca.pem> https://www.example.org/
33
+ export const TEST_HOST = 'www.example.org';
34
+
35
+ const certDir = () => dirname(getConfigPath());
36
+ const fpath = (n) => join(certDir(), n);
37
+
38
+ /** Path to the CA cert clients should trust via NODE_EXTRA_CA_CERTS. */
39
+ export function caCertPath() {
40
+ return fpath(CA_CERT);
41
+ }
42
+
43
+ async function readIf(p) {
44
+ try { return await readFile(p, 'utf8'); } catch { return null; }
45
+ }
46
+
47
+ async function atomicWrite(path, data, mode) {
48
+ const tmp = `${path}.tmp${process.pid}`;
49
+ await writeFile(tmp, data, { mode });
50
+ await rename(tmp, path);
51
+ }
52
+
53
+ // Is the stored leaf signed by the stored CA and valid for every host in `hosts`?
54
+ function leafCovers(caCertPem, leafCertPem, hosts) {
55
+ try {
56
+ const ca = new X509Certificate(caCertPem);
57
+ const leaf = new X509Certificate(leafCertPem);
58
+ if (!leaf.verify(ca.publicKey)) return false;
59
+ const names = (leaf.subjectAltName || '').split(',').map((s) => s.trim());
60
+ return hosts.every((h) => names.includes(`DNS:${h}`));
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Ensure a CA cert + a leaf for `host` exist in the config dir, generating them
68
+ * if missing/mismatched. The CA *private* key is never persisted — we regenerate
69
+ * the whole chain when needed, so the only on-disk secret is the leaf key (0600),
70
+ * which only authenticates as `host` to a process that already trusts our CA.
71
+ * Returns { caPath, caCertPem, leafCertPem, leafKeyPem }.
72
+ */
73
+ export async function ensureCerts(host) {
74
+ const hosts = host === TEST_HOST ? [TEST_HOST] : [host, TEST_HOST];
75
+ const [caCertPem, leafCertPem, leafKeyPem] = await Promise.all([
76
+ readIf(fpath(CA_CERT)), readIf(fpath(LEAF_CERT)), readIf(fpath(LEAF_KEY)),
77
+ ]);
78
+
79
+ if (caCertPem && leafCertPem && leafKeyPem && leafCovers(caCertPem, leafCertPem, hosts)) {
80
+ return { caPath: fpath(CA_CERT), caCertPem, leafCertPem, leafKeyPem };
81
+ }
82
+
83
+ const chain = generateCertChain(hosts); // caKeyPem intentionally discarded
84
+ await mkdir(certDir(), { recursive: true });
85
+ await atomicWrite(fpath(CA_CERT), chain.caCertPem, 0o644);
86
+ await atomicWrite(fpath(LEAF_CERT), chain.leafCertPem, 0o644);
87
+ await atomicWrite(fpath(LEAF_KEY), chain.leafKeyPem, 0o600);
88
+ return {
89
+ caPath: fpath(CA_CERT),
90
+ caCertPem: chain.caCertPem,
91
+ leafCertPem: chain.leafCertPem,
92
+ leafKeyPem: chain.leafKeyPem,
93
+ };
94
+ }
95
+
96
+ function upstreamHostOf(config) {
97
+ try { return new URL(config?.upstream || 'https://api.anthropic.com').hostname; }
98
+ catch { return 'api.anthropic.com'; }
99
+ }
100
+
101
+ /** Per-CONNECT behavior: 'rewrite' (intercept + token inject), 'test', or 'tunnel'. */
102
+ export function hostMode(host, config) {
103
+ if (host === TEST_HOST) return 'test';
104
+ if (host === upstreamHostOf(config)) return 'rewrite';
105
+ return 'tunnel';
106
+ }
107
+
108
+ /**
109
+ * Build a `connect` event handler implementing the transparent MITM described
110
+ * at the top of this file.
111
+ * @param ensureLeaf async () => { key, cert } // current leaf PEMs
112
+ */
113
+ export function createConnectHandler({ config, accountManager, ensureLeaf, upstreamTlsOptions = {}, logDir = null, hooks = {}, log = () => {}, sx = null }) {
114
+ return (req, clientSocket, head) => {
115
+ clientSocket.on('error', () => {});
116
+ const [host, portStr] = (req.url || '').split(':');
117
+ const port = parseInt(portStr, 10) || 443;
118
+ const mode = hostMode(host, config);
119
+
120
+ if (mode === 'tunnel') {
121
+ const up = net.connect(port, host, () => {
122
+ reply200Raw(clientSocket);
123
+ if (head && head.length) up.write(head);
124
+ up.pipe(clientSocket); clientSocket.pipe(up);
125
+ });
126
+ up.on('error', () => clientSocket.destroy());
127
+ return;
128
+ }
129
+
130
+ intercept({ host, port, mode, clientSocket, head, accountManager, ensureLeaf, upstreamTlsOptions, logDir, hooks, log, sx })
131
+ .catch((err) => { log(`[TeamClaude] MITM ${host}: ${err.message}`); clientSocket.destroy(); });
132
+ };
133
+ }
134
+
135
+ async function intercept({ host, port, mode, clientSocket, head, accountManager, ensureLeaf, upstreamTlsOptions, logDir, hooks = {}, log, sx }) {
136
+ const { key, cert } = await ensureLeaf();
137
+
138
+ if (mode === 'test') {
139
+ reply200Raw(clientSocket);
140
+ const tlsSock = termClaude(clientSocket, head, key, cert, ['http/1.1']);
141
+ serveTest(tlsSock);
142
+ return;
143
+ }
144
+
145
+ // rewrite: be a faithful ALPN mirror. We don't terminate TLS at the byte
146
+ // level (that's node:tls), so to avoid imposing our own protocol preference we
147
+ // peek the client's ClientHello, read the exact ALPN list it offers, present
148
+ // THAT list to the upstream, let the upstream pick, then terminate the client
149
+ // with whatever the upstream selected. The upstream decides — constrained to
150
+ // what the client can speak — so an http/1.1-only client (undici/claude) and
151
+ // an h2 client both negotiate end-to-end exactly as they would directly.
152
+ const account = accountManager.getActiveAccount();
153
+ if (!account) { clientSocket.destroy(); return; }
154
+ await accountManager.ensureTokenFresh(account.index);
155
+
156
+ reply200Raw(clientSocket);
157
+ const offered = await peekClientAlpn(clientSocket, head); // client's ALPN list, or null
158
+
159
+ // When sx.org is enabled, dial the upstream THROUGH its proxy (different egress
160
+ // IP — the IP-based-429 workaround); TLS still terminates end-to-end at the
161
+ // upstream, so the proxy sees only ciphertext. Otherwise connect directly.
162
+ // autoSelectFamily (happy-eyeballs) is the default on Node 20+ but not 18; set
163
+ // it explicitly so a dual-stack upstream whose IPv6 path is unreachable falls
164
+ // back to IPv4 instead of hanging the connect (option ignored pre-18.13).
165
+ let upstreamSock;
166
+ if (sx?.useForConnect()) {
167
+ upstreamSock = await tunnelTls({
168
+ proxy: sx.getProxy(), targetHost: host, targetPort: port,
169
+ tlsOptions: { ...(offered ? { ALPNProtocols: offered } : {}), ...upstreamTlsOptions },
170
+ });
171
+ } else {
172
+ upstreamSock = tls.connect({
173
+ host, port, servername: host, autoSelectFamily: true,
174
+ ...(offered ? { ALPNProtocols: offered } : {}),
175
+ ...upstreamTlsOptions,
176
+ });
177
+ await new Promise((resolve, reject) => {
178
+ upstreamSock.once('secureConnect', resolve);
179
+ upstreamSock.once('error', reject);
180
+ });
181
+ }
182
+ const upstreamAlpn = upstreamSock.alpnProtocol; // false if none negotiated
183
+ const alpn = upstreamAlpn || 'http/1.1'; // relay protocol selector
184
+
185
+ // Terminate the client advertising exactly what the upstream chose (a subset
186
+ // of what the client offered, so it always overlaps).
187
+ const claudeTls = new tls.TLSSocket(clientSocket, {
188
+ isServer: true, key, cert,
189
+ ...(upstreamAlpn ? { ALPNProtocols: [upstreamAlpn] } : {}),
190
+ });
191
+ claudeTls.on('error', () => { claudeTls.destroy(); upstreamSock.destroy(); });
192
+ upstreamSock.on('error', () => claudeTls.destroy());
193
+ await new Promise((resolve, reject) => {
194
+ claudeTls.once('secure', resolve);
195
+ claudeTls.once('error', reject);
196
+ });
197
+
198
+ // Per-stream streaming body patcher: align metadata.user_id.account_uuid with
199
+ // the injected account (same length; no-op if the account has no UUID).
200
+ const makeBodyPatcher = account.accountUuid
201
+ ? () => new AccountUuidPatcher(account.accountUuid)
202
+ : null;
203
+ // Fan request lifecycle out to the on-disk log (when configured) AND the live
204
+ // TUI activity feed, so MITM traffic is visible in the TUI like reverse-proxy
205
+ // traffic — not just on disk.
206
+ const tap = combineTaps(makeMitmTap(logDir, account.name), makeActivityTap(hooks, account.name));
207
+
208
+ if (alpn === 'h2') {
209
+ h2Relay(claudeTls, upstreamSock, {
210
+ rewriteRequest: makeRewriteRequest(account),
211
+ makeBodyPatcher,
212
+ onResponseHeaders: makeQuotaObserver(accountManager, account, sx),
213
+ tap,
214
+ log,
215
+ });
216
+ } else {
217
+ const auth = account.type === 'oauth'
218
+ ? { authorization: `Bearer ${account.credential}` }
219
+ : { apiKey: account.credential };
220
+ h1Relay(claudeTls, upstreamSock, {
221
+ rewriteHead: (h) => rewriteH1Auth(h, auth),
222
+ makeBodyPatcher,
223
+ onResponseHeaders: makeQuotaObserver(accountManager, account, sx),
224
+ tap,
225
+ });
226
+ }
227
+ }
228
+
229
+ function reply200Raw(sock) { sock.write('HTTP/1.1 200 Connection Established\r\n\r\n'); }
230
+
231
+ function termClaude(clientSocket, head, key, cert, alpn) {
232
+ if (head && head.length) clientSocket.unshift(head);
233
+ const t = new tls.TLSSocket(clientSocket, { isServer: true, key, cert, ALPNProtocols: alpn });
234
+ t.on('error', () => t.destroy());
235
+ return t;
236
+ }
237
+
238
+ // Read the client's first TLS record (the ClientHello) to learn the ALPN
239
+ // protocol list it offers, WITHOUT consuming it: bytes are unshifted back so the
240
+ // real TLS handshake still sees the full ClientHello. Returns the protocol
241
+ // string array, or null if there is no ALPN extension / it can't be parsed (in
242
+ // which case we offer the upstream no ALPN and mirror its no-ALPN result).
243
+ function peekClientAlpn(sock, head) {
244
+ return new Promise((resolve) => {
245
+ let buf = head && head.length ? Buffer.from(head) : Buffer.alloc(0);
246
+ let done = false;
247
+ const finish = (result) => {
248
+ if (done) return; done = true;
249
+ sock.removeListener('readable', onReadable);
250
+ sock.removeListener('end', onEnd);
251
+ sock.removeListener('error', onEnd);
252
+ if (buf.length) sock.unshift(buf); // put the ClientHello back for node:tls
253
+ resolve(result);
254
+ };
255
+ const tryParse = () => {
256
+ const r = parseClientHelloAlpn(buf);
257
+ if (r === undefined && buf.length < 16384) return false; // need more bytes
258
+ finish(Array.isArray(r) ? r : null);
259
+ return true;
260
+ };
261
+ // Read in PAUSED mode (readable/read) — never switch the socket to flowing,
262
+ // or the TLSSocket we hand it to afterwards won't receive the unshifted bytes.
263
+ const onReadable = () => {
264
+ let chunk;
265
+ while ((chunk = sock.read()) !== null) {
266
+ buf = Buffer.concat([buf, chunk]);
267
+ if (tryParse()) return;
268
+ }
269
+ };
270
+ const onEnd = () => finish(null);
271
+ sock.on('readable', onReadable);
272
+ sock.once('end', onEnd);
273
+ sock.once('error', onEnd);
274
+ if (tryParse()) return; // head may already contain the whole ClientHello
275
+ onReadable(); // drain anything already buffered
276
+ });
277
+ }
278
+
279
+ // Parse the ALPN protocol list out of a TLS ClientHello.
280
+ // returns string[] — ALPN extension present
281
+ // returns null — parsed far enough to know there is no usable ALPN
282
+ // returns undefined — need more bytes to decide
283
+ export function parseClientHelloAlpn(buf) {
284
+ if (buf.length < 5) return undefined;
285
+ if (buf[0] !== 0x16) return null; // not a handshake record
286
+ const recEnd = 5 + buf.readUInt16BE(3);
287
+ if (buf.length < recEnd) return undefined; // wait for the full record
288
+ let p = 5;
289
+ if (buf[p] !== 0x01) return null; // not a ClientHello
290
+ const hsEnd = p + 4 + ((buf[p + 1] << 16) | (buf[p + 2] << 8) | buf[p + 3]);
291
+ const end = Math.min(hsEnd, recEnd);
292
+ p += 4;
293
+ p += 2 + 32; // client_version + random
294
+ if (p >= end) return null;
295
+ p += 1 + buf[p]; // session_id
296
+ if (p + 2 > end) return null;
297
+ p += 2 + buf.readUInt16BE(p); // cipher_suites
298
+ if (p + 1 > end) return null;
299
+ p += 1 + buf[p]; // compression_methods
300
+ if (p + 2 > end) return null;
301
+ let extEnd = p + 2 + buf.readUInt16BE(p);
302
+ p += 2;
303
+ extEnd = Math.min(extEnd, end);
304
+ while (p + 4 <= extEnd) {
305
+ const type = buf.readUInt16BE(p);
306
+ const len = buf.readUInt16BE(p + 2);
307
+ p += 4;
308
+ if (type === 0x0010) { // application_layer_protocol_negotiation
309
+ let q = p + 2; // skip ALPN list length
310
+ const listEnd = Math.min(p + len, extEnd);
311
+ const protos = [];
312
+ while (q < listEnd) {
313
+ const l = buf[q]; q += 1;
314
+ if (q + l > listEnd) break;
315
+ protos.push(buf.toString('latin1', q, q + l));
316
+ q += l;
317
+ }
318
+ return protos.length ? protos : null;
319
+ }
320
+ p += len;
321
+ }
322
+ return null; // no ALPN extension
323
+ }
324
+
325
+ // Rewrite only the auth field on an h2 request header list (account token in,
326
+ // client's x-api-key out). Preserves order; marks auth never-indexed.
327
+ function makeRewriteRequest(account) {
328
+ const isOAuth = account.type === 'oauth';
329
+ return (fields) => {
330
+ let replaced = false;
331
+ const out = [];
332
+ for (const f of fields) {
333
+ const n = f.name.toString().toLowerCase();
334
+ if (n === 'x-api-key') continue;
335
+ if (n === 'authorization') {
336
+ if (isOAuth) { out.push({ name: f.name, value: Buffer.from(`Bearer ${account.credential}`), sensitive: true }); replaced = true; }
337
+ continue;
338
+ }
339
+ out.push(f);
340
+ }
341
+ if (!replaced) {
342
+ out.push(isOAuth
343
+ ? { name: Buffer.from('authorization'), value: Buffer.from(`Bearer ${account.credential}`), sensitive: true }
344
+ : { name: Buffer.from('x-api-key'), value: Buffer.from(account.credential), sensitive: true });
345
+ }
346
+ return out;
347
+ };
348
+ }
349
+
350
+ function makeQuotaObserver(accountManager, account, sx = null) {
351
+ return (fields) => {
352
+ const m = {};
353
+ for (const f of fields) m[f.name.toString().toLowerCase()] = f.value.toString();
354
+ if (!m[':status']) return;
355
+ const rl = {};
356
+ for (const k in m) if (k.startsWith('anthropic-ratelimit-')) rl[k] = m[k];
357
+ if (Object.keys(rl).length) accountManager.updateQuota(account.index, rl);
358
+ if (m[':status'] === '429') {
359
+ let ra = parseInt(m['retry-after'], 10);
360
+ if (Number.isNaN(ra)) ra = 60;
361
+ ra = Math.min(Math.max(ra, 1), 300);
362
+ accountManager.markRateLimited(account.index, ra);
363
+ // Arm sx.org sticky routing so the next MITM tunnel uses the proxy (in
364
+ // '429' mode); no-op in 'off'/'always'.
365
+ sx?.noteRateLimited(ra);
366
+ }
367
+ };
368
+ }
369
+
370
+ // Answer the built-in test host locally over h1 with a canned JSON response.
371
+ function serveTest(tlsSock) {
372
+ let buf = Buffer.alloc(0);
373
+ const onData = (chunk) => {
374
+ buf = Buffer.concat([buf, chunk]);
375
+ const idx = buf.indexOf('\r\n\r\n');
376
+ if (idx < 0) { if (buf.length > 65536) tlsSock.destroy(); return; }
377
+ tlsSock.removeListener('data', onData);
378
+ const reqLine = buf.subarray(0, buf.indexOf('\r\n')).toString('latin1');
379
+ const path = reqLine.split(' ')[1] || '/';
380
+ const body = JSON.stringify({ teamclaude: 'mitm-proxy-ok', host: TEST_HOST, path });
381
+ tlsSock.end(
382
+ `HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: ${Buffer.byteLength(body)}\r\nconnection: close\r\n\r\n${body}`,
383
+ );
384
+ };
385
+ tlsSock.on('data', onData);
386
+ tlsSock.on('error', () => tlsSock.destroy());
387
+ }
package/src/oauth.js CHANGED
@@ -24,6 +24,8 @@ export async function importCredentials(filePath) {
24
24
  }
25
25
 
26
26
  const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
27
+ const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
28
+ const OAUTH_USAGE_BETA = 'oauth-2025-04-20';
27
29
  const DEFAULT_TOKEN_ENDPOINT = 'https://platform.claude.com/v1/oauth/token';
28
30
  const DEFAULT_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
29
31
 
@@ -62,7 +64,13 @@ export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_
62
64
  continue;
63
65
  }
64
66
  const text = await res.text();
65
- throw new Error(`Token refresh failed (${res.status}): ${text}`);
67
+ const err = new Error(`Token refresh failed (${res.status}): ${text}`);
68
+ // Surface the HTTP status so callers can distinguish a genuine auth
69
+ // rejection (the refresh token is dead — re-login needed) from a
70
+ // transient server error. 5xx is retried above; reaching here with a 5xx
71
+ // means retries were exhausted, which is still transient, not auth.
72
+ err.status = res.status;
73
+ throw err;
66
74
  }
67
75
 
68
76
  const data = await res.json();
@@ -129,6 +137,7 @@ export async function fetchProfile(accessToken) {
129
137
  accountUuid: data.account?.uuid,
130
138
  email: data.account?.email,
131
139
  name: data.account?.display_name,
140
+ orgUuid: data.organization?.uuid,
132
141
  orgName: data.organization?.name,
133
142
  orgType: data.organization?.organization_type,
134
143
  hasClaudeMax: data.account?.has_claude_max,
@@ -139,6 +148,73 @@ export async function fetchProfile(accessToken) {
139
148
  }
140
149
  }
141
150
 
151
+ // Normalize one usage bucket from the /api/oauth/usage payload into
152
+ // { utilization: 0-1, resetAt: ms-epoch }. Tolerant of field-name and
153
+ // percentage/fraction and seconds/ms variations across payload versions.
154
+ export function normalizeUsageBucket(bucket) {
155
+ if (!bucket || typeof bucket !== 'object') return null;
156
+
157
+ const rawPct = bucket.used_percentage ?? bucket.utilization ?? bucket.usedPercentage;
158
+ const parsedPct = typeof rawPct === 'number' ? rawPct : parseFloat(rawPct);
159
+ const utilization = Number.isFinite(parsedPct)
160
+ ? (parsedPct > 1 ? parsedPct / 100 : parsedPct)
161
+ : null;
162
+
163
+ const rawReset = bucket.resets_at ?? bucket.resetsAt ?? bucket.reset_at ?? bucket.resetAt;
164
+ let resetAt = null;
165
+ if (typeof rawReset === 'number') {
166
+ resetAt = rawReset < 1e12 ? rawReset * 1000 : rawReset;
167
+ } else if (typeof rawReset === 'string') {
168
+ const asNum = Number(rawReset);
169
+ if (Number.isFinite(asNum) && rawReset.trim() !== '') {
170
+ resetAt = asNum < 1e12 ? asNum * 1000 : asNum;
171
+ } else {
172
+ const parsed = Date.parse(rawReset);
173
+ if (Number.isFinite(parsed)) resetAt = parsed;
174
+ }
175
+ }
176
+
177
+ return { utilization, resetAt };
178
+ }
179
+
180
+ /**
181
+ * Fetch OAuth subscription usage from the usage endpoint. This reports quota
182
+ * utilization WITHOUT spending message quota, which is what makes it safe to
183
+ * poll. Returns normalized { fiveHour, sevenDay, sevenDaySonnet } buckets, or
184
+ * { error, status } on failure.
185
+ */
186
+ export async function fetchUsage(accessToken) {
187
+ try {
188
+ const res = await fetch(USAGE_URL, {
189
+ headers: {
190
+ 'Authorization': `Bearer ${accessToken}`,
191
+ 'anthropic-beta': OAUTH_USAGE_BETA,
192
+ 'Accept': 'application/json',
193
+ },
194
+ });
195
+
196
+ if (!res.ok) {
197
+ let detail = '';
198
+ try {
199
+ const body = await res.json();
200
+ detail = body?.error?.message || JSON.stringify(body).slice(0, 200);
201
+ } catch {
202
+ detail = await res.text().catch(() => '');
203
+ }
204
+ return { error: `HTTP ${res.status}${detail ? ': ' + detail : ''}`, status: res.status };
205
+ }
206
+
207
+ const data = await res.json();
208
+ return {
209
+ fiveHour: normalizeUsageBucket(data?.five_hour),
210
+ sevenDay: normalizeUsageBucket(data?.seven_day),
211
+ sevenDaySonnet: normalizeUsageBucket(data?.seven_day_sonnet),
212
+ };
213
+ } catch (err) {
214
+ return { error: err.message || String(err), status: null };
215
+ }
216
+ }
217
+
142
218
  // OAuth config (extracted from Claude Code)
143
219
  const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
144
220
  const OAUTH_AUTHORIZE = 'https://claude.ai/oauth/authorize';
package/src/prober.js ADDED
@@ -0,0 +1,82 @@
1
+ // Opt-in background quota probe.
2
+ //
3
+ // DISABLED BY DEFAULT. When enabled (config.quotaProbeSeconds > 0), periodically
4
+ // reads each OAuth account's quota from the zero-spend /api/oauth/usage endpoint
5
+ // so idle accounts' utilization/reset stay fresh without waiting to be rotated
6
+ // to — and without consuming any message quota. This is the one sanctioned
7
+ // active-upstream feature; the proxy is otherwise passive.
8
+
9
+ import { fetchUsage } from './oauth.js';
10
+
11
+ export class Prober {
12
+ constructor(accountManager, { intervalMs = 0, probeFn = fetchUsage, timeoutMs = 10_000, log = console.log } = {}) {
13
+ this.am = accountManager;
14
+ this.intervalMs = intervalMs;
15
+ this.probeFn = probeFn;
16
+ this.timeoutMs = timeoutMs;
17
+ this.log = log;
18
+ this.timer = null;
19
+ this._running = false;
20
+ }
21
+
22
+ start() {
23
+ if (this.intervalMs > 0) this.reschedule(this.intervalMs);
24
+ }
25
+
26
+ /** Change the interval at runtime (0 = off). Probes once immediately when on. */
27
+ reschedule(intervalMs) {
28
+ const wasOn = this.intervalMs > 0 && this.timer;
29
+ this.intervalMs = intervalMs;
30
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
31
+
32
+ if (intervalMs > 0) {
33
+ // Probe right away so quota populates without waiting a full cycle.
34
+ this.probeAll().catch(() => {});
35
+ this.timer = setInterval(() => this.probeAll().catch(() => {}), intervalMs);
36
+ this.timer.unref?.();
37
+ this.log(`[TeamClaude] Quota probe enabled (every ${Math.round(intervalMs / 1000)}s)`);
38
+ } else if (wasOn) {
39
+ this.log('[TeamClaude] Quota probe disabled');
40
+ }
41
+ }
42
+
43
+ stop() {
44
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
45
+ }
46
+
47
+ /** Probe every OAuth account once. Overlapping cycles are skipped. */
48
+ async probeAll() {
49
+ if (this._running) return;
50
+ this._running = true;
51
+ try {
52
+ const accounts = this.am.accounts.filter(a => a.type === 'oauth' && a.credential);
53
+ await Promise.all(accounts.map(a => this.probeOne(a)));
54
+ } finally {
55
+ this._running = false;
56
+ }
57
+ }
58
+
59
+ async probeOne(account) {
60
+ try {
61
+ await this.am.ensureTokenFresh(account.index);
62
+ let usage = await this._withTimeout(this.probeFn(account.credential));
63
+ if (usage?.status === 401) {
64
+ // Token rejected — force a refresh and retry once.
65
+ await this.am.ensureTokenFresh(account.index, true);
66
+ usage = await this._withTimeout(this.probeFn(account.credential));
67
+ }
68
+ if (!usage || usage.error) return; // transient — try again next cycle
69
+ this.am.applyUsageData(account.index, usage);
70
+ } catch { /* best-effort; never let a probe throw */ }
71
+ }
72
+
73
+ _withTimeout(promise) {
74
+ return Promise.race([
75
+ promise,
76
+ new Promise(resolve => {
77
+ const t = setTimeout(() => resolve(null), this.timeoutMs);
78
+ t.unref?.();
79
+ }),
80
+ ]);
81
+ }
82
+ }