maxpool 1.5.25 → 1.5.27

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": "maxpool",
3
- "version": "1.5.25",
3
+ "version": "1.5.27",
4
4
  "description": "Multi-account Claude Code proxy with adaptive, rate-aware load balancing across Claude accounts",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -1,4 +1,4 @@
1
- import { refreshAccessToken, isTokenExpiringSoon, modelFamily } from './oauth.js';
1
+ import { refreshAccessToken, isTokenExpiringSoon, modelFamily, tokenFingerprint } from './oauth.js';
2
2
 
3
3
  // Bounded re-poll hold for an account blocked ONLY by a transient, self-clearing
4
4
  // condition whose exact recovery time is unknown: (a) a weekly-critical account
@@ -2298,8 +2298,24 @@ export class AccountManager {
2298
2298
  account.expiresAt = newTokens.expiresAt;
2299
2299
  account.status = 'active';
2300
2300
  account.cooldownUntil = null;
2301
- console.log(`[Maxpool] Token refreshed for account "${account.name}"`);
2302
- this._onTokenRefresh?.(accountIndex, newTokens);
2301
+ console.log(`[Maxpool] Token refreshed for account "${account.name}" (rotated ${tokenFingerprint(account._refreshedFrom)} → ${tokenFingerprint(newTokens.refreshToken)})`);
2302
+ // Persist-before-serve: the rotated single-use refresh token must be
2303
+ // DURABLE on disk before we return true (before this request serves on the
2304
+ // new access token). A non-graceful kill (SIGKILL/crash/OOM/terminal-close)
2305
+ // between here and the disk write would otherwise leave the now-CONSUMED
2306
+ // token on disk → next boot POSTs it → invalid_grant → forced re-auth.
2307
+ // This minimizes the loss window to the write duration (it cannot be zero —
2308
+ // the upstream consumes the old token the instant the POST returns); the
2309
+ // fingerprint audit trail makes the irreducible residual diagnosable.
2310
+ // persistTokenRefresh is bulletproofed to never throw, but the refresh has
2311
+ // ALREADY succeeded — a persist anomaly must never be re-classified as a
2312
+ // refresh failure (which would latch refreshDead on a working account), so
2313
+ // guard the await too.
2314
+ try {
2315
+ await this._onTokenRefresh?.(accountIndex, newTokens);
2316
+ } catch (persistErr) {
2317
+ console.error(`[Maxpool] Token persist raised unexpectedly for "${account.name}": ${persistErr?.message || persistErr}`);
2318
+ }
2303
2319
  return true;
2304
2320
  } catch (err) {
2305
2321
  console.error(`[Maxpool] Token refresh failed for "${account.name}": ${err.message}`);
@@ -2319,6 +2335,11 @@ export class AccountManager {
2319
2335
  // dead token. Set ONLY here (a rejected refresh), never in markAuthFailed
2320
2336
  // (shared with provider auth failures).
2321
2337
  account.refreshDead = true;
2338
+ // Monitoring: name the exact token that was rejected + how to diagnose it
2339
+ // from the persistent event log next time this recurs. (fp= is safe from
2340
+ // the log's secret-redactor; refresh_token= would be redacted.)
2341
+ const rejFp = tokenFingerprint(account._refreshedFrom);
2342
+ console.error(`[Maxpool] Token refresh REJECTED for "${account.name}" (invalid_grant) — the refresh token maxpool sent (fp=${rejFp}) was not accepted. Diagnose from the event log: an earlier "rotated → ${rejFp}" that WAS "Persisted" ⇒ upstream revocation; NO persisted line for ${rejFp} ⇒ the rotation was lost across a restart (double-spend); the SAME source fp in two "rotated" lines in one window ⇒ two writers double-spent it. Re-login via the TUI ('l' key).`);
2322
2343
  }
2323
2344
  return false;
2324
2345
  }
package/src/index.js CHANGED
@@ -8,7 +8,7 @@ import { SleepGuard } from './sleep-guard.js';
8
8
  import { AccountManager } from './account-manager.js';
9
9
  import { createProxyServer } from './server.js';
10
10
  import { Prober } from './prober.js';
