fix(api): add race condition prevention and input validation for account control

- add mutex pattern with providerLocks Map for solo mode

- convert soloAccount to async function

- extract VALID_PROVIDERS constant and isValidProvider helper (DRY)

- allow empty arrays in bulk endpoints (return early success)

- add accountIds string validation (non-empty strings only)
This commit is contained in:
kaitranntt
2026-01-24 12:22:49 -05:00
parent caba99c3b5
commit d1b579ad1b
2 changed files with 231 additions and 3 deletions
+107
View File
@@ -835,6 +835,113 @@ export function discoverExistingAccounts(): void {
saveAccountsRegistry(freshRegistry);
}
/** Result of bulk pause/resume operations */
export interface BulkOperationResult {
succeeded: string[];
failed: Array<{ id: string; reason: string }>;
}
/** Result of solo account operation */
export interface SoloOperationResult {
activated: string;
paused: string[];
}
/** Provider-level locks for preventing race conditions in solo mode */
const providerLocks = new Map<CLIProxyProvider, Promise<unknown>>();
/**
* Bulk pause multiple accounts
* Pauses each account, collecting successes and failures (no fail-fast)
*/
export function bulkPauseAccounts(
provider: CLIProxyProvider,
accountIds: string[]
): BulkOperationResult {
const result: BulkOperationResult = { succeeded: [], failed: [] };
for (const id of accountIds) {
const success = pauseAccount(provider, id);
if (success) {
result.succeeded.push(id);
} else {
result.failed.push({ id, reason: 'Account not found' });
}
}
return result;
}
/**
* Bulk resume multiple accounts
* Resumes each account, collecting successes and failures (no fail-fast)
*/
export function bulkResumeAccounts(
provider: CLIProxyProvider,
accountIds: string[]
): BulkOperationResult {
const result: BulkOperationResult = { succeeded: [], failed: [] };
for (const id of accountIds) {
const success = resumeAccount(provider, id);
if (success) {
result.succeeded.push(id);
} else {
result.failed.push({ id, reason: 'Account not found' });
}
}
return result;
}
/**
* Solo mode: activate one account, pause all others in same provider
* Per validation: auto-resumes target if paused, keeps default unchanged
*/
export async function soloAccount(
provider: CLIProxyProvider,
accountId: string
): Promise<SoloOperationResult | null> {
// Wait for any pending operation on this provider
const pending = providerLocks.get(provider);
if (pending) await pending;
const operation = (async () => {
const accounts = getProviderAccounts(provider);
const targetAccount = accounts.find((a) => a.id === accountId);
if (!targetAccount) {
return null;
}
const result: SoloOperationResult = { activated: accountId, paused: [] };
// Resume target account if paused (per validation decision)
if (targetAccount.paused) {
resumeAccount(provider, accountId);
}
// Pause all other accounts
for (const account of accounts) {
if (account.id !== accountId && !account.paused) {
const success = pauseAccount(provider, account.id);
if (success) {
result.paused.push(account.id);
}
}
}
return result;
})();
providerLocks.set(provider, operation);
try {
return await operation;
} finally {
providerLocks.delete(provider);
}
}
/**
* Get summary of all accounts across providers
*/
+124 -3
View File
@@ -12,22 +12,40 @@ import {
getAllAccountsSummary,
setDefaultAccount as setCliproxyDefault,
removeAccount as removeCliproxyAccount,
bulkPauseAccounts,
bulkResumeAccounts,
soloAccount,
} from '../../cliproxy/account-manager';
import { CLIProxyProvider } from '../../cliproxy/types';
import type { CLIProxyProvider } from '../../cliproxy/types';
const router = Router();
const registry = new ProfileRegistry();
/** Valid CLIProxy providers */
const VALID_PROVIDERS: CLIProxyProvider[] = [
'gemini',
'codex',
'agy',
'qwen',
'iflow',
'kiro',
'ghcp',
];
/** Check if provider is valid */
function isValidProvider(provider: string): provider is CLIProxyProvider {
return VALID_PROVIDERS.includes(provider as CLIProxyProvider);
}
/** Parse CLIProxy account key format: "provider:accountId" */
function parseCliproxyKey(key: string): { provider: CLIProxyProvider; accountId: string } | null {
const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp'];
const colonIndex = key.indexOf(':');
if (colonIndex === -1) return null;
const provider = key.slice(0, colonIndex) as CLIProxyProvider;
const accountId = key.slice(colonIndex + 1);
if (!providers.includes(provider) || !accountId) return null;
if (!isValidProvider(provider) || !accountId) return null;
return { provider, accountId };
}
@@ -203,4 +221,107 @@ router.delete('/:name', (req: Request, res: Response): void => {
}
});
/**
* POST /api/accounts/bulk-pause - Bulk pause multiple accounts
*/
router.post('/bulk-pause', (req: Request, res: Response): void => {
try {
const { provider, accountIds } = req.body;
if (!provider || !Array.isArray(accountIds)) {
res.status(400).json({ error: 'Missing required fields: provider and accountIds (array)' });
return;
}
if (!isValidProvider(provider)) {
res.status(400).json({ error: `Invalid provider: ${provider}` });
return;
}
// Allow empty arrays - return early success
if (accountIds.length === 0) {
res.json({ succeeded: [], failed: [] });
return;
}
// Validate accountIds are non-empty strings
const invalidIds = accountIds.filter((id) => typeof id !== 'string' || id.trim().length === 0);
if (invalidIds.length > 0) {
res.status(400).json({ error: 'Invalid accountIds: must be non-empty strings' });
return;
}
const result = bulkPauseAccounts(provider, accountIds);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/accounts/bulk-resume - Bulk resume multiple accounts
*/
router.post('/bulk-resume', (req: Request, res: Response): void => {
try {
const { provider, accountIds } = req.body;
if (!provider || !Array.isArray(accountIds)) {
res.status(400).json({ error: 'Missing required fields: provider and accountIds (array)' });
return;
}
if (!isValidProvider(provider)) {
res.status(400).json({ error: `Invalid provider: ${provider}` });
return;
}
// Allow empty arrays - return early success
if (accountIds.length === 0) {
res.json({ succeeded: [], failed: [] });
return;
}
// Validate accountIds are non-empty strings
const invalidIds = accountIds.filter((id) => typeof id !== 'string' || id.trim().length === 0);
if (invalidIds.length > 0) {
res.status(400).json({ error: 'Invalid accountIds: must be non-empty strings' });
return;
}
const result = bulkResumeAccounts(provider, accountIds);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/accounts/solo - Solo mode: activate one account, pause all others
*/
router.post('/solo', async (req: Request, res: Response): Promise<void> => {
try {
const { provider, accountId } = req.body;
if (!provider || !accountId) {
res.status(400).json({ error: 'Missing required fields: provider and accountId' });
return;
}
if (!isValidProvider(provider)) {
res.status(400).json({ error: `Invalid provider: ${provider}` });
return;
}
const result = await soloAccount(provider, accountId);
if (!result) {
res.status(404).json({ error: `Account not found: ${accountId}` });
return;
}
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
export default router;