teamclaude-cloud 1.5.3 → 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 +1 -1
- package/src/config.js +4 -0
- package/src/index.js +42 -8
- package/src/mask.js +31 -0
- package/src/tui.js +21 -5
package/package.json
CHANGED
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(`
|
|
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(
|
|
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
|
-
|
|
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`;
|