teamclaude-cloud 1.5.4 → 1.5.5

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.5",
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
@@ -265,6 +268,7 @@ export class TUI {
265
268
  if (k === 'a') { this.mode = 'add'; return; }
266
269
  if (k === 'R') { this._doSync(); return; }
267
270
  if (k === 'p') { this._doPush(); return; }
271
+ if (k === 'm') { this._doToggleMask(); return; }
268
272
 
269
273
  if (this.am.accounts.length === 0) return;
270
274
 
@@ -405,6 +409,16 @@ export class TUI {
405
409
  }
406
410
  }
407
411
 
412
+ // `m` toggles mask mode — hides account emails in the account list (display-only
413
+ // security). Persisted to disk via setMask (fresh-read atomic merge) so it survives
414
+ // restart, mirroring the local-disable overlay.
415
+ _doToggleMask() {
416
+ const on = !(this.config.maskMode === true);
417
+ this.config.maskMode = on;
418
+ this.setMask?.(on);
419
+ this._addLog(`Mask mode ${on ? 'ON' : 'OFF'} — account emails ${on ? 'hidden' : 'shown'}`);
420
+ }
421
+
408
422
  async _doImport() {
409
423
  try {
410
424
  this._addLog('Importing credentials...');
@@ -675,7 +689,7 @@ export class TUI {
675
689
  const lines = [];
676
690
 
677
691
  // ── Header
678
- const left = bold(' TeamClaude');
692
+ const left = bold(' TeamClaude') + (this.version ? dim(` v${this.version}`) : '');
679
693
  const port = this.config.proxy?.port || 3456;
680
694
  const right = `Port ${port} ${green('▲')} `;
681
695
  lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
@@ -767,8 +781,8 @@ export class TUI {
767
781
  // sizes are small; a 3rd digit just widens the gutter for 100+ accounts).
768
782
  const num = gray(String(pos + 1).padStart(2) + '.');
769
783
 
770
- // Name (bold if selected)
771
- const rawName = a.name.slice(0, 12).padEnd(12);
784
+ // Name (bold if selected). Masked when mask mode is on (display-only security).
785
+ const rawName = maskIf(a.name, this.config?.maskMode === true).slice(0, 12).padEnd(12);
772
786
  const name = isSel ? bold(rawName) : rawName;
773
787
 
774
788
  // Type
@@ -853,7 +867,9 @@ export class TUI {
853
867
  case 'normal': {
854
868
  // `p`ush is only shown when a cloud config is present (the callback is wired).
855
869
  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`;
870
+ // `m`ask reflects the current state so the user can see it's on at a glance.
871
+ const mask = ` ${bold('m')}ask:${this.config?.maskMode === true ? green('on') : dim('off')}`;
872
+ 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
873
  }
858
874
  case 'select':
859
875
  return ` ${dim('↑↓')} select ${bold('Enter')} delete ${bold('Esc')} cancel`;