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.
Files changed (3) hide show
  1. package/package.json +2 -2
  2. package/src/cloud.js +34 -0
  3. 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.1",
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
- const cloud = await ensureCloudSession(config).catch(() => config.cloud);
1440
- await cloudPush(cloud, [{
1441
- accountUuid: account.accountUuid,
1442
- name: account.name,
1443
- subscriptionType: account.subscriptionType,
1444
- tier: account.tier,
1445
- type: account.type,
1446
- accessToken: newTokens.accessToken,
1447
- refreshToken: newTokens.refreshToken,
1448
- expiresAt: newTokens.expiresAt,
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 auto-push (token refresh) failed for ${account.name}: ${err.message}`);
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
- const s = await cloudRefreshSession(cloud, cloud.session.refresh_token);
1466
- cloud.session = s;
1467
- 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;
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) {