11
- import { loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
11
+ import { loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon, tokenFingerprint } from './oauth.js';
12
12
  import { TUI } from './tui.js';
13
13
  import { RestartController } from './restart-controller.js';
14
14
  import { resolveAccounts } from './account-config.js';
@@ -595,46 +595,63 @@ async function serverWorkerCommand() {
595
595
  // baton hands off and the new worker boots from the now-invalidated on-disk
596
596
  // token (B1/M3). New refreshes can't start without the lease (ensureTokenFresh
597
597
  // no-ops), so every callback here is from a legitimate lease-era refresh.
598
- const persistTokenRefresh = (idx, newTokens) => {
598
+ //
599
+ // BULLETPROOF CONTRACT: ensureTokenFresh now AWAITs this (persist-before-serve),
600
+ // so it must NEVER throw or reject. A throw here — including from the synchronous
601
+ // prologue (findConfigAccount / addAccount) — would land in ensureTokenFresh's
602
+ // refresh-FAILURE catch and latch `refreshDead` on an account whose refresh POST
603
+ // actually SUCCEEDED (bricking a working account — strictly worse than the window
604
+ // this fix closes). So the ENTIRE body is wrapped, prologue included, and every
605
+ // exit resolves. Returns the awaitable persist promise.
606
+ const persistTokenRefresh = async (idx, newTokens) => {
599
607
  const account = accountManager.accounts[idx];
600
608
  if (!account) return;
601
- // Keep config.accounts in sync so TUI saveConfig doesn't clobber fresh tokens
602
- const memIdx = findConfigAccount(config, account);
603
- if (memIdx >= 0) {
604
- config.accounts[memIdx].accessToken = newTokens.accessToken;
605
- config.accounts[memIdx].refreshToken = newTokens.refreshToken;
606
- config.accounts[memIdx].expiresAt = newTokens.expiresAt;
607
- }
608
- atomicConfigUpdate(diskConfig => {
609
- // Pick up any new accounts from disk so index matching stays correct
610
- // (only add, don't refresh credentials — we're about to write the authoritative tokens)
611
- for (const diskAcct of diskConfig.accounts) {
612
- const known = (diskAcct.accountUuid && config.accounts.some(a => a.accountUuid === diskAcct.accountUuid))
613
- || config.accounts.some(a => a.name === diskAcct.name);
614
- if (!known) {
615
- config.accounts.push(diskAcct);
616
- accountManager.addAccount(diskAcct);
617
- }
609
+ try {
610
+ // Keep config.accounts in sync so TUI saveConfig doesn't clobber fresh tokens
611
+ const memIdx = findConfigAccount(config, account);
612
+ if (memIdx >= 0) {
613
+ config.accounts[memIdx].accessToken = newTokens.accessToken;
614
+ config.accounts[memIdx].refreshToken = newTokens.refreshToken;
615
+ config.accounts[memIdx].expiresAt = newTokens.expiresAt;
618
616
  }
619
- // Match by UUID first, then by name — index may have shifted
620
- const cfgIdx = findConfigAccount(diskConfig, account);
621
- if (cfgIdx >= 0) {
617
+ let skipped = false; // guard-skip or account-not-on-disk → nothing was persisted
618
+ await atomicConfigUpdate(diskConfig => {
619
+ // Pick up any new accounts from disk so index matching stays correct
620
+ // (only add, don't refresh credentials — we're about to write the authoritative tokens)
621
+ for (const diskAcct of diskConfig.accounts) {
622
+ const known = (diskAcct.accountUuid && config.accounts.some(a => a.accountUuid === diskAcct.accountUuid))
623
+ || config.accounts.some(a => a.name === diskAcct.name);
624
+ if (!known) {
625
+ config.accounts.push(diskAcct);
626
+ accountManager.addAccount(diskAcct);
627
+ }
628
+ }
629
+ // Match by UUID first, then by name — index may have shifted
630
+ const cfgIdx = findConfigAccount(diskConfig, account);
631
+ if (cfgIdx < 0) { skipped = true; return; }
622
632
  const onDisk = diskConfig.accounts[cfgIdx];
623
633
  // Generation guard: if the on-disk refresh token already advanced past
624
634
  // the token we rotated FROM, another writer beat us — skip the write so
625
635
  // we don't revert a fresher single-use token (the brick-the-account case).
626
636
  if (onDisk.refreshToken && onDisk.refreshToken !== account._refreshedFrom &&
627
637
  onDisk.refreshToken !== newTokens.refreshToken) {
638
+ skipped = true;
628
639
  return;
629
640
  }
630
641
  onDisk.accessToken = newTokens.accessToken;
631
642
  onDisk.refreshToken = newTokens.refreshToken;
632
643
  onDisk.expiresAt = newTokens.expiresAt;
644
+ });
645
+ // Monitoring: the rotated token is now DURABLE on disk. Correlate this fp
646
+ // with a later "REJECTED sent fp=" line to tell a lost-rotation double-spend
647
+ // (no matching Persisted line) from an upstream revocation (fp matches).
648
+ if (!skipped) {
649
+ console.log(`[Maxpool] Persisted rotated token for "${account.name}" (fp=${tokenFingerprint(newTokens.refreshToken)})`);
633
650
  }
634
- }).catch(err => {
651
+ } catch (err) {
635
652
  if (err?.code === 'STALE_GENERATION') return; // another writer advanced; benign
636
- console.error(`[Maxpool] Failed to save refreshed token: ${err.message}`);
637
- });
653
+ console.error(`[Maxpool] Failed to save refreshed token for "${account.name}" (fp=${tokenFingerprint(newTokens?.refreshToken)}): ${err?.message || err}`);
654
+ }
638
655
  };
639
656
  accountManager.onTokenRefresh(persistTokenRefresh);
640
657
 
package/src/oauth.js CHANGED
@@ -10,6 +10,19 @@ const DEFAULT_TOKEN_ENDPOINT = process.env.MAXPOOL_OAUTH_TOKEN_ENDPOINT
10
10
  || 'https://platform.claude.com/v1/oauth/token';
11
11
  const DEFAULT_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
12
12
 
13
+ /**
14
+ * A short, NON-REVERSIBLE fingerprint of a refresh token, for audit logging of
15
+ * the single-use-token rotation lifecycle. NEVER log the token itself — an
16
+ * 8-char sha256 prefix is irreversible yet enough to correlate a rotation
17
+ * ("rotated → fp / Persisted fp") with the token later rejected on boot
18
+ * ("REJECTED sent fp"), which is what distinguishes a lost-rotation double-spend
19
+ * from an upstream revocation. Returns 'none' for a falsy token.
20
+ */
21
+ export function tokenFingerprint(token) {
22
+ if (!token) return 'none';
23
+ return createHash('sha256').update(String(token)).digest('hex').slice(0, 8);
24
+ }
25
+
13
26
  /**
14
27
  * Refresh an expired OAuth access token using the refresh token.
15
28
  * Retries on 5xx and network errors with exponential backoff.
package/src/tui.js CHANGED
@@ -18,11 +18,38 @@ const yellow = s => fg(33, s);
18
18
  const red = s => fg(31, s);
19
19
  const cyan = s => fg(36, s);
20
20
  const gray = s => fg(90, s);
21
+ const dimUnderline = s => `${ESC}2;4m${s}${RESET}`;
21
22
 
22
23
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
23
24
  const strip = s => s.replace(ANSI_RE, '');
24
25
  const vw = s => strip(s).length;
25
26
 
27
+ // ── Accounts-table columns ───────────────────────────────────
28
+ // Fixed column widths shared by the header row (acctHeader) AND every data row, so
29
+ // the header labels stay aligned with the columns they name. The Account/Type/
30
+ // Status/Quota start offsets (4/17/26/40) are pure functions of these widths + the
31
+ // 4-col row prefix, independent of the quota-bar width.
32
+ const NAME_W = 12; // a.name.slice(0, NAME_W).padEnd(NAME_W)
33
+ const TYPE_W = 8; // a.type.padEnd(TYPE_W) — fits "provider"
34
+ const STATUS_W = 13; // rpad(status, STATUS_W) — fits "throttled 59s"
35
+ const ROW_PREFIX = ' '; // ' ' + sel(1) + cur(1) + ' ' — 4 cols before the name
36
+
37
+ /**
38
+ * Aligned column header for the accounts table. Names the three columns that carry
39
+ * NO inline label (Account / Type / Status) plus a group label over the two quota
40
+ * bars (Quota). The per-bar naming (Ses/Wk for OAuth, Tok/Req for API keys) and the
41
+ * load fields (Now/15m/1h) stay inline per row — they're row-type-dependent and
42
+ * begin at a variable offset — so the header stops after the Quota group label.
43
+ */
44
+ function acctHeader(W) {
45
+ const quota = W >= 88 ? 'Quota (used% · resets-in)' : 'Quota';
46
+ return ROW_PREFIX
47
+ + 'Account'.padEnd(NAME_W) + ' '
48
+ + 'Type'.padEnd(TYPE_W) + ' '
49
+ + 'Status'.padEnd(STATUS_W) + ' '
50
+ + quota;
51
+ }
52
+
26
53
  function rpad(s, w) {
27
54
  const gap = w - vw(s);
28
55
  return gap > 0 ? s + ' '.repeat(gap) : s;
@@ -194,7 +221,7 @@ function emptyBar(label, w = 10) {
194
221
  return `${ESC}100m${' '.repeat(lp)}${text}${' '.repeat(rp)}${RESET}`;
195
222
  }
196
223
 
197
- export const __tuiTest = { formatReset, quotaLabel, bar, emptyBar, strip, loadText, countdown };
224
+ export const __tuiTest = { formatReset, quotaLabel, bar, emptyBar, strip, loadText, countdown, acctHeader, fitLine };
198
225
 
199
226
  function timestamp() {
200
227
  return new Date().toLocaleTimeString('en-US', { hour12: false });
@@ -663,12 +690,14 @@ export class TUI {
663
690
  const previous = this.config.accounts[idx];
664
691
  entry.enabled = previous.enabled;
665
692
  this.config.accounts[idx] = entry;
666
- try {
667
- await this.saveConfig(this.config);
668
- } catch (error) {
669
- this.config.accounts[idx] = previous;
670
- throw error;
671
- }
693
+ // Update the AccountManager to the FRESH tokens BEFORE persisting. The
694
+ // saveConfig writer (index.js) derives the ON-DISK tokens from the
695
+ // AccountManager, not from config.accounts — so persisting first writes the
696
+ // STALE pre-re-auth token (typically the dead one we're re-authing away from)
697
+ // to disk. The account then works in memory but boots DEAD on the next restart
698
+ // (a just-re-authed token rejected as invalid_grant). A later refresh does NOT
699
+ // heal disk (persistTokenRefresh's generation guard skips a disk token that
700
+ // != _refreshedFrom), so disk must be made correct right here.
672
701
  const amAcct = this.am.accounts.find(account =>
673
702
  (entry.accountUuid && account.accountUuid === entry.accountUuid) || account.name === name
674
703
  );
@@ -684,6 +713,17 @@ export class TUI {
684
713
  amAcct.refreshDead = false;
685
714
  if (amAcct.status === 'error') amAcct.status = 'active';
686
715
  }
716
+ try {
717
+ await this.saveConfig(this.config);
718
+ } catch (error) {
719
+ // Persist failed (e.g. disk full). The browser re-auth genuinely SUCCEEDED,
720
+ // so KEEP the AccountManager on the fresh tokens — reverting it to the dead
721
+ // token would brick a working in-memory account. Roll back only the
722
+ // config.accounts mirror and surface the error; disk self-heals on the next
723
+ // saveConfig (the AM-derived writer re-writes the fresh token).
724
+ this.config.accounts[idx] = previous;
725
+ throw error;
726
+ }
687
727
  this._addLog(`Updated account "${name}"`);
688
728
  return { updated: true, name };
689
729
  } else {
@@ -906,10 +946,13 @@ export class TUI {
906
946
  lines.push(yellow(' No accounts configured. Press [a] to add one.'));
907
947
  } else {
908
948
  lines.push('');
909
- // Column legend — the per-row numbers are otherwise cryptic. Ses/Wk are the
910
- // two quota bars; Now is live concurrency; 15m/1h are recent throughput.
949
+ // Aligned column header (dim + underline so it reads as chrome, not data). It
950
+ // labels the fixed columns that have no inline label; the Ses/Wk/Tok/Req and
951
+ // Now/15m/1h labels stay inline per row. A short glossary below expands the
952
+ // abbreviations the header + inline labels can't spell out.
953
+ lines.push(dimUnderline(acctHeader(W)));
911
954
  if (W >= 88) {
912
- lines.push(' ' + dim('Ses/Wk = 5h/7d quota (used% · resets-in) · Now = in-flight (weight) · 15m/1h = requests served (avg latency · Nf=fails)'));
955
+ lines.push(' ' + dim('Ses 5h · Wk 7d · Now in-flight (weight) · 15m/1h served (avg · f fails)'));
913
956
  }
914
957
  const showBoth = W >= 70;
915
958
  const bw = showBoth
@@ -979,13 +1022,13 @@ export class TUI {
979
1022
  const cur = isCur ? green('►') : ' ';
980
1023
 
981
1024
  // Name (bold if selected)
982
- const rawName = a.name.slice(0, 12).padEnd(12);
1025
+ const rawName = a.name.slice(0, NAME_W).padEnd(NAME_W);
983
1026
  const name = isSel ? bold(rawName) : rawName;
984
1027
 
985
1028
  // Type — pad to 8 so "provider" (8 chars) doesn't overflow a 7-wide column and
986
1029
  // shift the whole provider row (incl. its quota bars) 1 char out of alignment
987
1030
  // with the "oauth"/"apikey" rows.
988
- const type = gray(a.type.padEnd(8));
1031
+ const type = gray(a.type.padEnd(TYPE_W));
989
1032
 
990
1033
  // Status
991
1034
  let status;
@@ -1030,7 +1073,7 @@ export class TUI {
1030
1073
  default: status = a.status || 'ready';
1031
1074
  }
1032
1075
  // Widened from 10 to fit "throttled 59s" so the quota bars stay column-aligned.
1033
- status = rpad(status, 13);
1076
+ status = rpad(status, STATUS_W);
1034
1077
 
1035
1078
  if (a.type === 'provider') {
1036
1079
  return this._renderProviderAcct(sel, cur, name, type, status, a, bw, showBoth);