teamclaude-cloud 1.8.1 → 1.9.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamclaude-cloud",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
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",
@@ -66,6 +66,13 @@ export class AccountManager {
66
66
  // `personal` flag). Surfaced so the dashboard/status can label it "private"
67
67
  // vs a shared/fleet account.
68
68
  isPrivate: acct.cloudPrivate === true,
69
+ // Cloud-management provenance carried on the LIVE object (not just in mutable
70
+ // config): an account whose token is shared with the cloud — pulled from it
71
+ // (cloudSources) OR pushed to it (cloudPushed) — must NEVER be rotated locally.
72
+ // Keeping it here means a held in-flight account still reports cloud-managed
73
+ // even after reconcileRemovedAccounts() drops its config entry (revocation
74
+ // race) — so a late 401→refresh can't fall through to a poisoning local rotation.
75
+ cloudManaged: (Array.isArray(acct.cloudSources) && acct.cloudSources.length > 0) || !!acct.cloudPushed,
69
76
  quota: emptyQuota(),
70
77
  usage: {
71
78
  totalInputTokens: 0,
@@ -978,24 +985,66 @@ export class AccountManager {
978
985
  // Refresh via an injected handler when set (e.g. cloud-centralized refresh
979
986
  // so multiple PCs don't each rotate the same account's refresh token); it
980
987
  // falls back to a local refresh internally. Default: local OAuth refresh.
988
+ // A cloud-managed (shared) account must only be refreshed via an injected cloud
989
+ // handler — never by the DEFAULT local path, which would rotate the token shared
990
+ // across PCs and poison it. If no handler is set (an AccountManager built outside
991
+ // the proxy server), refuse rather than poison. The server always sets one.
992
+ if (!this._refreshHandler && account.cloudManaged) {
993
+ throw new Error(`Refusing to locally refresh cloud-managed account "${account.name}" (no cloud refresh handler)`);
994
+ }
981
995
  const newTokens = this._refreshHandler
982
996
  ? await this._refreshHandler(account)
983
997
  : await refreshAccessToken(account.refreshToken);
998
+ // Validate the payload BEFORE mutating account state. A handler that returns a
999
+ // malformed 2xx result (e.g. the cloud replies {} or omits accessToken/expiresAt)
1000
+ // must NOT overwrite the live credential with undefined, clear 'error', and let
1001
+ // the account re-enter rotation sending "Bearer undefined". Treat it as a refresh
1002
+ // failure so the account stays unavailable until a real token arrives.
1003
+ if (!newTokens
1004
+ || typeof newTokens.accessToken !== 'string' || newTokens.accessToken === ''
1005
+ || typeof newTokens.refreshToken !== 'string' || newTokens.refreshToken === ''
1006
+ || !Number.isFinite(newTokens.expiresAt) || newTokens.expiresAt <= 0) {
1007
+ throw new Error(`Refresh returned an invalid token payload for "${account.name}"`);
1008
+ }
1009
+ // The token we're rotating AWAY. Anthropic invalidates it the moment newTokens is
1010
+ // issued, so if the on-disk config still holds this exact parent, our new token is
1011
+ // unambiguously newer (even at an equal expiry second) and must overwrite it —
1012
+ // passed to the persist callback so it can break the same-second tie correctly.
1013
+ const parentRefreshToken = account.refreshToken;
984
1014
  account.credential = newTokens.accessToken;
985
1015
  account.refreshToken = newTokens.refreshToken;
986
1016
  account.expiresAt = newTokens.expiresAt;
1017
+ // A successful refresh means the account is usable again — clear a prior
1018
+ // 'error' so a cloud-managed account stuck in error (e.g. after a transient
1019
+ // cloud outage) rejoins rotation once the cloud token is healthy again,
1020
+ // WITHOUT a proxy restart. getActiveAccount excludes 'error', so nothing else
1021
+ // would ever revive it; the recovery timer re-probes such accounts.
1022
+ if (account.status === 'error') account.status = 'active';
1023
+ delete account.errorTransient; // healed — clear the stale failure-class marker
987
1024
  console.log(`[TeamClaude] Token refreshed for account "${account.name}"`);
988
1025
  // Only persist if the account is still live at its claimed index. If it was
989
1026
  // removed during the (awaited) network refresh, its `.index` is stale and
990
1027
  // would misattribute the write to the survivor that shifted into that slot
991
1028
  // — and a deleted account's tokens don't need persisting anyway.
992
- if (this.accounts[account.index] === account) this._onTokenRefresh?.(account.index, newTokens);
1029
+ // AWAIT the persist callback so this refresh doesn't resolve until the rotated token
1030
+ // reaches disk — a concurrent `cloud push` drains _refreshPromise before snapshotting,
1031
+ // so an un-awaited persist would let it upload the pre-rotation token (dead-chain poison).
1032
+ // Guarded: a persist failure must not turn a successful refresh into an error.
1033
+ if (this.accounts[account.index] === account) {
1034
+ try { await this._onTokenRefresh?.(account.index, newTokens, parentRefreshToken); }
1035
+ catch (persistErr) { console.error(`[TeamClaude] Token persist error for "${account.name}": ${persistErr.message}`); }
1036
+ }
993
1037
  } catch (err) {
994
1038
  console.error(`[TeamClaude] Token refresh failed for "${account.name}": ${err.message}`);
995
1039
  // Only mark as error if the access token is actually expired;
996
1040
  // a failed proactive refresh shouldn't kill a still-valid token
997
1041
  if (!account.expiresAt || Date.now() >= account.expiresAt) {
998
1042
  account.status = 'error';
1043
+ // Record the failure CLASS so the request path can distinguish a TRANSIENT cloud outage
1044
+ // (retryable 503 — the recovery timer heals it) from a TERMINAL auth failure (401 —
1045
+ // re-auth needed). Default (no flag) = terminal; only a cloud handler that classified
1046
+ // the failure as transient (network/5xx) sets err.transient.
1047
+ account.errorTransient = err && err.transient === true;
999
1048
  }
1000
1049
  } finally {
1001
1050
  account._refreshPromise = null;
@@ -1060,6 +1109,7 @@ export class AccountManager {
1060
1109
  enabled: acctData.enabled !== false,
1061
1110
  priority: Number.isFinite(acctData.priority) ? Math.floor(acctData.priority) : null,
1062
1111
  isPrivate: acctData.cloudPrivate === true,
1112
+ cloudManaged: (Array.isArray(acctData.cloudSources) && acctData.cloudSources.length > 0) || !!acctData.cloudPushed,
1063
1113
  quota: emptyQuota(),
1064
1114
  usage: { totalInputTokens: 0, totalOutputTokens: 0, totalRequests: 0, lastUsed: null },
1065
1115
  rateLimitedUntil: null,
package/src/cloud.js CHANGED
@@ -6,7 +6,8 @@
6
6
  // Config lives under the teamclaude config as `config.cloud`:
7
7
  // { url, anonKey, email, session: { access_token, refresh_token, expires_at } }
8
8
  import { createHash } from 'node:crypto';
9
- import { normalizeExpiresAt } from './oauth.js';
9
+ import { normalizeExpiresAt, refreshAccessToken } from './oauth.js';
10
+ import { findConfigAccount } from './config.js';
10
11
 
11
12
  // Built-in PUBLIC cloud endpoint — our own domain only. A consumer PC pulls with
12
13
  // ONLY an auth key (`teamclaude cloud pull --key <key>` works right after
@@ -27,6 +28,15 @@ export function resolveCloud(config) {
27
28
  return { ...DEFAULT_CLOUD, ...(config && config.cloud ? config.cloud : {}) };
28
29
  }
29
30
 
31
+ // Whether a URL points at the built-in default cloud, tolerant of a trailing slash. Used to
32
+ // gate the ambient env auth key to the default endpoint ONLY (never a custom URL). A raw ===
33
+ // compare would treat `https://…cloud/` as a DIFFERENT endpoint than `https://…cloud`, wrongly
34
+ // SUPPRESSING the env key so cloud-managed accounts can't refresh and go unavailable.
35
+ export function isDefaultCloudUrl(url) {
36
+ const strip = (u) => (typeof u === 'string' ? u.replace(/\/+$/, '') : u);
37
+ return strip(url) === strip(DEFAULT_CLOUD.url);
38
+ }
39
+
30
40
  /**
31
41
  * Remove live accounts (and their in-memory config entries) that are no longer in
32
42
  * the desired disk set — the removal counterpart to syncAccountsFromDisk (which
@@ -245,7 +255,15 @@ export async function cloudPush(cloud, accounts, usageByUuid = null, authKey = n
245
255
  headers: fnHeaders(cloud, authHeader),
246
256
  body: payload,
247
257
  });
248
- if (!res.ok) throw new Error(`Cloud push failed (${res.status}): ${describe(res.data)}`);
258
+ if (!res.ok) {
259
+ // Attach the HTTP status so callers can tell a DEFINITIVE pre-commit rejection
260
+ // (4xx — the cloud refused, nothing was stored) from an AMBIGUOUS transport/server
261
+ // failure (5xx/timeout/network — the write MAY have committed). Reverting a
262
+ // cloudPushed mark is only safe on the former (irreversible-mutation-uncertain).
263
+ const err = new Error(`Cloud push failed (${res.status}): ${describe(res.data)}`);
264
+ err.status = res.status;
265
+ throw err;
266
+ }
249
267
  return res.data; // { ok, pushed, updated, skipped }
250
268
  }
251
269
 
@@ -312,7 +330,14 @@ export async function cloudRefreshToken(cloud, authKey, accountUuid, minRemainin
312
330
  headers: fnHeaders(cloud, { 'x-teamclaude-key': authKey }),
313
331
  body,
314
332
  });
315
- if (!res.ok) throw new Error(`Cloud refresh failed (${res.status}): ${describe(res.data)}`);
333
+ if (!res.ok) {
334
+ // Attach the HTTP status so the handler can classify TERMINAL (4xx invalid_grant/revoked →
335
+ // needs re-auth) vs TRANSIENT (5xx/network → healable by the recovery timer). A network error
336
+ // (thrown before res exists) has no status → treated as transient by the handler.
337
+ const err = new Error(`Cloud refresh failed (${res.status}): ${describe(res.data)}`);
338
+ err.status = res.status;
339
+ throw err;
340
+ }
316
341
  return {
317
342
  accessToken: res.data.accessToken,
318
343
  refreshToken: res.data.refreshToken,
@@ -362,12 +387,14 @@ export async function cloudPull(cloud, authKey) {
362
387
  });
363
388
  if (!res.ok) {
364
389
  // Attach the HTTP status so a live-sync loop can classify the failure:
365
- // 403 = the key was revoked/invalidated (stop serving); 5xx/0/network =
366
- // transient (keep last-known accounts, retry next interval). See index.js
367
- // syncFromCloudOnce (irreversible-mutation 3-way: revoke vs transient).
390
+ // 401/403 = definitive AUTH failure (bad/revoked/invalidated key) treat as revoked so the
391
+ // loop applies the reversible hold (stop serving); 5xx/0/network = transient (keep last-known
392
+ // accounts, retry next interval). Both 401 and 403 are "the key doesn't authorize" — a server
393
+ // may use either — and the hold is reversible, so a transient auth blip returning 401 recovers
394
+ // on the next successful pull. See index.js syncFromCloudOnce (irreversible-mutation 3-way).
368
395
  const err = new Error(`Cloud pull failed (${res.status}): ${describe(res.data)}`);
369
396
  err.status = res.status;
370
- err.revoked = res.status === 403;
397
+ err.revoked = res.status === 403 || res.status === 401;
371
398
  throw err;
372
399
  }
373
400
  const accounts = (res.data.accounts || []).map((a) => ({
@@ -704,3 +731,150 @@ function describe(data) {
704
731
  if (data.error) return String(data.error) + (data.detail ? ` — ${data.detail}` : '');
705
732
  return JSON.stringify(data);
706
733
  }
734
+
735
+ /**
736
+ * Is this account's authoritative token shared with the cloud?
737
+ *
738
+ * An account is cloud-shared once it is either PULLED from the cloud (`cloudSources`
739
+ * set) OR explicitly PUSHED to it (`cloudPushed`). In both cases the cloud holds the
740
+ * authoritative refresh-token chain, shared across every PC. Such an account must be
741
+ * refreshed ONLY through the cloud's centralized rotation and NEVER rotated locally:
742
+ * Anthropic invalidates the previous refresh token on every rotation, so an
743
+ * independent local rotation on one PC kills the shared token for the cloud AND every
744
+ * other PC (divergent-chain poisoning — the exact failure the centralized design
745
+ * exists to prevent). Covering the PUSH side too closes the origin-PC hole: a PC that
746
+ * pushes an account it authenticated locally has no cloudSources, but its token is now
747
+ * shared, so it must not local-rotate either. Matches UUID-first then legacy name.
748
+ */
749
+ export function isCloudManaged(config, account) {
750
+ if (!config || !account || !Array.isArray(config.accounts)) return false;
751
+ const i = findConfigAccount(config, account);
752
+ if (i < 0) return false;
753
+ const a = config.accounts[i];
754
+ return (Array.isArray(a.cloudSources) && a.cloudSources.length > 0) || !!a.cloudPushed;
755
+ }
756
+
757
+ /**
758
+ * Ordered auth keys AUTHORIZED to refresh a specific account, most-specific first.
759
+ *
760
+ * An account can be pulled from more than one auth key (its `cloudSources` lists a
761
+ * `keySource(key)` per key), but `config.cloud.pullKey` only holds the LAST key used.
762
+ * Refreshing with a single key 403s for accounts in a different key's group. So we
763
+ * return EVERY key that grants this account (mapped from its cloudSources via the
764
+ * `{ keySource(key) -> key }` store `config.cloud.pullKeys` accumulated across pulls),
765
+ * then the default `pullKey` — and the handler tries them in order. This means that
766
+ * when one authorizing key is revoked but ANOTHER still grants the account, the refresh
767
+ * transparently uses the surviving key instead of erroring (key-revocation availability).
768
+ * Falls back to `[pullKey]` for single-key / legacy configs / a removed account entry.
769
+ */
770
+ export function refreshKeyCandidates(config, account) {
771
+ const c = config && config.cloud;
772
+ if (!c) return [];
773
+ const out = [];
774
+ const push = (k) => { if (typeof k === 'string' && k && !out.includes(k)) out.push(k); };
775
+ const store = c.pullKeys && typeof c.pullKeys === 'object' ? c.pullKeys : null;
776
+ let hasProvenance = false; // account carries a cloudSources tag (pulled), vs. push-only / none
777
+ if (store && account) {
778
+ const i = findConfigAccount(config, account);
779
+ const sources = i >= 0 && Array.isArray(config.accounts[i].cloudSources)
780
+ ? config.accounts[i].cloudSources : [];
781
+ if (sources.length) hasProvenance = true;
782
+ for (const s of sources) push(store[s]);
783
+ }
784
+ push(c.pullKey); // default / single-key / legacy fallback
785
+ // Then, ONLY for an account with NO cloudSources provenance, EVERY stored key. A pushed
786
+ // account that hasn't yet gained a cloudSources tag (its post-success reconcile is pending
787
+ // or failed transiently) has no source-specific key, so it must try all authorized keys —
788
+ // else a revoked default pullKey would block its refresh while another valid key still
789
+ // grants it. But an account that DOES have provenance whose source key is simply gone from
790
+ // the store keeps only the default: don't blast every key at an account whose authorizing
791
+ // key is known-gone (that would 403-spray unrelated keys). Wrong keys just 403; handler moves on.
792
+ if (store && !hasProvenance) for (const k of Object.values(store)) push(k);
793
+ return out;
794
+ }
795
+
796
+ /**
797
+ * Build the AccountManager token-refresh handler.
798
+ *
799
+ * Cloud-centralized refresh: when a cloud key is present and the account has a UUID,
800
+ * route the refresh through the cloud so the SAME account used from multiple PCs is
801
+ * rotated exactly ONCE (by the cloud), not independently by each PC.
802
+ *
803
+ * CRITICAL — single-authority for cloud-managed accounts: if the cloud refresh fails
804
+ * for a cloud-managed account (see isCloudManaged) we DO NOT fall back to a local
805
+ * rotation. A local `refreshAccessToken` would rotate the shared refresh token
806
+ * independently of the cloud, and because Anthropic invalidates the previous refresh
807
+ * token on every rotation, that poisons the token for the cloud and every other PC
808
+ * (an account "shared across PCs" then dies as invalid_grant everywhere). Instead we
809
+ * surface the error: on a transient cloud outage the cloud's token is preserved for a
810
+ * later central refresh (the account recovers automatically — see the recovery timer
811
+ * in serverCommand); on a terminal invalid_grant the account needs a one-time re-auth
812
+ * at the source. A brief unavailability is the correct trade-off over permanently
813
+ * poisoning a token shared across machines.
814
+ *
815
+ * NON-cloud (locally-owned) accounts keep the local refresh — there is no shared cloud
816
+ * token to protect, so this PC is their sole authority.
817
+ *
818
+ * `deps` allows injecting cloud/oauth functions for unit testing without network I/O.
819
+ */
820
+ export function createCloudRefreshHandler(config, deps = {}) {
821
+ const resolveCloudFn = deps.resolveCloud || resolveCloud;
822
+ const cloudRefreshTokenFn = deps.cloudRefreshToken || cloudRefreshToken;
823
+ const refreshAccessTokenFn = deps.refreshAccessToken || refreshAccessToken;
824
+ const candidatesFn = deps.refreshKeyCandidates || refreshKeyCandidates;
825
+ return async (account) => {
826
+ const cloud = resolveCloudFn(config);
827
+ // Every key that authorizes THIS account (multi-key fleets), most-specific first,
828
+ // then env — tried in order so a revoked key transparently yields to a surviving
829
+ // one instead of erroring. Not just the last pullKey (which 403s cross-group).
830
+ const keys = candidatesFn(config, account);
831
+ // The ambient env key carries no endpoint binding, so it's presumed for the DEFAULT
832
+ // cloud: append it ONLY when the resolved endpoint IS the default — never send it to a
833
+ // custom/self-hosted URL the config points at (key exfiltration via a tricked endpoint).
834
+ const envKey = deps.envKey !== undefined
835
+ ? deps.envKey
836
+ : (isDefaultCloudUrl(cloud.url) ? process.env.TEAMCLAUDE_AUTH_KEY : undefined);
837
+ if (envKey && !keys.includes(envKey)) keys.push(envKey);
838
+ // Prefer the provenance flag carried on the LIVE account object — it survives a
839
+ // reconcileRemovedAccounts() that drops the config entry mid-request (revocation
840
+ // race), so a held account can't fall through to a poisoning local rotation. Fall
841
+ // back to the config lookup for accounts constructed without the flag.
842
+ const cloudManaged = account.cloudManaged === true || isCloudManaged(config, account);
843
+ // ONLY cloud-managed (shared) accounts ever touch the cloud refresh path. A non-cloud
844
+ // (local) account must local-refresh ONLY — attempting a cloud refresh for it (e.g.
845
+ // because an env auth key or some pullKey happens to authorize its UUID) would advance
846
+ // a cloud token chain WITHOUT marking cloudPushed/cloudSources provenance; a later cloud
847
+ // failure would then fall back to local rotation and poison that chain. So a local
848
+ // account skips the cloud path entirely — no cloud attempt, ever.
849
+ if (cloudManaged) {
850
+ if (keys.length && account.accountUuid) {
851
+ let lastErr;
852
+ for (const k of keys) {
853
+ try {
854
+ const t = await cloudRefreshTokenFn(cloud, k, account.accountUuid);
855
+ return {
856
+ accessToken: t.accessToken,
857
+ refreshToken: t.refreshToken || account.refreshToken,
858
+ expiresAt: t.expiresAt,
859
+ };
860
+ } catch (err) { lastErr = err; }
861
+ }
862
+ // Single-authority: never local-rotate a cloud-managed (shared) token, even after
863
+ // every authorized key failed (revoked/outage) — a local rotation would poison the
864
+ // token shared across PCs. Surface the error → the account goes unavailable and the
865
+ // recovery timer re-probes until a key/cloud recovers. Preserve the FAILURE CLASS so the
866
+ // proxy can tell a transient outage (retryable) from a terminal invalid_grant (re-auth):
867
+ // transient iff the last key's failure had NO status (network) or a 5xx; a 4xx (invalid/
868
+ // revoked key, invalid_grant) is TERMINAL and must surface as an auth error, not a
869
+ // permanent "recovering" 503.
870
+ const e = new Error(`Cloud refresh failed for cloud-managed account "${account.name}" — refusing to rotate locally (a local rotation would poison the token shared across PCs): ${lastErr?.message}`);
871
+ e.transient = !lastErr?.status || lastErr.status >= 500;
872
+ throw e;
873
+ }
874
+ // Cloud-managed but no cloud key/UUID to reach the cloud → cannot refresh safely;
875
+ // refusing to local-rotate is still correct (would poison the share).
876
+ throw new Error(`Cannot refresh cloud-managed account "${account.name}" without a cloud key — refusing to rotate locally (single-authority).`);
877
+ }
878
+ return refreshAccessTokenFn(account.refreshToken); // local fallback — non-cloud accounts ONLY
879
+ };
880
+ }