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.
- package/package.json +1 -1
- package/src/cloud.js +13 -0
- package/src/index.js +136 -24
package/package.json
CHANGED
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
|
|
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
|
-
|
|
1438
|
-
|
|
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 (
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
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
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
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) {
|