mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
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:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user