teamclaude-cloud 1.4.1 → 1.4.3
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/package.json +2 -2
- package/src/cloud.js +34 -0
- package/src/index.js +144 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "teamclaude-cloud",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.3",
|
|
4
4
|
"description": "Multi-account Claude proxy with quota-based rotation + cloud token sync and auth-key control",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -35,4 +35,4 @@
|
|
|
35
35
|
"engines": {
|
|
36
36
|
"node": ">=18.0.0"
|
|
37
37
|
}
|
|
38
|
-
}
|
|
38
|
+
}
|
package/src/cloud.js
CHANGED
|
@@ -154,6 +154,19 @@ export async function cloudLogin(cloud, email, password) {
|
|
|
154
154
|
};
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Verify a session token actually authorizes as a cloud owner (GET /me). Used by
|
|
159
|
+
* `cloud login --token` to reject a forged/expired token BEFORE overwriting an
|
|
160
|
+
* existing working login. Returns true only on a 2xx owner response.
|
|
161
|
+
*/
|
|
162
|
+
export async function cloudValidateOwner(cloud) {
|
|
163
|
+
requireSession(cloud);
|
|
164
|
+
const res = await httpJson(`${fnBase(cloud)}/me`, {
|
|
165
|
+
headers: fnHeaders(cloud, { Authorization: `Bearer ${cloud.session.access_token}` }),
|
|
166
|
+
});
|
|
167
|
+
return res.ok;
|
|
168
|
+
}
|
|
169
|
+
|
|
157
170
|
/** Refresh an expired owner session. */
|
|
158
171
|
export async function cloudRefreshSession(cloud, refreshToken) {
|
|
159
172
|
assertSecureUrl(cloud.url);
|
|
@@ -305,6 +318,27 @@ export async function cloudRefreshToken(cloud, authKey, accountUuid, minRemainin
|
|
|
305
318
|
};
|
|
306
319
|
}
|
|
307
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Report a locally-rotated token back to the cloud (auth-key authenticated).
|
|
323
|
+
* When a consumer had to refresh a token LOCALLY (cloud refresh unreachable), it
|
|
324
|
+
* MUST push the rotated token back — otherwise the cloud keeps a now-dead refresh
|
|
325
|
+
* token and every other consumer (and the cloud's own /sync/refresh) fails with
|
|
326
|
+
* invalid_grant. Freshest-token-wins on the server, scoped to the key's group.
|
|
327
|
+
* Best-effort at the call site: a failure to report never breaks the local refresh.
|
|
328
|
+
*/
|
|
329
|
+
export async function cloudReportToken(cloud, authKey, accountUuid, { accessToken, refreshToken, expiresAt } = {}) {
|
|
330
|
+
requireCloud(cloud);
|
|
331
|
+
if (!authKey) throw new Error('Auth key required for token report.');
|
|
332
|
+
if (!accountUuid) throw new Error('accountUuid required for token report.');
|
|
333
|
+
const res = await httpJson(`${fnBase(cloud)}/sync/report-token`, {
|
|
334
|
+
method: 'POST',
|
|
335
|
+
headers: fnHeaders(cloud, { 'x-teamclaude-key': authKey }),
|
|
336
|
+
body: { accountUuid, accessToken, refreshToken, expiresAt: normalizeExpiresAt(expiresAt) || 0 },
|
|
337
|
+
});
|
|
338
|
+
if (!res.ok) throw new Error(`Cloud token report failed (${res.status}): ${describe(res.data)}`);
|
|
339
|
+
return { action: res.data.action || 'skipped' };
|
|
340
|
+
}
|
|
341
|
+
|
|
308
342
|
/**
|
|
309
343
|
* Pull allowed accounts+tokens using an auth key (no owner session needed).
|
|
310
344
|
* Returns `{ accounts, allowed, groupId, source }`:
|
package/src/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConf
|
|
|
7
7
|
import { AccountManager } from './account-manager.js';
|
|
8
8
|
import { createProxyServer } from './server.js';
|
|
9
9
|
import { importCredentials, loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
|
|
10
|
-
import { cloudLogin, cloudRefreshSession, cloudPush, cloudPushUsage, cloudReportUsage, cloudPull, cloudRefreshToken, cloudKeyCreate, cloudKeyList, cloudKeyRevoke, cloudKeyMove, cloudGroupList, cloudGroupCreate, cloudGroupRename, cloudGroupDelete, cloudGroupSetAccounts, cloudAccountSetEnabled, buildUsageFromQuota, mergePulledAccounts, resolveCloud, DEFAULT_CLOUD, keySource, reconcileRemovedAccounts } from './cloud.js';
|
|
10
|
+
import { cloudLogin, cloudValidateOwner, cloudRefreshSession, cloudPush, cloudPushUsage, cloudReportUsage, cloudPull, cloudRefreshToken, cloudReportToken, cloudKeyCreate, cloudKeyList, cloudKeyRevoke, cloudKeyMove, cloudGroupList, cloudGroupCreate, cloudGroupRename, cloudGroupDelete, cloudGroupSetAccounts, cloudAccountSetEnabled, buildUsageFromQuota, mergePulledAccounts, resolveCloud, DEFAULT_CLOUD, keySource, reconcileRemovedAccounts } from './cloud.js';
|
|
11
11
|
import { TUI } from './tui.js';
|
|
12
12
|
|
|
13
13
|
const args = process.argv.slice(2);
|
|
@@ -1432,23 +1432,52 @@ const _autoPushInFlight = new Set();
|
|
|
1432
1432
|
*/
|
|
1433
1433
|
async function maybePushRefreshedToken(config, account, newTokens) {
|
|
1434
1434
|
const key = account.accountUuid || account.name;
|
|
1435
|
+
const session = config.cloud && config.cloud.session;
|
|
1436
|
+
// An owner session is USABLE if its access token is still valid OR it carries a
|
|
1437
|
+
// refresh token (ensureCloudSession can renew it). A token-only login (Google/SSO)
|
|
1438
|
+
// has NO refresh token, so once it expires it's unusable — treating it as usable
|
|
1439
|
+
// would 401 the push and skip the key fallback, leaving the cloud with a dead
|
|
1440
|
+
// refresh token (Codex HIGH). expires_at is seconds.
|
|
1441
|
+
const ownerUsable = !!(session && session.access_token && (
|
|
1442
|
+
(Number(session.expires_at || 0) * 1000) > Date.now() + 30_000 ||
|
|
1443
|
+
(session.refresh_token && String(session.refresh_token).length > 0)
|
|
1444
|
+
));
|
|
1445
|
+
const pullKey = config.cloud?.pullKey || process.env.TEAMCLAUDE_AUTH_KEY;
|
|
1446
|
+
const canKey = !!(pullKey && account.accountUuid);
|
|
1447
|
+
if (!ownerUsable && !canKey) return; // nowhere to sync back to
|
|
1435
1448
|
try {
|
|
1436
|
-
if (!config.cloud || !config.cloud.session || !config.cloud.session.access_token) return; // not an admin/pusher
|
|
1437
1449
|
if (_autoPushInFlight.has(key)) return;
|
|
1438
1450
|
_autoPushInFlight.add(key);
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1451
|
+
if (ownerUsable) {
|
|
1452
|
+
try {
|
|
1453
|
+
const cloud = await ensureCloudSession(config).catch(() => config.cloud);
|
|
1454
|
+
await cloudPush(cloud, [{
|
|
1455
|
+
accountUuid: account.accountUuid,
|
|
1456
|
+
name: account.name,
|
|
1457
|
+
subscriptionType: account.subscriptionType,
|
|
1458
|
+
tier: account.tier,
|
|
1459
|
+
type: account.type,
|
|
1460
|
+
accessToken: newTokens.accessToken,
|
|
1461
|
+
refreshToken: newTokens.refreshToken,
|
|
1462
|
+
expiresAt: newTokens.expiresAt,
|
|
1463
|
+
}]);
|
|
1464
|
+
return;
|
|
1465
|
+
} catch (e) {
|
|
1466
|
+
// Owner push failed (e.g. the short-lived token-login expired) — fall back
|
|
1467
|
+
// to the key path so the rotation still reaches the cloud.
|
|
1468
|
+
if (!canKey) throw e;
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
if (canKey) {
|
|
1472
|
+
// Consumer / expired-owner path: key-authenticated write-back (freshest-wins).
|
|
1473
|
+
await cloudReportToken(resolveCloud(config), pullKey, account.accountUuid, {
|
|
1474
|
+
accessToken: newTokens.accessToken,
|
|
1475
|
+
refreshToken: newTokens.refreshToken,
|
|
1476
|
+
expiresAt: newTokens.expiresAt,
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1450
1479
|
} catch (err) {
|
|
1451
|
-
console.error(`[TeamClaude] Cloud
|
|
1480
|
+
console.error(`[TeamClaude] Cloud token sync-back failed for ${account.name}: ${err.message}`);
|
|
1452
1481
|
} finally {
|
|
1453
1482
|
_autoPushInFlight.delete(key);
|
|
1454
1483
|
}
|
|
@@ -1457,14 +1486,34 @@ async function maybePushRefreshedToken(config, account, newTokens) {
|
|
|
1457
1486
|
// ── cloud (SaaS token sync + auth-key control) ──────────────────
|
|
1458
1487
|
|
|
1459
1488
|
// Refresh the owner session if it is expiring; persist the new session.
|
|
1489
|
+
let _cloudSessionRefresh = null; // coalesce concurrent owner-session refreshes
|
|
1460
1490
|
async function ensureCloudSession(config) {
|
|
1461
1491
|
const cloud = config.cloud;
|
|
1462
1492
|
if (!cloud || !cloud.session || !cloud.session.access_token) return cloud;
|
|
1463
1493
|
const expMs = (Number(cloud.session.expires_at) || 0) * 1000; // Supabase expires_at is seconds
|
|
1464
1494
|
if (expMs && Date.now() + 60_000 >= expMs && cloud.session.refresh_token) {
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1495
|
+
// The owner session has ONE single-use rotating Supabase refresh token. If two
|
|
1496
|
+
// account rotations refresh it concurrently, one wins and the other trips reuse
|
|
1497
|
+
// detection and loses its push (Codex HIGH). Serialize: all concurrent callers
|
|
1498
|
+
// share the SAME in-flight refresh instead of each rotating independently.
|
|
1499
|
+
if (!_cloudSessionRefresh) {
|
|
1500
|
+
const rt = cloud.session.refresh_token;
|
|
1501
|
+
_cloudSessionRefresh = (async () => {
|
|
1502
|
+
try {
|
|
1503
|
+
const s = await cloudRefreshSession(cloud, rt);
|
|
1504
|
+
cloud.session = s;
|
|
1505
|
+
await atomicConfigUpdate(c => { c.cloud = { ...(c.cloud || {}), session: s }; });
|
|
1506
|
+
return s;
|
|
1507
|
+
} finally {
|
|
1508
|
+
_cloudSessionRefresh = null;
|
|
1509
|
+
}
|
|
1510
|
+
})();
|
|
1511
|
+
}
|
|
1512
|
+
// NOTE: concurrent refreshes WITHIN this process share the single in-flight
|
|
1513
|
+
// promise above (one rotation). Cross-PROCESS coordination is intentionally not
|
|
1514
|
+
// done here — the codebase assumes a single proxy per config (see
|
|
1515
|
+
// atomicConfigUpdate's documented cross-process limitation).
|
|
1516
|
+
await _cloudSessionRefresh;
|
|
1468
1517
|
}
|
|
1469
1518
|
return cloud;
|
|
1470
1519
|
}
|
|
@@ -1484,6 +1533,7 @@ async function cloudCommand() {
|
|
|
1484
1533
|
console.log('Cloud commands (SaaS token sync + auth-key control):');
|
|
1485
1534
|
console.log(' teamclaude cloud pull --key <auth-key> Pull allowed tokens (key-only — no login)');
|
|
1486
1535
|
console.log(' teamclaude cloud login --email <e> [--password <p>] (admin, to push)');
|
|
1536
|
+
console.log(' teamclaude cloud login --token (Google/SSO — paste the token from the dashboard at the prompt)');
|
|
1487
1537
|
console.log(' teamclaude cloud push Push local account tokens to the cloud');
|
|
1488
1538
|
console.log(' teamclaude cloud key create [--label <l>] [--accounts uuid1,uuid2]');
|
|
1489
1539
|
console.log(' teamclaude cloud key list');
|
|
@@ -1493,11 +1543,88 @@ async function cloudCommand() {
|
|
|
1493
1543
|
}
|
|
1494
1544
|
}
|
|
1495
1545
|
|
|
1546
|
+
// Decode a JWT payload (base64url) without verifying — we only read exp/email to
|
|
1547
|
+
// stamp the stored session; the server verifies the token on every request.
|
|
1548
|
+
function decodeJwtPayload(token) {
|
|
1549
|
+
try {
|
|
1550
|
+
const part = String(token).split('.')[1] || '';
|
|
1551
|
+
const b64 = part.replace(/-/g, '+').replace(/_/g, '/');
|
|
1552
|
+
return JSON.parse(Buffer.from(b64, 'base64').toString('utf8')) || {};
|
|
1553
|
+
} catch {
|
|
1554
|
+
return {};
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1496
1558
|
async function cloudLoginCommand(config) {
|
|
1497
1559
|
// url/anon-key default to the built-in public endpoint — only --email/--password
|
|
1498
1560
|
// are required for the owner (admin) login.
|
|
1499
1561
|
const url = argValue('--url') || config.cloud?.url || process.env.SUPABASE_URL || DEFAULT_CLOUD.url;
|
|
1500
1562
|
const anonKey = argValue('--anon-key') || config.cloud?.anonKey || process.env.SUPABASE_ANON_KEY || DEFAULT_CLOUD.anonKey;
|
|
1563
|
+
|
|
1564
|
+
// Token login: for Google / SSO owners who have NO password. The dashboard's
|
|
1565
|
+
// "Copy CLI token" button copies just the access token; run `teamclaude cloud
|
|
1566
|
+
// login --token` and PASTE it at the prompt. Reading from stdin keeps the owner
|
|
1567
|
+
// token out of argv / shell history (Codex HIGH). A `--token <value>` (or env)
|
|
1568
|
+
// is still accepted for scripting.
|
|
1569
|
+
const tokenFlag = args.includes('--token');
|
|
1570
|
+
const tokenArg = argValue('--token');
|
|
1571
|
+
let token = (tokenArg && tokenArg !== '-' && !tokenArg.startsWith('--')) ? tokenArg : process.env.TEAMCLAUDE_CLOUD_TOKEN || '';
|
|
1572
|
+
if (!token && tokenFlag) {
|
|
1573
|
+
if (process.stdin.isTTY) {
|
|
1574
|
+
// Read WITHOUT echo via raw mode — the owner token must never land in the
|
|
1575
|
+
// terminal's visible scrollback or a screen recording (Codex HIGH). Raw mode
|
|
1576
|
+
// disables the TTY driver's echo; we buffer chars ourselves and print nothing.
|
|
1577
|
+
token = (await new Promise((resolve) => {
|
|
1578
|
+
process.stderr.write('Paste cloud access token (input hidden): ');
|
|
1579
|
+
const stdin = process.stdin;
|
|
1580
|
+
stdin.setRawMode?.(true);
|
|
1581
|
+
stdin.resume();
|
|
1582
|
+
stdin.setEncoding('utf8');
|
|
1583
|
+
let buf = '';
|
|
1584
|
+
const onData = (chunk) => {
|
|
1585
|
+
for (const ch of chunk) {
|
|
1586
|
+
if (ch === '\n' || ch === '\r' || ch === '\u0004') { // Enter / EOF -> done
|
|
1587
|
+
stdin.removeListener('data', onData);
|
|
1588
|
+
stdin.setRawMode?.(false); stdin.pause();
|
|
1589
|
+
process.stderr.write('\n');
|
|
1590
|
+
return resolve(buf);
|
|
1591
|
+
}
|
|
1592
|
+
if (ch === '\u0003') { stdin.setRawMode?.(false); process.stderr.write('\n'); process.exit(1); } // Ctrl-C
|
|
1593
|
+
else if (ch === '\u007f' || ch === '\b') buf = buf.slice(0, -1); // backspace
|
|
1594
|
+
else buf += ch;
|
|
1595
|
+
}
|
|
1596
|
+
};
|
|
1597
|
+
stdin.on('data', onData);
|
|
1598
|
+
})).trim();
|
|
1599
|
+
} else {
|
|
1600
|
+
// Piped (e.g. pbpaste | teamclaude cloud login --token): read from stdin.
|
|
1601
|
+
token = (await new Promise((resolve) => {
|
|
1602
|
+
let d = ''; process.stdin.on('data', c => (d += c)); process.stdin.on('end', () => resolve(d));
|
|
1603
|
+
})).trim();
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
if (token) {
|
|
1607
|
+
const payload = decodeJwtPayload(token);
|
|
1608
|
+
const expSec = Number(payload.exp) || Math.floor(Date.now() / 1000) + 3600; // JWT exp (seconds)
|
|
1609
|
+
const email = payload.email || payload?.user_metadata?.email || config.cloud?.email || null;
|
|
1610
|
+
// Access token ONLY — never store the browser's refresh token. Supabase refresh
|
|
1611
|
+
// tokens are single-use rotating; sharing one between the browser and the CLI
|
|
1612
|
+
// would make them rotate-conflict and revoke each other (reuse detection). So
|
|
1613
|
+
// this is a short-lived (~1h) login — enough to push; re-copy when it expires.
|
|
1614
|
+
const session = { access_token: token, refresh_token: '', expires_at: expSec };
|
|
1615
|
+
// Validate the token actually authorizes as an owner BEFORE overwriting any
|
|
1616
|
+
// existing login — a forged/expired token (or a bogus future `exp`) must not
|
|
1617
|
+
// silently replace a working session.
|
|
1618
|
+
const ok = await cloudValidateOwner({ url, anonKey, session }).catch(() => false);
|
|
1619
|
+
if (!ok) {
|
|
1620
|
+
console.error('Token login failed — the token is invalid or expired. Re-copy "CLI login" from the web dashboard.');
|
|
1621
|
+
process.exit(1);
|
|
1622
|
+
}
|
|
1623
|
+
await atomicConfigUpdate(c => { c.cloud = { ...(c.cloud || {}), url, anonKey, email, session }; });
|
|
1624
|
+
console.log(`Logged in to cloud${email ? ` as ${email}` : ''} (token, short-lived ~1h — re-copy from the dashboard when it expires).`);
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1501
1628
|
const email = argValue('--email') || config.cloud?.email || process.env.TEAMCLAUDE_CLOUD_EMAIL;
|
|
1502
1629
|
let password = argValue('--password') || process.env.TEAMCLAUDE_CLOUD_PASSWORD;
|
|
1503
1630
|
if (!email) {
|