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 +1 -1
- package/src/config.js +4 -0
- package/src/index.js +42 -8
- package/src/mask.js +31 -0
- package/src/tui.js +39 -9
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
|
|
@@ -198,12 +201,26 @@ export class TUI {
|
|
|
198
201
|
}
|
|
199
202
|
|
|
200
203
|
_addLog(msg) {
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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`;
|