mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 22:14:22 +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:
1 parent
caba99c3b5
commit
d1b579ad1b
2 files changed
+231
-3
No files matched your search
@@ -835,6 +835,113 @@ export function discoverExistingAccounts(): void {
|
|||||||
saveAccountsRegistry(freshRegistry);
|
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
|
* Get summary of all accounts across providers
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -12,22 +12,40 @@ import {
|
|||||||
getAllAccountsSummary,
|
getAllAccountsSummary,
|
||||||
setDefaultAccount as setCliproxyDefault,
|
setDefaultAccount as setCliproxyDefault,
|
||||||
removeAccount as removeCliproxyAccount,
|
removeAccount as removeCliproxyAccount,
|
||||||
|
bulkPauseAccounts,
|
||||||
|
bulkResumeAccounts,
|
||||||
|
soloAccount,
|
||||||
} from '../../cliproxy/account-manager';
|
} from '../../cliproxy/account-manager';
|
||||||
import { CLIProxyProvider } from '../../cliproxy/types';
|
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const registry = new ProfileRegistry();
|
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" */
|
/** Parse CLIProxy account key format: "provider:accountId" */
|
||||||
function parseCliproxyKey(key: string): { provider: CLIProxyProvider; accountId: string } | null {
|
function parseCliproxyKey(key: string): { provider: CLIProxyProvider; accountId: string } | null {
|
||||||
const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp'];
|
|
||||||
const colonIndex = key.indexOf(':');
|
const colonIndex = key.indexOf(':');
|
||||||
if (colonIndex === -1) return null;
|
if (colonIndex === -1) return null;
|
||||||
|
|
||||||
const provider = key.slice(0, colonIndex) as CLIProxyProvider;
|
const provider = key.slice(0, colonIndex) as CLIProxyProvider;
|
||||||
const accountId = key.slice(colonIndex + 1);
|
const accountId = key.slice(colonIndex + 1);
|
||||||
|
|
||||||
if (!providers.includes(provider) || !accountId) return null;
|
if (!isValidProvider(provider) || !accountId) return null;
|
||||||
return { provider, accountId };
|
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;
|
export default router;
|
||||||
Reference in new issue
Block a user