teamclaude-cloud 1.4.2 → 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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/cloud.js +13 -0
  3. package/src/index.js +136 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamclaude-cloud",
3
- "version": "1.4.2",
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",
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);
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, cloudReportToken, 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,30 +1432,44 @@ const _autoPushInFlight = new Set();
1432
1432
  */
1433
1433
  async function maybePushRefreshedToken(config, account, newTokens) {
1434
1434
  const key = account.accountUuid || account.name;
1435
- const hasOwner = !!(config.cloud && config.cloud.session && config.cloud.session.access_token);
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
+ ));
1436
1445
  const pullKey = config.cloud?.pullKey || process.env.TEAMCLAUDE_AUTH_KEY;
1437
- // Nothing to sync back to unless we're an admin (owner session) OR a consumer
1438
- // with a pull key. A consumer that rotated a token LOCALLY (cloud refresh was
1439
- // unreachable) MUST report it back, or the cloud keeps a now-dead refresh token
1440
- // and every consumer — plus the cloud's own /sync/refresh — fails invalid_grant.
1441
- if (!hasOwner && !(pullKey && account.accountUuid)) return;
1446
+ const canKey = !!(pullKey && account.accountUuid);
1447
+ if (!ownerUsable && !canKey) return; // nowhere to sync back to
1442
1448
  try {
1443
1449
  if (_autoPushInFlight.has(key)) return;
1444
1450
  _autoPushInFlight.add(key);
1445
- if (hasOwner) {
1446
- const cloud = await ensureCloudSession(config).catch(() => config.cloud);
1447
- await cloudPush(cloud, [{
1448
- accountUuid: account.accountUuid,
1449
- name: account.name,
1450
- subscriptionType: account.subscriptionType,
1451
- tier: account.tier,
1452
- type: account.type,
1453
- accessToken: newTokens.accessToken,
1454
- refreshToken: newTokens.refreshToken,
1455
- expiresAt: newTokens.expiresAt,
1456
- }]);
1457
- } else {
1458
- // Consumer path: key-authenticated write-back (freshest-wins on the server).
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).
1459
1473
  await cloudReportToken(resolveCloud(config), pullKey, account.accountUuid, {
1460
1474
  accessToken: newTokens.accessToken,
1461
1475
  refreshToken: newTokens.refreshToken,
@@ -1472,14 +1486,34 @@ async function maybePushRefreshedToken(config, account, newTokens) {
1472
1486
  // ── cloud (SaaS token sync + auth-key control) ──────────────────
1473
1487
 
1474
1488
  // Refresh the owner session if it is expiring; persist the new session.
1489
+ let _cloudSessionRefresh = null; // coalesce concurrent owner-session refreshes
1475
1490
  async function ensureCloudSession(config) {
1476
1491
  const cloud = config.cloud;
1477
1492
  if (!cloud || !cloud.session || !cloud.session.access_token) return cloud;
1478
1493
  const expMs = (Number(cloud.session.expires_at) || 0) * 1000; // Supabase expires_at is seconds
1479
1494
  if (expMs && Date.now() + 60_000 >= expMs && cloud.session.refresh_token) {
1480
- const s = await cloudRefreshSession(cloud, cloud.session.refresh_token);
1481
- cloud.session = s;
1482
- await atomicConfigUpdate(c => { c.cloud = { ...(c.cloud || {}), session: s }; });
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;
1483
1517
  }
1484
1518
  return cloud;
1485
1519
  }
@@ -1499,6 +1533,7 @@ async function cloudCommand() {
1499
1533
  console.log('Cloud commands (SaaS token sync + auth-key control):');
1500
1534
  console.log(' teamclaude cloud pull --key <auth-key> Pull allowed tokens (key-only — no login)');
1501
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)');
1502
1537
  console.log(' teamclaude cloud push Push local account tokens to the cloud');
1503
1538
  console.log(' teamclaude cloud key create [--label <l>] [--accounts uuid1,uuid2]');
1504
1539
  console.log(' teamclaude cloud key list');
@@ -1508,11 +1543,88 @@ async function cloudCommand() {
1508
1543
  }
1509
1544
  }
1510
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
+
1511
1558
  async function cloudLoginCommand(config) {
1512
1559
  // url/anon-key default to the built-in public endpoint — only --email/--password
1513
1560
  // are required for the owner (admin) login.
1514
1561
  const url = argValue('--url') || config.cloud?.url || process.env.SUPABASE_URL || DEFAULT_CLOUD.url;
1515
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
+
1516
1628
  const email = argValue('--email') || config.cloud?.email || process.env.TEAMCLAUDE_CLOUD_EMAIL;
1517
1629
  let password = argValue('--password') || process.env.TEAMCLAUDE_CLOUD_PASSWORD;
1518
1630
  if (!email) {