From f5996d4c13d16fc0b4dc2c1cae12f7136e57c79d Mon Sep 17 00:00:00 2001 From: seakee Date: Mon, 20 Jul 2026 15:15:38 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=90=9B=20fix(auth-files):=20prevent?= =?UTF-8?q?=20OAuth=20alias=20write=20races=20and=20silent=20drops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serialize OAuth model-alias mutations with a write queue and soft-refresh baseline so concurrent diagram updates do not overwrite each other. Align client validation with CPA sanitize rules (reject name===alias and duplicate aliases), plan multi-channel renames before write with best-effort rollback, and restrict excluded-model DELETE fallbacks to 404/405. Improve list previews and scope/usage copy for OAuth channels. --- .../AuthFilesOAuthModelAliasEditPage.tsx | 51 +- .../authFiles/AuthFilesPage.module.scss | 7 + .../components/OAuthExcludedCard.tsx | 12 + .../components/OAuthModelAliasCard.tsx | 9 + .../authFiles/hooks/useAuthFilesOauth.tsx | 557 +++++++++--------- .../authFiles/oauthAliasValidation.test.ts | 207 +++++++ .../authFiles/oauthAliasValidation.ts | 271 +++++++++ apps/web/src/i18n/locales/en.json | 9 +- apps/web/src/i18n/locales/ru.json | 9 +- apps/web/src/i18n/locales/zh-CN.json | 9 +- apps/web/src/i18n/locales/zh-TW.json | 9 +- apps/web/src/services/api/authFiles.test.ts | 19 + apps/web/src/services/api/authFiles.ts | 9 +- 13 files changed, 879 insertions(+), 299 deletions(-) create mode 100644 apps/web/src/features/authFiles/oauthAliasValidation.test.ts create mode 100644 apps/web/src/features/authFiles/oauthAliasValidation.ts diff --git a/apps/web/src/features/authFiles/AuthFilesOAuthModelAliasEditPage.tsx b/apps/web/src/features/authFiles/AuthFilesOAuthModelAliasEditPage.tsx index 613ae079d..65b669861 100644 --- a/apps/web/src/features/authFiles/AuthFilesOAuthModelAliasEditPage.tsx +++ b/apps/web/src/features/authFiles/AuthFilesOAuthModelAliasEditPage.tsx @@ -16,6 +16,7 @@ import type { AuthFileItem, OAuthModelAliasEntry } from '@/types'; import { generateId, getErrorMessage } from '@/utils/helpers'; import styles from './AuthFilesOAuthModelAliasEditPage.module.scss'; import { isOAuthAliasDraftDirty } from './oauthEditorState'; +import { normalizeOAuthAliasEntries } from './oauthAliasValidation'; type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string }; @@ -336,23 +337,34 @@ export function AuthFilesOAuthModelAliasEditPage() { return; } - const seen = new Set(); - const normalized = mappings - .map((entry) => { - const name = String(entry.name ?? '').trim(); - const alias = String(entry.alias ?? '').trim(); - if (!name || !alias) return null; - const key = `${name.toLowerCase()}::${alias.toLowerCase()}::${entry.fork ? '1' : '0'}::${entry.forceMapping ? '1' : '0'}`; - if (seen.has(key)) return null; - seen.add(key); - return { - name, - alias, - ...(entry.fork ? { fork: true } : {}), - ...(entry.forceMapping ? { forceMapping: true } : {}), - }; - }) - .filter(Boolean) as OAuthModelAliasEntry[]; + const normalization = normalizeOAuthAliasEntries(mappings); + // Whole-form validation: refuse to save when any row would be dropped by CPA rules. + // This keeps the draft visible so the user can fix invalid rows instead of silently + // persisting a partial subset. + const firstIssue = normalization.issues[0]; + if (firstIssue) { + if (firstIssue.code === 'same_as_name') { + showNotification(t('oauth_model_alias.alias_same_as_name'), 'error'); + return; + } + if (firstIssue.code === 'duplicate_alias') { + showNotification( + t('oauth_model_alias.alias_duplicate', { alias: firstIssue.alias ?? '' }), + 'error' + ); + return; + } + if (firstIssue.code === 'empty_fields' || firstIssue.code === 'duplicate_entry') { + showNotification(t('oauth_model_alias.alias_incomplete'), 'error'); + return; + } + } + + const normalized = normalization.accepted; + if (normalized.length === 0 && mappings.some((entry) => entry.name.trim() || entry.alias.trim())) { + showNotification(t('oauth_model_alias.alias_incomplete'), 'error'); + return; + } setSaving(true); try { @@ -434,6 +446,11 @@ export function AuthFilesOAuthModelAliasEditPage() {
{headerHint}
+
+
{t('oauth_model_alias.scope_hint')}
+
{t('oauth_model_alias.usage_hint')}
+
+
diff --git a/apps/web/src/features/authFiles/AuthFilesPage.module.scss b/apps/web/src/features/authFiles/AuthFilesPage.module.scss index cd4c01321..2cf399e29 100644 --- a/apps/web/src/features/authFiles/AuthFilesPage.module.scss +++ b/apps/web/src/features/authFiles/AuthFilesPage.module.scss @@ -1949,6 +1949,13 @@ gap: $spacing-sm; } +.cardScopeHint { + margin: 0 0 $spacing-sm; + color: var(--text-secondary); + font-size: $font-size-sm; + line-height: 1.45; +} + .excludedItem { display: flex; justify-content: space-between; diff --git a/apps/web/src/features/authFiles/components/OAuthExcludedCard.tsx b/apps/web/src/features/authFiles/components/OAuthExcludedCard.tsx index a163bc2ff..676535bb0 100644 --- a/apps/web/src/features/authFiles/components/OAuthExcludedCard.tsx +++ b/apps/web/src/features/authFiles/components/OAuthExcludedCard.tsx @@ -29,6 +29,9 @@ export function OAuthExcludedCard(props: OAuthExcludedCardProps) { } > + {loadState === 'ready' ? ( +
{t('oauth_excluded.scope_hint')}
+ ) : null} {loadState === 'unsupported' ? ( + {models?.length ? ( +
+ {models.slice(0, 3).join(' · ')} + {models.length > 3 ? ` · +${models.length - 3}` : ''} +
+ ) : null}
{t('ai_providers.claude_models_hint')}
+
{t('ai_providers.model_alias_scope_hint')}
(null); + /** Synchronous lock so double-clicks in the same tick cannot start two enable/disable writes. */ + const configSwitchingLockRef = useRef(false); // 表格筛选 / 排序 / 详情状态 const [kindFilter, setKindFilter] = useState('all'); @@ -319,6 +321,7 @@ export function AiProvidersPage() { actions: Map ) => { if (actions.size === 0) return; + if (configSwitchingLockRef.current || configSwitchingKey) return; const rowByKey = new Map(rows.map((row) => [row.key, row])); const previous = { @@ -428,6 +431,7 @@ export function AiProvidersPage() { return; } + configSwitchingLockRef.current = true; setConfigSwitchingKey('health-check'); const applyLocalState = ( @@ -552,8 +556,9 @@ export function AiProvidersPage() { showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); throw err; } finally { - setConfigSwitchingKey(null); - } + configSwitchingLockRef.current = false; + setConfigSwitchingKey(null); + } }; const setHealthCheckProviderEnabled = async (providerKey: string, enabled: boolean) => { @@ -566,12 +571,15 @@ export function AiProvidersPage() { index: number, enabled: boolean ) => { + if (configSwitchingLockRef.current || configSwitchingKey) return; + if (provider === 'gemini' || provider === 'interactions') { const source = provider === 'gemini' ? geminiKeys : interactionsKeys; const current = source[index]; if (!current) return; const switchingKey = `${provider}:${current.apiKey}`; + configSwitchingLockRef.current = true; setConfigSwitchingKey(switchingKey); const previousList = source; @@ -615,6 +623,7 @@ export function AiProvidersPage() { } showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { + configSwitchingLockRef.current = false; setConfigSwitchingKey(null); } return; @@ -632,6 +641,7 @@ export function AiProvidersPage() { if (!current) return; const switchingKey = `${provider}:${current.apiKey}`; + configSwitchingLockRef.current = true; setConfigSwitchingKey(switchingKey); const previousList = source; @@ -695,15 +705,18 @@ export function AiProvidersPage() { } showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { - setConfigSwitchingKey(null); - } + configSwitchingLockRef.current = false; + setConfigSwitchingKey(null); + } }; const setOpenAIProviderEnabled = async (index: number, enabled: boolean) => { + if (configSwitchingLockRef.current || configSwitchingKey) return; const current = openaiProviders[index]; if (!current) return; const switchingKey = `openai:${current.name}:${index}`; + configSwitchingLockRef.current = true; setConfigSwitchingKey(switchingKey); const previousList = openaiProviders; @@ -728,8 +741,9 @@ export function AiProvidersPage() { clearCache('openai-compatibility'); showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { - setConfigSwitchingKey(null); - } + configSwitchingLockRef.current = false; + setConfigSwitchingKey(null); + } }; const setProviderWebsocketsEnabled = async ( @@ -742,7 +756,9 @@ export function AiProvidersPage() { const current = source[index]; if (!current) return; + if (configSwitchingLockRef.current || configSwitchingKey) return; const switchingKey = `${provider}:${current.apiKey}:websockets`; + configSwitchingLockRef.current = true; setConfigSwitchingKey(switchingKey); const previousList = source; @@ -794,8 +810,9 @@ export function AiProvidersPage() { } showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { - setConfigSwitchingKey(null); - } + configSwitchingLockRef.current = false; + setConfigSwitchingKey(null); + } }; const setProviderCloakEnabled = async ( @@ -807,7 +824,9 @@ export function AiProvidersPage() { const current = source[index]; if (!current) return; + if (configSwitchingLockRef.current || configSwitchingKey) return; const switchingKey = `${provider}:${current.apiKey}:cloak`; + configSwitchingLockRef.current = true; setConfigSwitchingKey(switchingKey); const previousList = source; @@ -852,8 +871,9 @@ export function AiProvidersPage() { } showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { - setConfigSwitchingKey(null); - } + configSwitchingLockRef.current = false; + setConfigSwitchingKey(null); + } }; const setProviderDisableCoolingEnabled = async ( @@ -911,6 +931,7 @@ export function AiProvidersPage() { } showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { + configSwitchingLockRef.current = false; setConfigSwitchingKey(null); } return; @@ -942,6 +963,7 @@ export function AiProvidersPage() { clearCache('openai-compatibility'); showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { + configSwitchingLockRef.current = false; setConfigSwitchingKey(null); } return; @@ -1004,8 +1026,9 @@ export function AiProvidersPage() { } showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { - setConfigSwitchingKey(null); - } + configSwitchingLockRef.current = false; + setConfigSwitchingKey(null); + } }; const setProviderPriority = async (row: ProviderRow, priority: number) => { @@ -1062,6 +1085,7 @@ export function AiProvidersPage() { } showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { + configSwitchingLockRef.current = false; setConfigSwitchingKey(null); } return; @@ -1095,6 +1119,7 @@ export function AiProvidersPage() { clearCache('openai-compatibility'); showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { + configSwitchingLockRef.current = false; setConfigSwitchingKey(null); } return; @@ -1174,8 +1199,9 @@ export function AiProvidersPage() { } showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { - setConfigSwitchingKey(null); - } + configSwitchingLockRef.current = false; + setConfigSwitchingKey(null); + } }; // 删除(按 provider 分派,沿用既有 API 契约) diff --git a/apps/web/src/services/api/providers.ts b/apps/web/src/services/api/providers.ts index b0f151310..4a3eb86f2 100644 --- a/apps/web/src/services/api/providers.ts +++ b/apps/web/src/services/api/providers.ts @@ -409,14 +409,31 @@ export const replaceLatestProviderRecord = ( ); }; +/** Serializes read-modify-write updates per CPA config section to avoid lost updates. */ +const providerSectionWriteQueues = new Map>(); + +const enqueueProviderSectionWrite = (section: string, task: () => Promise): Promise => { + const previous = providerSectionWriteQueues.get(section) ?? Promise.resolve(); + const run = previous.then(task, task); + providerSectionWriteQueues.set( + section, + run.then( + () => undefined, + () => undefined + ) + ); + return run; +}; + const mutateLatestProviderList = async ( section: string, mutate: (latestItems: unknown[]) => unknown[] -) => { - const rawConfig = await apiClient.get('/config'); - const latestItems = getRawSectionList(rawConfig, section); - await apiClient.put(`/${section}`, mutate(latestItems)); -}; +) => + enqueueProviderSectionWrite(section, async () => { + const rawConfig = await apiClient.get('/config'); + const latestItems = getRawSectionList(rawConfig, section); + await apiClient.put(`/${section}`, mutate(latestItems)); + }); const matchesProviderConfig = ( record: Record, From d87f3df84d2feefa73316766b7caec4a1d8bee51 Mon Sep 17 00:00:00 2001 From: seakee Date: Mon, 20 Jul 2026 15:17:37 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=9B=20fix(demo):=20return=20CPA-sh?= =?UTF-8?q?aped=20oauth-model-alias=20payload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serve demo GET /oauth-model-alias as the real channel→alias map used by CPA instead of a legacy items array with identity mappings. This keeps demo mode from normalizing to an empty alias list and misleading manual checks of multi-mapping behavior. --- apps/web/src/features/demo/demoApi.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/web/src/features/demo/demoApi.ts b/apps/web/src/features/demo/demoApi.ts index 22792a08b..9784e48c2 100644 --- a/apps/web/src/features/demo/demoApi.ts +++ b/apps/web/src/features/demo/demoApi.ts @@ -119,10 +119,16 @@ export async function handleDemoApiRequest( if (pathname === '/oauth-model-alias') { if (method === 'get') { return { - items: [ - { provider: 'codex', source: 'gpt-5-codex', target: 'gpt-4.1' }, - { provider: 'claude', source: 'claude-sonnet-4-5', target: 'claude-sonnet-4-5' }, - ], + 'oauth-model-alias': { + codex: [ + { name: 'gpt-5-codex', alias: 'team-codex', fork: true }, + { name: 'gpt-5', alias: 'g5', fork: true }, + ], + claude: [ + { name: 'claude-sonnet-4-5-20250929', alias: 'claude-sonnet-4-5', fork: true }, + { name: 'claude-opus-4-1-20250805', alias: 'claude-opus-4-1', fork: true }, + ], + }, } as T; } return ok as T; From 0a2bbdbabaae4bd176aeb4cc85869be0edf9bba8 Mon Sep 17 00:00:00 2001 From: seakee Date: Mon, 20 Jul 2026 16:36:30 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=90=9B=20fix(auth-files):=20replace?= =?UTF-8?q?=20undefined=20SCSS=20font-size=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $font-size-sm is not defined in styles/variables.scss, so the new .cardScopeHint rule broke vite production builds. Use 12px to match nearby secondary text styles in AuthFilesPage. --- apps/web/src/features/authFiles/AuthFilesPage.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/features/authFiles/AuthFilesPage.module.scss b/apps/web/src/features/authFiles/AuthFilesPage.module.scss index 2cf399e29..52d0905ec 100644 --- a/apps/web/src/features/authFiles/AuthFilesPage.module.scss +++ b/apps/web/src/features/authFiles/AuthFilesPage.module.scss @@ -1952,7 +1952,7 @@ .cardScopeHint { margin: 0 0 $spacing-sm; color: var(--text-secondary); - font-size: $font-size-sm; + font-size: 12px; line-height: 1.45; }