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 +1 -1
- package/src/account-manager.js +51 -1
- package/src/cloud.js +181 -7
- package/src/index.js +961 -117
- package/src/server.js +84 -13
- package/src/tui.js +7 -0
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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)
|
|
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 =
|
|
366
|
-
//
|
|
367
|
-
//
|
|
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
|
+
}
|