teamclaude-cloud 1.5.4 → 1.5.6

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.5.4",
3
+ "version": "1.5.6",
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",
package/src/config.js CHANGED
@@ -86,6 +86,10 @@ export function createDefaultConfig() {
86
86
  // Hard caps that bound proxy memory under a request flood.
87
87
  overflowQueueMaxDepth: 256, // max queued requests before 429
88
88
  maxRequestBytes: 33554432, // 32 MiB max buffered request body, else 413
89
+ // Mask mode: hide account emails in CLI output (status/accounts/TUI). Display-only
90
+ // security posture (shoulder-surfing / screen-share). Toggle: `teamclaude mask on|off`
91
+ // or the TUI `m` key. Independent of the web dashboard's own mask toggle.
92
+ maskMode: false,
89
93
  accounts: [],
90
94
  };
91
95
  }
package/src/index.js CHANGED
@@ -9,6 +9,7 @@ import { createProxyServer } from './server.js';
9
9
  import { importCredentials, loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
10
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
+ import { maskIf, maskEmail } from './mask.js';
12
13
 
13
14
  const args = process.argv.slice(2);
14
15
  const command = args[0];
@@ -52,6 +53,10 @@ switch (command) {
52
53
  await accountsCommand();
53
54
  process.exit(0);
54
55
  break;
56
+ case 'mask':
57
+ await maskCommand();
58
+ process.exit(0);
59
+ break;
55
60
  case 'remove':
56
61
  await removeCommand();
57
62
  process.exit(0);
@@ -254,7 +259,7 @@ async function serverCommand() {
254
259
 
255
260
  if (useTUI) {
256
261
  tui = new TUI({
257
- accountManager, config,
262
+ accountManager, config, version: currentVersion(),
258
263
  saveConfig: () => atomicConfigUpdate(async diskConfig => {
259
264
  // Write in-memory accounts as the authoritative state, preserving
260
265
  // extra disk-only fields (e.g. importFrom) where the account still exists.
@@ -309,6 +314,9 @@ async function serverCommand() {
309
314
  if (disabled) set.add(uuid); else set.delete(uuid);
310
315
  cloud.localDisabled = [...set];
311
316
  }),
317
+ // Persist the mask-mode flag (TUI `m`) atomically, re-reading disk so it merges
318
+ // with any concurrent token-refresh write instead of clobbering it.
319
+ setMask: (on) => atomicConfigUpdate(c => { c.maskMode = on === true; }),
312
320
  // `p` in the dashboard = `teamclaude cloud push`, so the admin can register the
313
321
  // server's live account tokens to the cloud without dropping to a shell. Only
314
322
  // wired when a cloud config exists (else the footer hides the key). Uses the
@@ -447,14 +455,15 @@ async function serverCommand() {
447
455
  }
448
456
  }
449
457
 
458
+ const ver = currentVersion();
450
459
  if (tui) {
451
460
  tui.start();
452
- console.log(`Listening on port ${port} with ${accounts.length} account(s)`);
461
+ console.log(`TeamClaude${ver ? ` v${ver}` : ''} — listening on port ${port} with ${accounts.length} account(s)`);
453
462
  } else {
454
463
  const sep = '='.repeat(60);
455
464
  console.log('');
456
465
  console.log(sep);
457
- console.log(' TeamClaude Proxy');
466
+ console.log(` TeamClaude Proxy${ver ? ` v${ver}` : ''}`);
458
467
  console.log(sep);
459
468
  console.log(` Port: ${port}`);
460
469
  console.log(` Accounts: ${accounts.length}`);
@@ -462,7 +471,7 @@ async function serverCommand() {
462
471
  console.log(` Upstream: ${config.upstream || 'https://api.anthropic.com'}`);
463
472
  console.log('');
464
473
  accounts.forEach((a, i) => {
465
- console.log(` [${i + 1}] ${a.name} (${a.type})`);
474
+ console.log(` [${i + 1}] ${maskIf(a.name, config.maskMode)} (${a.type})`);
466
475
  });
467
476
  console.log('');
468
477
  console.log(' Run Claude through proxy: teamclaude run');
@@ -813,6 +822,29 @@ async function runCommand() {
813
822
  process.exit(result.status ?? 1);
814
823
  }
815
824
 
825
+ // ── mask mode ───────────────────────────────────────────────
826
+
827
+ // `teamclaude mask [on|off|toggle]` — flip the display-only mask that hides account
828
+ // emails in status/accounts/TUI output. Persisted in config (freshest-wins safe via
829
+ // atomicConfigUpdate re-read). Independent of the web dashboard's own mask toggle.
830
+ async function maskCommand() {
831
+ const arg = (args[1] || '').toLowerCase();
832
+ let target;
833
+ if (['on', 'true', '1'].includes(arg)) target = true;
834
+ else if (['off', 'false', '0'].includes(arg)) target = false;
835
+ else if (arg === '' || arg === 'toggle') target = null; // toggle current
836
+ else { console.error('Usage: teamclaude mask [on|off|toggle]'); process.exit(1); }
837
+
838
+ const updated = await atomicConfigUpdate((cfg) => {
839
+ cfg.maskMode = target === null ? !(cfg.maskMode === true) : target;
840
+ return cfg;
841
+ });
842
+ const on = updated.maskMode === true;
843
+ console.log(`Mask mode: ${on ? 'ON' : 'OFF'} — account emails are ${on ? 'hidden' : 'shown'} in CLI output.`);
844
+ const first = updated.accounts?.[0]?.name;
845
+ if (first) console.log(` example: ${maskIf(first, on)} (masked: ${maskEmail(first)})`);
846
+ }
847
+
816
848
  // ── status ──────────────────────────────────────────────────
817
849
 
818
850
  async function statusCommand() {
@@ -842,7 +874,7 @@ async function statusCommand() {
842
874
  const current = acct.name === data.currentAccount ? ' *' : '';
843
875
 
844
876
  const disabledTag = acct.enabled === false ? ' [disabled]' : '';
845
- console.log(` ${acct.name} (${acct.type})${current}${disabledTag}`);
877
+ console.log(` ${maskIf(acct.name, config.maskMode)} (${acct.type})${current}${disabledTag}`);
846
878
  console.log(` Status: ${acct.status}${acct.enabled === false ? ' (disabled — out of rotation)' : ''}`);
847
879
  if (acct.priority != null) console.log(` Priority: ${acct.priority} (lower = preferred)`);
848
880
  if (acct.maxConcurrent != null) {
@@ -946,7 +978,7 @@ async function accountsCommand() {
946
978
  const p = profiles[i];
947
979
 
948
980
  if (a.type === 'apikey') {
949
- console.log(` [${i + 1}] ${a.name} (apikey) ${a.apiKey?.slice(0, 15)}...`);
981
+ console.log(` [${i + 1}] ${maskIf(a.name, config.maskMode)} (apikey) ${a.apiKey?.slice(0, 15)}...`);
950
982
  continue;
951
983
  }
952
984
 
@@ -955,8 +987,8 @@ async function accountsCommand() {
955
987
  const tier = hasProfile ? (p.hasClaudeMax ? 'Max' : p.hasClaudePro ? 'Pro' : 'subscription') : null;
956
988
  const status = hasProfile ? `Claude ${tier}` : `unknown (${p?.error || 'no token'})`;
957
989
  const src = a.source ? `, ${a.source}` : '';
958
- console.log(` [${i + 1}] ${a.name} (${status}${src})`);
959
- if (hasProfile && p.email && p.email !== a.name) console.log(` Email: ${p.email}`);
990
+ console.log(` [${i + 1}] ${maskIf(a.name, config.maskMode)} (${status}${src})`);
991
+ if (hasProfile && p.email && p.email !== a.name) console.log(` Email: ${maskIf(p.email, config.maskMode)}`);
960
992
  if (hasProfile && p.orgName) console.log(` Org: ${p.orgName}`);
961
993
  if (verbose && a.expiresAt) {
962
994
  const remaining = a.expiresAt - Date.now();
@@ -1156,6 +1188,8 @@ Commands:
1156
1188
  admin disabled in the cloud — that stays excluded)
1157
1189
  priority <name> <n> Set selection priority (lower = preferred; "auto" to return
1158
1190
  to automatic ordering — weekly reset soonest drained first)
1191
+ mask [on|off] Toggle mask mode — hide account emails in CLI/TUI output
1192
+ (display-only security; ma*******73@gmail.com). No arg = toggle
1159
1193
  push Shortcut for "cloud push" — upload local tokens to the cloud
1160
1194
  api <path> Call an API endpoint with account credentials
1161
1195
  cloud <sub> Cloud SaaS: sync tokens + group/auth-key based token control
package/src/mask.js ADDED
@@ -0,0 +1,31 @@
1
+ // Account-identifier masking for "mask mode" — a security posture that hides
2
+ // account emails from shoulder-surfers / screen shares. Pure, zero-dependency.
3
+ //
4
+ // Rule (from the product spec, with the canonical example maestrobs73@gmail.com →
5
+ // ma*******73@gmail.com):
6
+ // - Only the local part (before '@') is masked; the domain is kept intact.
7
+ // - local length >= 7 : first 2 chars + '*' × (n-4) + last 2 chars.
8
+ // - local length <= 6 : first 2 chars + '*' × (n-2) (no trailing reveal — too short).
9
+ // - local length <= 2 : returned unchanged (nothing meaningful to mask).
10
+ // - No '@' (not an email): the whole string is treated as the local part.
11
+ //
12
+ // The SAME algorithm is mirrored in public/app.js (browser) — keep them in sync.
13
+
14
+ export function maskEmail(value) {
15
+ if (typeof value !== 'string' || value.length === 0) return value;
16
+ const at = value.indexOf('@');
17
+ const local = at >= 0 ? value.slice(0, at) : value;
18
+ const domain = at >= 0 ? value.slice(at) : ''; // includes the leading '@'
19
+ const n = local.length;
20
+ let masked;
21
+ if (n <= 2) masked = local;
22
+ else if (n <= 6) masked = local.slice(0, 2) + '*'.repeat(n - 2);
23
+ else masked = local.slice(0, 2) + '*'.repeat(n - 4) + local.slice(n - 2);
24
+ return masked + domain;
25
+ }
26
+
27
+ // Convenience: mask only when the flag is on. Every account-name display site calls
28
+ // this so new/removed accounts are masked uniformly with no per-site special-casing.
29
+ export function maskIf(value, on) {
30
+ return on ? maskEmail(value) : value;
31
+ }
package/src/tui.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { importCredentials, fetchProfile } from './oauth.js';
2
+ import { maskIf } from './mask.js';
2
3
 
3
4
  // ── ANSI helpers ─────────────────────────────────────────────
4
5
 
@@ -114,15 +115,17 @@ function timestamp() {
114
115
  // ── TUI class ────────────────────────────────────────────────
115
116
 
116
117
  export class TUI {
117
- constructor({ accountManager, config, saveConfig, syncAccounts, refreshQuota, onQuit, setLocalDisabled, cloudPush }) {
118
+ constructor({ accountManager, config, saveConfig, syncAccounts, refreshQuota, onQuit, setLocalDisabled, cloudPush, setMask, version }) {
118
119
  this.am = accountManager;
119
120
  this.config = config;
121
+ this.version = version; // optional: shown in the TUI header
120
122
  this.saveConfig = saveConfig;
121
123
  this.syncAccounts = syncAccounts;
122
124
  this.refreshQuota = refreshQuota; // optional: forced fleet quota re-measure (R)
123
125
  this.onQuit = onQuit;
124
126
  this.setLocalDisabled = setLocalDisabled; // optional: persist offline local-disable overlay (by uuid) to disk (fresh-read merge)
125
127
  this.cloudPush = cloudPush; // optional: push account tokens to the cloud (`p`) — returns {pushed,updated,skipped,usage}
128
+ this.setMask = setMask; // optional: persist mask-mode flag to disk (fresh-read merge)
126
129
 
127
130
  this.log = []; // completed activity entries
128
131
  this.active = new Map(); // in-flight requests
@@ -198,12 +201,26 @@ export class TUI {
198
201
  }
199
202
 
200
203
  _addLog(msg) {
201
- msg = msg.replace(/^\[TeamClaude\]\s*/, '');
202
- this.log.unshift({ t: timestamp(), msg });
204
+ // Store RAW; masking is applied at RENDER time (_maskLog) so a mid-session mask
205
+ // toggle re-masks already-buffered lines too (Codex HIGH: buffered logs stayed raw).
206
+ this.log.unshift({ t: timestamp(), msg: msg.replace(/^\[TeamClaude\]\s*/, '') });
203
207
  if (this.log.length > 200) this.log.length = 200;
204
208
  if (this.running) this.render();
205
209
  }
206
210
 
211
+ // Mask every email in a log line when mask mode is on — applied at RENDER time. Uses
212
+ // an email REGEX (not the current account list) so it also masks a just-deleted
213
+ // account's name and can't partially expose one email that is a substring of another
214
+ // (Codex HIGH ×3). Covers TUI-generated lines AND redirected server/account-manager
215
+ // console output, so ordinary request/activity logging can't bypass mask mode.
216
+ _maskLog(s) {
217
+ if (this.config?.maskMode !== true || typeof s !== 'string') return s;
218
+ return s.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, (m) => maskIf(m, true));
219
+ }
220
+
221
+ // Mask a single account name for direct render (active-requests line, etc.).
222
+ _mn(name) { return maskIf(name, this.config?.maskMode === true); }
223
+
207
224
  // ── input handling ─────────────────────────────────
208
225
 
209
226
  _onData(d) {
@@ -265,6 +282,7 @@ export class TUI {
265
282
  if (k === 'a') { this.mode = 'add'; return; }
266
283
  if (k === 'R') { this._doSync(); return; }
267
284
  if (k === 'p') { this._doPush(); return; }
285
+ if (k === 'm') { this._doToggleMask(); return; }
268
286
 
269
287
  if (this.am.accounts.length === 0) return;
270
288
 
@@ -405,6 +423,16 @@ export class TUI {
405
423
  }
406
424
  }
407
425
 
426
+ // `m` toggles mask mode — hides account emails in the account list (display-only
427
+ // security). Persisted to disk via setMask (fresh-read atomic merge) so it survives
428
+ // restart, mirroring the local-disable overlay.
429
+ _doToggleMask() {
430
+ const on = !(this.config.maskMode === true);
431
+ this.config.maskMode = on;
432
+ this.setMask?.(on);
433
+ this._addLog(`Mask mode ${on ? 'ON' : 'OFF'} — account emails ${on ? 'hidden' : 'shown'}`);
434
+ }
435
+
408
436
  async _doImport() {
409
437
  try {
410
438
  this._addLog('Importing credentials...');
@@ -675,7 +703,7 @@ export class TUI {
675
703
  const lines = [];
676
704
 
677
705
  // ── Header
678
- const left = bold(' TeamClaude');
706
+ const left = bold(' TeamClaude') + (this.version ? dim(` v${this.version}`) : '');
679
707
  const port = this.config.proxy?.port || 3456;
680
708
  const right = `Port ${port} ${green('▲')} `;
681
709
  lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
@@ -722,7 +750,7 @@ export class TUI {
722
750
  for (const [, r] of this.active) {
723
751
  const el = ((now - r.started) / 1000).toFixed(1);
724
752
  const sp = cyan(SPINNER[this.frame]);
725
- const a = r.account ? ` → ${r.account}` : '';
753
+ const a = r.account ? ` → ${this._mn(r.account)}` : '';
726
754
  lines.push(` ${sp} ${gray(r.t)} ${r.method} ${r.path}${a} ${dim(`(${el}s...)`)}`);
727
755
  }
728
756
 
@@ -730,7 +758,7 @@ export class TUI {
730
758
  const footerH = 2;
731
759
  const space = Math.max(0, H - lines.length - footerH);
732
760
  for (let i = 0; i < space && i < this.log.length; i++) {
733
- lines.push(` ${gray(this.log[i].t)} ${this.log[i].msg}`);
761
+ lines.push(` ${gray(this.log[i].t)} ${this._maskLog(this.log[i].msg)}`);
734
762
  }
735
763
 
736
764
  // Pad to fill
@@ -767,8 +795,8 @@ export class TUI {
767
795
  // sizes are small; a 3rd digit just widens the gutter for 100+ accounts).
768
796
  const num = gray(String(pos + 1).padStart(2) + '.');
769
797
 
770
- // Name (bold if selected)
771
- const rawName = a.name.slice(0, 12).padEnd(12);
798
+ // Name (bold if selected). Masked when mask mode is on (display-only security).
799
+ const rawName = maskIf(a.name, this.config?.maskMode === true).slice(0, 12).padEnd(12);
772
800
  const name = isSel ? bold(rawName) : rawName;
773
801
 
774
802
  // Type
@@ -853,7 +881,9 @@ export class TUI {
853
881
  case 'normal': {
854
882
  // `p`ush is only shown when a cloud config is present (the callback is wired).
855
883
  const push = this.cloudPush ? ` ${bold('p')}ush` : '';
856
- return ` ${dim('↑↓')} select ${bold('s')}witch ${bold('e')}nable/disable ${bold('o')}rder ${bold('d')}elete ${bold('a')}dd${push} ${bold('R')}eload ${bold('q')}uit`;
884
+ // `m`ask reflects the current state so the user can see it's on at a glance.
885
+ const mask = ` ${bold('m')}ask:${this.config?.maskMode === true ? green('on') : dim('off')}`;
886
+ return ` ${dim('↑↓')} select ${bold('s')}witch ${bold('e')}nable/disable ${bold('o')}rder ${bold('d')}elete ${bold('a')}dd${push}${mask} ${bold('R')}eload ${bold('q')}uit`;
857
887
  }
858
888
  case 'select':
859
889
  return ` ${dim('↑↓')} select ${bold('Enter')} delete ${bold('Esc')} cancel`;