AGC 认证命令失败改为 JS 侧载体并只按变体分流

- 新增 ClientAuthFailure:payload 为 ts-rs 判别联合,context 固定 source=auth + 命令名,cause 为原始拒绝值
- clientAuth 的六个命令统一走 invokeClientAuth:结构化拒绝转成 ClientAuthFailure,非结构化拒绝原样抛出,不兜底文案
- 删除 clientAuthError.ts 的形状读取与 getClientAuthErrorMessage 文案回落层
- AuthenticatedClient 的两个 catch 内联 19 个变体的 switch:业务 / 会话变体原样展示载荷 message,系统变体原样抛出经 unhandledrejection 入池,default: expectNever 保证漏接变体编译失败
- hydrateAuth 失败先离开 loading 态;系统变体不再伪装成 unavailable 投影
- ClientAuthState 收窄为 authenticated / unauthenticated,ClientAuthRefreshResult 收窄为 refreshed / unauthenticated / stale
- platformSession 续期只保留 refreshed / unauthenticated / stale 三条路径
- check-config 的 App 调用扫描登记 invokeClientAuth,继续静态证明命令名可达
- 测试:新增 clientAuthFailure 载体与编译期穷尽用例、jsdom unhandledrejection 桥;删除 clientAuthError.test.ts;clientAuthHost / authFailureReporting / authSurface 跟改新口径
- 共享记忆记录 check-config 扫描形态新增 invokeClientAuth
This commit is contained in:
2026-10-01 20:49:48 +08:00
parent c940bf502c
commit ce54c729e8
13 changed files with 672 additions and 330 deletions
@@ -432,6 +432,7 @@ const APP_INVOKE_BARE_CALL_NAMES = new Set([
'directInvoke',
'invokeInput',
'invokeAuthenticatedInput',
'invokeClientAuth',
'invokeDiagnostic',
]);
@@ -11,9 +11,7 @@ import {
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
import { ErrorReportNotice } from '../components/error-report/ErrorReportNotice';
import { ClientActionError } from '../services/clientActionError';
import {
getClientAuthErrorMessage,
loginClientWithPassword,
loginClientWithPhoneCode,
logoutClientAuthSession,
@@ -22,7 +20,7 @@ import {
sendClientPhoneLoginCode,
subscribeClientAuthState,
} from '../services/clientAuth';
import { isClientAuthError } from '../services/clientAuthError';
import { ClientAuthFailure } from '../services/clientAuthFailure';
import {
type ClientServerPreset,
type ClientServerSelection,
@@ -37,6 +35,18 @@ import {
captureClientError,
installWebviewLogBridge,
} from '../services/errorReporting';
import type { LoginCodeMissing } from '../services/generated/LoginCodeMissing';
import type { PasswordEntryInputRejected } from '../services/generated/PasswordEntryInputRejected';
import type { PasswordMissing } from '../services/generated/PasswordMissing';
import type { PermissionDenied } from '../services/generated/PermissionDenied';
import type { PhoneLoginInputRejected } from '../services/generated/PhoneLoginInputRejected';
import type { PhoneNumberInvalid } from '../services/generated/PhoneNumberInvalid';
import type { PhoneOrPasswordMismatch } from '../services/generated/PhoneOrPasswordMismatch';
import type { SendCodeInputRejected } from '../services/generated/SendCodeInputRejected';
import type { ServerAddressRejected } from '../services/generated/ServerAddressRejected';
import type { SessionAuthorityRejected } from '../services/generated/SessionAuthorityRejected';
import type { SmsCodeInvalidOrExpired } from '../services/generated/SmsCodeInvalidOrExpired';
import type { SmsCodeThrottled } from '../services/generated/SmsCodeThrottled';
import {
beginPlatformSessionClearTransition,
beginPlatformSessionTransition,
@@ -67,6 +77,14 @@ const AUTH_CHECK_REQUEST_TIMEOUT_MS = 15_000;
// 30s startup deadline. Keep the UI fence slightly above that worst case.
const AUTH_CHECK_RUNNER_TIMEOUT_MS = 45_000;
/**
* 编译期穷尽检查:参数只能是 `never`,switch 漏掉任何一个变体这一行就编译不过。
* 运行时不做事(`void`),所以 `default` 分支还要自己 `throw`。
*/
function expectNever(value: never): void {
void value;
}
function withAuthCheckTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
@@ -237,12 +255,6 @@ export function AuthenticatedClient({
'检查登录状态超时,请检查服务器地址和网络后重试',
);
if (!isActiveRun()) return;
if (state.status === 'unavailable') {
setAuthCheckError(state.message);
setLoginStatus(state.message);
setAuthStatus('unauthenticated');
return;
}
if (state.status !== 'authenticated') {
setAuthStatus('unauthenticated');
return;
@@ -263,13 +275,89 @@ export function AuthenticatedClient({
setAuthStatus('authenticated');
} catch (error) {
if (!isActiveRun()) return;
const message = getClientAuthErrorMessage(
error,
'登录服务暂时不可用,请稍后重试',
);
setAuthCheckError(message);
setLoginStatus(message);
// 失败一律先离开检查态:系统变体虽然要原样抛出上报,界面也不能卡在 loading。
setAuthStatus('unauthenticated');
if (!(error instanceof ClientAuthFailure)) throw error;
switch (error.payload.type) {
// 用户自己能改的输入 / 前置条件:展示原因并停在登录页,不进错误池。
case 'serverAddressRejected': {
const payload = error.payload as ServerAddressRejected;
setAuthCheckError(payload.message);
setLoginStatus(payload.message);
break;
}
case 'phoneNumberInvalid': {
const payload = error.payload as PhoneNumberInvalid;
setAuthCheckError(payload.message);
setLoginStatus(payload.message);
break;
}
case 'passwordMissing': {
const payload = error.payload as PasswordMissing;
setAuthCheckError(payload.message);
setLoginStatus(payload.message);
break;
}
case 'loginCodeMissing': {
const payload = error.payload as LoginCodeMissing;
setAuthCheckError(payload.message);
setLoginStatus(payload.message);
break;
}
case 'passwordEntryInputRejected': {
const payload = error.payload as PasswordEntryInputRejected;
setAuthCheckError(payload.message);
setLoginStatus(payload.message);
break;
}
case 'phoneOrPasswordMismatch': {
const payload = error.payload as PhoneOrPasswordMismatch;
setAuthCheckError(payload.message);
setLoginStatus(payload.message);
break;
}
case 'sendCodeInputRejected': {
const payload = error.payload as SendCodeInputRejected;
setAuthCheckError(payload.message);
setLoginStatus(payload.message);
break;
}
case 'smsCodeThrottled': {
const payload = error.payload as SmsCodeThrottled;
setAuthCheckError(payload.message);
setLoginStatus(payload.message);
break;
}
case 'phoneLoginInputRejected': {
const payload = error.payload as PhoneLoginInputRejected;
setAuthCheckError(payload.message);
setLoginStatus(payload.message);
break;
}
case 'smsCodeInvalidOrExpired': {
const payload = error.payload as SmsCodeInvalidOrExpired;
setAuthCheckError(payload.message);
setLoginStatus(payload.message);
break;
}
// 会话权威失效:Rust 已按未登录处理;走到这里也只按未登录呈现。
case 'sessionAuthorityRejected':
case 'permissionDenied':
break;
// 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。
case 'authNetworkUnavailable':
case 'authServiceUnavailable':
case 'unexpectedRejection':
case 'authResponseMalformed':
case 'clientSessionPersistFailed':
case 'runtimeSessionInstallFailed':
case 'authClientInitFailed':
throw error;
default: {
expectNever(error.payload);
throw error;
}
}
}
}
void hydrateAuth();
@@ -328,47 +416,15 @@ export function AuthenticatedClient({
}, [codeCooldownSeconds]);
/**
* 认证动作失败的统一收口:**要不要上报由这里按具体变体判**,不做任何文案匹配。
* 认证失败的分流判据直接写在每个 catch 里:
*
* 名单里的是"用户自己能改的输入 / 前置条件"与"会话路由 401/403":只给提示。其余(网络、
* 5xx、写盘、运行时、响应不合法,以及 Rust 新增而这里没接的变体)带上文交给错误池——
* `default` 指向上报,新变体不会被静默吞掉。`action` 决定错误池指纹,新增认证动作必须走这里。
* - 承载:命令失败由 `invokeClientAuth` 转成 `ClientAuthFailure`,`payload.type` 是唯一分流键;
* - 业务 / 会话变体:把载荷自带的 `message` 原样给用户,永不进错误池;
* - 系统变体:原样 `throw`,经全局 `unhandledrejection` 交给错误池;
* - `default: expectNever` 保证 Rust 新增变体时这里编译失败。
*
* 不把判据抽成函数:判定必须发生在 catch 里。
*/
function presentAuthFailure(
error: unknown,
fallback: string,
action: string,
) {
const structured = isClientAuthError(error);
if (structured) {
switch (structured.type) {
// 用户自己能改的输入 / 前置条件,以及会话路由 401/403(按“未登录”处理):
// 这些都给用户提示,不进错误池。
case 'serverAddressRejected':
case 'phoneNumberInvalid':
case 'passwordMissing':
case 'loginCodeMissing':
case 'passwordEntryInputRejected':
case 'phoneOrPasswordMismatch':
case 'sendCodeInputRejected':
case 'smsCodeThrottled':
case 'phoneLoginInputRejected':
case 'smsCodeInvalidOrExpired':
case 'sessionAuthorityRejected':
case 'permissionDenied': {
setLoginStatus(structured.message.trim() || fallback);
return;
}
default:
break;
}
}
const message = getClientAuthErrorMessage(error, fallback);
setLoginStatus(message);
void captureClientError(
new ClientActionError(message, { source: 'auth', action }, error),
);
}
async function handleSendCode() {
if (codeBusy || codeCooldownSeconds > 0) {
@@ -391,7 +447,82 @@ export function AuthenticatedClient({
setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds)));
setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`);
} catch (error) {
presentAuthFailure(error, '发送验证码失败', 'send-login-code');
if (!(error instanceof ClientAuthFailure)) throw error;
switch (error.payload.type) {
case 'serverAddressRejected': {
const payload = error.payload as ServerAddressRejected;
setLoginStatus(payload.message);
break;
}
case 'phoneNumberInvalid': {
const payload = error.payload as PhoneNumberInvalid;
setLoginStatus(payload.message);
break;
}
case 'passwordMissing': {
const payload = error.payload as PasswordMissing;
setLoginStatus(payload.message);
break;
}
case 'loginCodeMissing': {
const payload = error.payload as LoginCodeMissing;
setLoginStatus(payload.message);
break;
}
case 'passwordEntryInputRejected': {
const payload = error.payload as PasswordEntryInputRejected;
setLoginStatus(payload.message);
break;
}
case 'phoneOrPasswordMismatch': {
const payload = error.payload as PhoneOrPasswordMismatch;
setLoginStatus(payload.message);
break;
}
case 'sendCodeInputRejected': {
const payload = error.payload as SendCodeInputRejected;
setLoginStatus(payload.message);
break;
}
case 'smsCodeThrottled': {
const payload = error.payload as SmsCodeThrottled;
setLoginStatus(payload.message);
break;
}
case 'phoneLoginInputRejected': {
const payload = error.payload as PhoneLoginInputRejected;
setLoginStatus(payload.message);
break;
}
case 'smsCodeInvalidOrExpired': {
const payload = error.payload as SmsCodeInvalidOrExpired;
setLoginStatus(payload.message);
break;
}
case 'sessionAuthorityRejected': {
const payload = error.payload as SessionAuthorityRejected;
setLoginStatus(payload.message);
break;
}
case 'permissionDenied': {
const payload = error.payload as PermissionDenied;
setLoginStatus(payload.message);
break;
}
// 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。
case 'authNetworkUnavailable':
case 'authServiceUnavailable':
case 'unexpectedRejection':
case 'authResponseMalformed':
case 'clientSessionPersistFailed':
case 'runtimeSessionInstallFailed':
case 'authClientInitFailed':
throw error;
default: {
expectNever(error.payload);
throw error;
}
}
} finally {
setCodeBusy(false);
}
@@ -456,7 +587,82 @@ export function AuthenticatedClient({
setCode('');
setPassword('');
} catch (error) {
presentAuthFailure(error, '登录失败', 'login');
if (!(error instanceof ClientAuthFailure)) throw error;
switch (error.payload.type) {
case 'serverAddressRejected': {
const payload = error.payload as ServerAddressRejected;
setLoginStatus(payload.message);
break;
}
case 'phoneNumberInvalid': {
const payload = error.payload as PhoneNumberInvalid;
setLoginStatus(payload.message);
break;
}
case 'passwordMissing': {
const payload = error.payload as PasswordMissing;
setLoginStatus(payload.message);
break;
}
case 'loginCodeMissing': {
const payload = error.payload as LoginCodeMissing;
setLoginStatus(payload.message);
break;
}
case 'passwordEntryInputRejected': {
const payload = error.payload as PasswordEntryInputRejected;
setLoginStatus(payload.message);
break;
}
case 'phoneOrPasswordMismatch': {
const payload = error.payload as PhoneOrPasswordMismatch;
setLoginStatus(payload.message);
break;
}
case 'sendCodeInputRejected': {
const payload = error.payload as SendCodeInputRejected;
setLoginStatus(payload.message);
break;
}
case 'smsCodeThrottled': {
const payload = error.payload as SmsCodeThrottled;
setLoginStatus(payload.message);
break;
}
case 'phoneLoginInputRejected': {
const payload = error.payload as PhoneLoginInputRejected;
setLoginStatus(payload.message);
break;
}
case 'smsCodeInvalidOrExpired': {
const payload = error.payload as SmsCodeInvalidOrExpired;
setLoginStatus(payload.message);
break;
}
case 'sessionAuthorityRejected': {
const payload = error.payload as SessionAuthorityRejected;
setLoginStatus(payload.message);
break;
}
case 'permissionDenied': {
const payload = error.payload as PermissionDenied;
setLoginStatus(payload.message);
break;
}
// 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。
case 'authNetworkUnavailable':
case 'authServiceUnavailable':
case 'unexpectedRejection':
case 'authResponseMalformed':
case 'clientSessionPersistFailed':
case 'runtimeSessionInstallFailed':
case 'authClientInitFailed':
throw error;
default: {
expectNever(error.payload);
throw error;
}
}
} finally {
setLoginBusy(false);
}
@@ -1,6 +1,7 @@
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
import { resolveTauriInvoke } from '../app/tauri';
import { isClientAuthError } from './clientAuthError';
import { ClientAuthFailure } from './clientAuthFailure';
import type { ClientAuthError } from './generated/ClientAuthError';
import { subscribeTauriEvent } from './tauriEventSubscription';
/** Rust 认证态事件:只承载状态投影,不含 token 或 refresh 凭据。 */
@@ -8,14 +9,12 @@ export const CLIENT_AUTH_STATE_CHANGED_EVENT = 'agc-client-auth-state-changed';
export type ClientAuthState =
| { status: 'authenticated'; user: AuthUser; apiBaseUrl: string }
| { status: 'unauthenticated' }
| { status: 'unavailable'; message: string };
| { status: 'unauthenticated' };
export type ClientAuthRefreshResult =
| { status: 'refreshed'; user: AuthUser }
| { status: 'unauthenticated' }
| { status: 'stale' }
| { status: 'failed'; message: string; authoritative: boolean };
| { status: 'stale' };
export type ClientLoginCodeResult = {
cooldownSeconds: number;
@@ -38,55 +37,67 @@ function requireInvoke() {
}
/**
* 从任意拒绝值里取一条可显示文案。
* 认证命令的统一入口:把 Rust 结构化拒绝转成 JS 侧的 `ClientAuthFailure`,
* 非结构化拒绝(Tauri / JS 运行时自己的错误)原样抛出。
*
* 命令失败现在是结构化的(`ClientAuthError`),这里必须按形状取 Rust 那一份文案;裸字符串
* 是旧的 `Err(String)` 残留与浏览器环境的形态。**非 Error 的其它形状不再做字符串化**——
* `String({type,message})` 只会得到 `[object Object]`,把它当文案显示比回落更糟。
*
* 它只取文案,**不判要不要上报**:那由调用方在 catch 里按具体变体决定。
* 只判 `type` 是不是字符串(这是 ts-rs 判别联合的稳定键),**不校验字段名、不读文案判断、
* 不兜底文案**。要不要上报、给不给用户提示,由调用方在 catch 里按 `payload.type` 决定。
*/
export function getClientAuthErrorMessage(error: unknown, fallback: string) {
const structured = isClientAuthError(error);
if (structured) return structured.message.trim() || fallback;
if (error instanceof Error && error.message.trim()) return error.message;
if (typeof error === 'string') return error.trim() || fallback;
return fallback;
async function invokeClientAuth<T>(
command: string,
args?: Record<string, unknown>,
): Promise<T> {
try {
const invoke = requireInvoke();
// 不带参数时保持 `invoke(command)` 的单参调用形态,别给命令多塞一个 undefined。
return args === undefined
? await invoke<T>(command)
: await invoke<T>(command, args);
} catch (error) {
const rejection = error as { type?: unknown; message?: unknown };
if (typeof rejection?.type !== 'string') throw error;
throw new ClientAuthFailure(
typeof rejection.message === 'string' ? rejection.message : '',
error as ClientAuthError,
command,
error,
);
}
}
type RustAuthStateView = {
status?: string;
user?: AuthUser | null;
apiBaseUrl?: string | null;
errorMessage?: string | null;
};
/** Rust 认证态投影,与 `ClientAuthStateView` 一一对应。 */
type RustAuthStateView =
| { status: 'authenticated'; user: AuthUser; apiBaseUrl: string }
| { status: 'unauthenticated' };
/** Rust 续期结果投影,与 `ClientAuthRefreshView` 一一对应。 */
type RustAuthRefreshView =
| { status: 'refreshed'; user: AuthUser }
| { status: 'unauthenticated' }
| { status: 'stale' };
/**
* 恢复登录态。
*
* 凭据续期、当前用户复核与本机运行时会话安装都在 Rust 内完成;这里只把结果投影成
* `authenticated` / `unauthenticated` / `unavailable` 三态,供登录页决定展示分支。
* 凭据续期、当前用户复核与本机运行时会话安装都在 Rust 内完成;读状态失败就是命令失败,
* 由 `invokeClientAuth` 转成 `ClientAuthFailure`,不再有第三态投影。
*/
export async function readClientAuthState(
expectedApiBaseUrl?: string,
): Promise<ClientAuthState> {
const invoke = requireInvoke();
const view = await invoke<RustAuthStateView>('read_client_auth_state', {
expectedApiBaseUrl: expectedApiBaseUrl?.trim() || null,
});
if (view?.status === 'authenticated' && view.user && view.apiBaseUrl) {
const view = await invokeClientAuth<RustAuthStateView>(
'read_client_auth_state',
{
expectedApiBaseUrl: expectedApiBaseUrl?.trim() || null,
},
);
if (view.status === 'authenticated') {
return {
status: 'authenticated',
user: view.user,
apiBaseUrl: view.apiBaseUrl,
};
}
if (view?.status === 'unavailable') {
return {
status: 'unavailable',
message: view.errorMessage?.trim() || '登录服务暂时不可用,请稍后重试',
};
}
return { status: 'unauthenticated' };
}
@@ -94,8 +105,7 @@ export async function sendClientPhoneLoginCode(
phone: string,
apiBaseUrl: string,
): Promise<ClientLoginCodeResult> {
const invoke = requireInvoke();
const result = await invoke<{
const result = await invokeClientAuth<{
cooldownSeconds?: number;
expiresInSeconds?: number;
}>('send_client_phone_login_code', {
@@ -113,8 +123,7 @@ export async function loginClientWithPassword(
password: string,
apiBaseUrl: string,
): Promise<AuthUser> {
const invoke = requireInvoke();
return invoke<AuthUser>('login_client_with_password', {
return invokeClientAuth<AuthUser>('login_client_with_password', {
apiBaseUrl,
phone: normalizeAuthPhoneInput(phone),
password: password.trim(),
@@ -126,8 +135,7 @@ export async function loginClientWithPhoneCode(
code: string,
apiBaseUrl: string,
): Promise<AuthUser> {
const invoke = requireInvoke();
return invoke<AuthUser>('login_client_with_phone_code', {
return invokeClientAuth<AuthUser>('login_client_with_phone_code', {
apiBaseUrl,
phone: normalizeAuthPhoneInput(phone),
code: code.trim(),
@@ -136,43 +144,23 @@ export async function loginClientWithPhoneCode(
/** 登出:Rust 负责服务端撤销、凭据清除与本机运行时会话清理。 */
export async function logoutClientAuthSession(): Promise<void> {
const invoke = requireInvoke();
await invoke('logout_client_session');
await invokeClientAuth('logout_client_session');
}
export async function refreshClientAuthSession(
expectedUserId?: string,
): Promise<ClientAuthRefreshResult> {
const invoke = requireInvoke();
const view = await invoke<{
status?: string;
user?: AuthUser | null;
authoritative?: boolean;
errorMessage?: string | null;
}>('refresh_client_auth_session', {
expectedUserId: expectedUserId?.trim() || null,
});
switch (view?.status) {
case 'refreshed':
if (!view.user) {
return {
status: 'failed',
message: '刷新登录状态失败',
authoritative: false,
};
}
return { status: 'refreshed', user: view.user };
case 'unauthenticated':
return { status: 'unauthenticated' };
case 'stale':
return { status: 'stale' };
default:
return {
status: 'failed',
message: view?.errorMessage?.trim() || '刷新登录状态失败',
authoritative: view?.authoritative === true,
};
const view = await invokeClientAuth<RustAuthRefreshView>(
'refresh_client_auth_session',
{ expectedUserId: expectedUserId?.trim() || null },
);
if (view.status === 'refreshed') {
return { status: 'refreshed', user: view.user };
}
if (view.status === 'unauthenticated') {
return { status: 'unauthenticated' };
}
return { status: 'stale' };
}
/** 订阅 Rust 认证态事件,返回幂等释放函数。 */
@@ -183,7 +171,7 @@ export function subscribeClientAuthState(
CLIENT_AUTH_STATE_CHANGED_EVENT,
(event) => {
const view = event.payload;
if (view?.status === 'authenticated' && view.user && view.apiBaseUrl) {
if (view.status === 'authenticated') {
listener({
status: 'authenticated',
user: view.user,
@@ -1,23 +0,0 @@
import type { ClientAuthError } from './generated/ClientAuthError';
export type { ClientAuthError };
/**
* `invoke` 拒绝时拿到的是 Rust 序列化出来的普通对象(不是 `Error`)。这里只做形状读取:
* `type` 是稳定判别键,`message` 是 Rust 生成的可展示文案,**文案不参与任何判断**。
*
* 这里**不做任何分类**:要不要上报由调用方在 catch 里按具体变体判(`switch (error.type)`),
* 不在名单里的新变体由调用方的 `default` 分支交给错误池——见 ADR 的"未识别变体上调"。
*
* **`status` 故意不校验**:`authServiceUnavailable` / `unexpectedRejection` 在生成类型里带必填
* `status`,但全仓没有调用方读它(这两个变体只显示 `message`)。把 `status` 纳入校验只会让
* "缺 `status` 但 `message` 可用"的拒绝落回非结构化分支,把 Rust 的文案换成调用方兜底文案
* ——诊断信息更少、行为更差。将来真要用 `status` 做判断时,先补校验再用。
*/
export function isClientAuthError(value: unknown): ClientAuthError | null {
if (!value || typeof value !== 'object') return null;
const candidate = value as { type?: unknown; message?: unknown };
if (typeof candidate.type !== 'string' || !candidate.type) return null;
if (typeof candidate.message !== 'string') return null;
return value as ClientAuthError;
}
@@ -0,0 +1,30 @@
import { ClientActionError } from './clientActionError';
import type { ClientAuthError } from './generated/ClientAuthError';
/**
* 认证命令失败在 JS 侧的载体。
*
* Tauri 的 `invoke` 拒绝值是普通对象(Rust 序列化的结构化错误),不是 `Error`;直接抛出会被
* 上报链路降级成 `String(obj)`,类型与文案一起丢掉。`invokeClientAuth` 统一把它转成本类:
*
* - `payload` 是 ts-rs 生成的判别联合,`payload.type` 是唯一的稳定分流键;
* - `context` 固定为 `{ source: 'auth', action: 命令名 }`,给错误池定指纹;
* - `cause` 是原始拒绝值,保留给上报链路取栈与排障。
*
* 本类只承载事实,**不做分类、不产派生值、不兜底文案**:要不要上报、给不给用户提示,由调用方
* 在 catch 里按 `payload.type` 判。
*/
export class ClientAuthFailure extends ClientActionError {
readonly payload: ClientAuthError;
constructor(
message: string,
payload: ClientAuthError,
command: string,
cause: unknown,
) {
super(message, { source: 'auth', action: command }, cause);
this.name = 'ClientAuthFailure';
this.payload = payload;
}
}
@@ -168,14 +168,8 @@ export function requestPlatformSessionRefresh(expectedUserId?: string) {
authoritative: true,
};
}
if (result.status === 'stale') {
return { status: 'stale' };
}
return {
status: 'failed',
error: new Error(result.message),
authoritative: result.authoritative,
};
// `stale`:续期期间身份已变化,调用方不得重放旧身份请求。
return { status: 'stale' };
})().then((result) => {
notifyPlatformSessionRefresh(result);
return result;
@@ -5,6 +5,7 @@ import {
currentPlatformSessionGeneration,
resetPlatformSessionForTests,
} from '../../src/services/platformSession';
import { installUnhandledRejectionBridge } from '../unhandledRejectionBridge';
import {
act,
AuthenticatedClient,
@@ -99,26 +100,40 @@ export function registerAuthTests() {
delete window.__TAURI__;
});
it('leaves startup loading with an actionable retry after auth check timeout', async () => {
renderAuthSurface(async (command: string) => {
if (command === 'read_client_auth_state') {
return await new Promise(() => {});
}
return null;
});
it('throws a startup timeout through to unhandledrejection and leaves the loading state', async () => {
const uninstall = installUnhandledRejectionBridge();
const reasons: unknown[] = [];
const onRejection = (event: Event) => {
reasons.push((event as Event & { reason?: unknown }).reason);
};
window.addEventListener('unhandledrejection', onRejection);
try {
renderAuthSurface(async (command: string) => {
if (command === 'read_client_auth_state') {
return await new Promise(() => {});
}
return null;
});
expect(
await screen.findByText(
'检查登录状态超时,请检查服务器地址和网络后重试',
undefined,
{
timeout: 20_000,
},
),
).not.toBeNull();
expect(
screen.getByRole('button', { name: '重试登录状态检查' }),
).toHaveProperty('disabled', false);
await waitFor(
() =>
expect(
reasons.some(
(reason) =>
reason instanceof Error &&
reason.message.includes('检查登录状态超时'),
),
).toBe(true),
{ timeout: 20_000 },
);
expect(
screen.queryByRole('button', { name: '重试登录状态检查' }),
).toBeNull();
await screen.findByRole('main', { name: '登录' });
} finally {
window.removeEventListener('unhandledrejection', onRejection);
uninstall();
}
}, 30_000);
it('renders the unauthenticated client with the shared light platform theme and product image', async () => {
@@ -254,47 +269,51 @@ export function registerAuthTests() {
expect(screen.queryByLabelText('已登录')).toBeNull();
});
it('keeps transport details out of the login failure copy', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'read_client_auth_state') {
return { status: 'unauthenticated' };
}
if (command === 'login_client_with_phone_code') {
throw {
type: 'authNetworkUnavailable',
message: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
};
}
return null;
});
renderAuthSurface(invoke);
await loginWithCode();
it('keeps transport details out of the login copy by throwing system variants through', async () => {
const uninstall = installUnhandledRejectionBridge();
try {
const invoke = vi.fn(async (command: string) => {
if (command === 'read_client_auth_state') {
return { status: 'unauthenticated' };
}
if (command === 'login_client_with_phone_code') {
throw {
type: 'authNetworkUnavailable',
message: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
};
}
return null;
});
renderAuthSurface(invoke);
await loginWithCode();
expect(
await screen.findByText(
'无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
),
).not.toBeNull();
expect(document.body.textContent).not.toContain('ECONNREFUSED');
await waitFor(() =>
expect(screen.queryByText(/无法连接登录服务/u)).toBeNull(),
);
expect(document.body.textContent).not.toContain('ECONNREFUSED');
await screen.findByRole('main', { name: '登录' });
} finally {
uninstall();
}
});
it('keeps a retry when the startup check reports the service unavailable', async () => {
it('keeps a retry when the startup check is rejected by a business variant', async () => {
let reads = 0;
const invoke = vi.fn(async (command: string) => {
if (command === 'read_client_auth_state') {
reads += 1;
return reads === 1
? {
status: 'unavailable',
errorMessage: '无法连接登录服务',
}
? Promise.reject({
type: 'serverAddressRejected',
message: '服务器地址无效',
})
: { status: 'unauthenticated' };
}
return null;
});
renderAuthSurface(invoke);
expect(await screen.findByText('无法连接登录服务')).not.toBeNull();
expect(await screen.findByText('服务器地址无效')).not.toBeNull();
expect(screen.queryByLabelText('已登录')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '重试登录状态检查' }));
@@ -28,6 +28,7 @@ import { invoke } from '@tauri-apps/api/core';
import { AuthenticatedClient } from '../src/app/AuthenticatedClient';
import { resetPlatformSessionForTests } from '../src/services/platformSession';
import { installUnhandledRejectionBridge } from './unhandledRejectionBridge';
type AuthInvoke = (
command: string,
@@ -105,24 +106,52 @@ describe('认证失败的上报判据', () => {
expect(reportCalls()).toHaveLength(0);
});
it('系统变体(网络不可达)带 auth/login 上下文上报一次', async () => {
it('系统变体(网络不可达)原样抛出,经 unhandledrejection 上报一次', async () => {
const message = '无法连接登录服务,请确认配套后端或 API 代理已启动后重试';
await submitPasswordLogin(async (command) => {
if (command === 'read_client_auth_state') {
return { status: 'unauthenticated' };
}
if (command === 'login_client_with_password') {
throw { type: 'authNetworkUnavailable', message };
}
return null;
});
const uninstall = installUnhandledRejectionBridge();
try {
await submitPasswordLogin(async (command) => {
if (command === 'read_client_auth_state') {
return { status: 'unauthenticated' };
}
if (command === 'login_client_with_password') {
throw { type: 'authNetworkUnavailable', message };
}
return null;
});
expect(await screen.findByText(message)).not.toBeNull();
await waitFor(() => expect(reportCalls()).toHaveLength(1));
expect(reportCalls()[0]?.[1]).toMatchObject({
source: 'auth',
action: 'login',
message,
});
await waitFor(() => expect(reportCalls()).toHaveLength(1));
expect(reportCalls()[0]?.[1]).toMatchObject({
source: 'auth',
action: 'login_client_with_password',
message,
});
expect(screen.queryByText(message)).toBeNull();
} finally {
uninstall();
}
});
it('非结构化拒绝原样抛出,由 unhandledrejection 兜底上报', async () => {
const uninstall = installUnhandledRejectionBridge();
try {
await submitPasswordLogin(async (command) => {
if (command === 'read_client_auth_state') {
return { status: 'unauthenticated' };
}
if (command === 'login_client_with_password') {
throw new Error('IPC 桥接不可用');
}
return null;
});
await waitFor(() => expect(reportCalls()).toHaveLength(1));
expect(reportCalls()[0]?.[1]).toMatchObject({
source: 'unhandledrejection',
message: 'IPC 桥接不可用',
});
} finally {
uninstall();
}
});
});
@@ -1,77 +0,0 @@
import { describe, expect, it } from 'vitest';
import { isClientAuthError } from '../src/services/clientAuthError';
import type { AuthServiceUnavailable } from '../src/services/generated/AuthServiceUnavailable';
import type { ClientAuthError } from '../src/services/generated/ClientAuthError';
import type { PhoneNumberInvalid } from '../src/services/generated/PhoneNumberInvalid';
/**
* 编译期用例:每个变体在 `type` 上判别后都能落到一个**具名载荷类型**,
* 等价于 Java 的 `catch (PhoneNumberInvalid e)`;类型不匹配时 `tsc` 会直接报错。
*/
function handleByNamedType(error: ClientAuthError) {
switch (error.type) {
case 'phoneNumberInvalid': {
const payload: PhoneNumberInvalid = error;
return payload.message;
}
case 'authServiceUnavailable': {
const payload: AuthServiceUnavailable = error;
return `${payload.status}:${payload.message}`;
}
default:
return '';
}
}
describe('AGC 认证命令错误的形状读取', () => {
it('判别后按具名载荷类型分支处理', () => {
expect(
handleByNamedType({
type: 'phoneNumberInvalid',
message: '请输入正确的手机号',
}),
).toBe('请输入正确的手机号');
expect(
handleByNamedType({
type: 'authServiceUnavailable',
status: 503,
message: '登录服务暂时不可用',
}),
).toBe('503:登录服务暂时不可用');
});
it('只按形状读取 Rust 的结构化拒绝,裸字符串与 Error 都不算', () => {
expect(
isClientAuthError({
type: 'phoneOrPasswordMismatch',
message: '手机号或密码错误',
}),
).toEqual({ type: 'phoneOrPasswordMismatch', message: '手机号或密码错误' });
expect(isClientAuthError('手机号或密码错误')).toBeNull();
expect(isClientAuthError(new Error('手机号或密码错误'))).toBeNull();
expect(
isClientAuthError({ type: '', message: '手机号或密码错误' }),
).toBeNull();
expect(isClientAuthError({ type: 'phoneNumberInvalid' })).toBeNull();
expect(isClientAuthError(null)).toBeNull();
});
it('未知变体也算形状合法,由调用方按 default 交给错误池', () => {
expect(
isClientAuthError({ type: 'brandNewRejection', message: '新变体' }),
).toEqual({ type: 'brandNewRejection', message: '新变体' });
});
it('status 故意不校验:缺 status 但 message 可用的拒绝仍可读', () => {
expect(
isClientAuthError({
type: 'authServiceUnavailable',
message: '登录服务暂时不可用',
}),
).toEqual({
type: 'authServiceUnavailable',
message: '登录服务暂时不可用',
});
});
});
@@ -0,0 +1,164 @@
// @vitest-environment jsdom
/**
* 认证命令失败的 JS 侧载体。
*
* Rust 结构化拒绝(普通对象)必须被 `invokeClientAuth` 转成 `ClientAuthFailure`,非结构化拒绝
* 原样抛出;分流只按 `payload.type`,不读文案。另外用编译期用例钉住"每个变体一个具名载荷
* 类型":漏列任何变体,`expectNever(error)` 都编译不过。
*/
import { describe, expect, it, vi } from 'vitest';
import { ClientActionError } from '../src/services/clientActionError';
import { loginClientWithPassword } from '../src/services/clientAuth';
import { ClientAuthFailure } from '../src/services/clientAuthFailure';
import type { AuthClientInitFailed } from '../src/services/generated/AuthClientInitFailed';
import type { AuthNetworkUnavailable } from '../src/services/generated/AuthNetworkUnavailable';
import type { AuthResponseMalformed } from '../src/services/generated/AuthResponseMalformed';
import type { AuthServiceUnavailable } from '../src/services/generated/AuthServiceUnavailable';
import type { ClientAuthError } from '../src/services/generated/ClientAuthError';
import type { ClientSessionPersistFailed } from '../src/services/generated/ClientSessionPersistFailed';
import type { LoginCodeMissing } from '../src/services/generated/LoginCodeMissing';
import type { PasswordEntryInputRejected } from '../src/services/generated/PasswordEntryInputRejected';
import type { PasswordMissing } from '../src/services/generated/PasswordMissing';
import type { PermissionDenied } from '../src/services/generated/PermissionDenied';
import type { PhoneLoginInputRejected } from '../src/services/generated/PhoneLoginInputRejected';
import type { PhoneNumberInvalid } from '../src/services/generated/PhoneNumberInvalid';
import type { PhoneOrPasswordMismatch } from '../src/services/generated/PhoneOrPasswordMismatch';
import type { RuntimeSessionInstallFailed } from '../src/services/generated/RuntimeSessionInstallFailed';
import type { SendCodeInputRejected } from '../src/services/generated/SendCodeInputRejected';
import type { ServerAddressRejected } from '../src/services/generated/ServerAddressRejected';
import type { SessionAuthorityRejected } from '../src/services/generated/SessionAuthorityRejected';
import type { SmsCodeInvalidOrExpired } from '../src/services/generated/SmsCodeInvalidOrExpired';
import type { SmsCodeThrottled } from '../src/services/generated/SmsCodeThrottled';
import type { UnexpectedRejection } from '../src/services/generated/UnexpectedRejection';
function expectNever(value: never): void {
void value;
}
/** 编译期用例:19 个变体逐个列全,每个分支用 `as` 取具名载荷类型。 */
function variantMessage(error: ClientAuthError): string {
switch (error.type) {
case 'serverAddressRejected':
return (error as ServerAddressRejected).message;
case 'phoneNumberInvalid':
return (error as PhoneNumberInvalid).message;
case 'passwordMissing':
return (error as PasswordMissing).message;
case 'loginCodeMissing':
return (error as LoginCodeMissing).message;
case 'passwordEntryInputRejected':
return (error as PasswordEntryInputRejected).message;
case 'phoneOrPasswordMismatch':
return (error as PhoneOrPasswordMismatch).message;
case 'sendCodeInputRejected':
return (error as SendCodeInputRejected).message;
case 'smsCodeThrottled':
return (error as SmsCodeThrottled).message;
case 'phoneLoginInputRejected':
return (error as PhoneLoginInputRejected).message;
case 'smsCodeInvalidOrExpired':
return (error as SmsCodeInvalidOrExpired).message;
case 'sessionAuthorityRejected':
return (error as SessionAuthorityRejected).message;
case 'permissionDenied':
return (error as PermissionDenied).message;
case 'authNetworkUnavailable':
return (error as AuthNetworkUnavailable).message;
case 'authServiceUnavailable':
return (error as AuthServiceUnavailable).message;
case 'unexpectedRejection':
return (error as UnexpectedRejection).message;
case 'authResponseMalformed':
return (error as AuthResponseMalformed).message;
case 'clientSessionPersistFailed':
return (error as ClientSessionPersistFailed).message;
case 'runtimeSessionInstallFailed':
return (error as RuntimeSessionInstallFailed).message;
case 'authClientInitFailed':
return (error as AuthClientInitFailed).message;
default: {
expectNever(error);
return '';
}
}
}
function installInvoke(handler: () => Promise<unknown>) {
window.__TAURI__ = { core: { invoke: vi.fn(handler) } } as never;
}
describe('ClientAuthFailure', () => {
it('结构化拒绝转成带 payload / context / cause 的 JS 错误', async () => {
const rejection = {
type: 'phoneNumberInvalid',
message: '请输入正确的手机号',
};
installInvoke(async () => {
throw rejection;
});
const failure = await loginClientWithPassword(
'13800000000',
'secret',
'https://dev.genarrative.world',
).catch((error: unknown) => error);
expect(failure).toBeInstanceOf(ClientAuthFailure);
expect(failure).toBeInstanceOf(ClientActionError);
expect((failure as ClientAuthFailure).payload).toEqual(rejection);
expect((failure as ClientAuthFailure).context).toEqual({
source: 'auth',
action: 'login_client_with_password',
});
expect((failure as ClientAuthFailure).cause).toBe(rejection);
expect((failure as Error).message).toBe('请输入正确的手机号');
});
it('非结构化拒绝原样抛出,不包装也不改写', async () => {
const rejection = new Error('需要在 Tauri App 内登录');
installInvoke(async () => {
throw rejection;
});
await expect(
loginClientWithPassword(
'13800000000',
'secret',
'https://dev.genarrative.world',
),
).rejects.toBe(rejection);
});
it('结构化拒绝缺 message 时 Error.message 为空,不造兜底文案', async () => {
installInvoke(async () => {
throw { type: 'unexpectedRejection', status: 409 };
});
const failure = await loginClientWithPassword(
'13800000000',
'secret',
'https://dev.genarrative.world',
).catch((error: unknown) => error);
expect((failure as Error).message).toBe('');
expect((failure as ClientAuthFailure).payload.type).toBe(
'unexpectedRejection',
);
});
});
describe('变体分流', () => {
it('判别后按具名载荷类型取值', () => {
expect(
variantMessage({ type: 'phoneNumberInvalid', message: '格式不对' }),
).toBe('格式不对');
expect(
variantMessage({
type: 'authServiceUnavailable',
status: 503,
message: '服务暂不可用',
}),
).toBe('服务暂不可用');
});
});
@@ -10,7 +10,6 @@ import { beforeEach, expect, test, vi } from 'vitest';
const invoke = vi.fn();
import {
getClientAuthErrorMessage,
loginClientWithPassword,
loginClientWithPhoneCode,
logoutClientAuthSession,
@@ -47,7 +46,7 @@ test('手机号归一化只保留可提交的纯号码', () => {
expect(normalizeAuthPhoneInput('0086-138-0000-0000')).toBe('008613800000000');
});
test('恢复登录态映射为 authenticated / unauthenticated / unavailable', async () => {
test('恢复登录态映射为 authenticated / unauthenticated', async () => {
invoke.mockResolvedValueOnce({
status: 'authenticated',
user: testUser,
@@ -71,15 +70,6 @@ test('恢复登录态映射为 authenticated / unauthenticated / unavailable', a
expect(invoke).toHaveBeenCalledWith('read_client_auth_state', {
expectedApiBaseUrl: null,
});
invoke.mockResolvedValueOnce({
status: 'unavailable',
errorMessage: '无法连接登录服务',
});
await expect(readClientAuthState()).resolves.toEqual({
status: 'unavailable',
message: '无法连接登录服务',
});
});
test('登录只提交结构化参数,凭据由 Rust 持有', async () => {
@@ -135,7 +125,7 @@ test('登录只提交结构化参数,凭据由 Rust 持有', async () => {
expect(serialized).not.toContain('cookie');
});
test('续期结果区分成功、失效、身份变化与暂时失败', async () => {
test('续期结果区分成功、失效与身份变化', async () => {
invoke.mockResolvedValueOnce({ status: 'refreshed', user: testUser });
await expect(refreshClientAuthSession('user-1')).resolves.toEqual({
status: 'refreshed',
@@ -151,17 +141,6 @@ test('续期结果区分成功、失效、身份变化与暂时失败', async ()
await expect(refreshClientAuthSession()).resolves.toEqual({
status: 'stale',
});
invoke.mockResolvedValueOnce({
status: 'failed',
authoritative: false,
errorMessage: '无法连接登录服务',
});
await expect(refreshClientAuthSession()).resolves.toEqual({
status: 'failed',
message: '无法连接登录服务',
authoritative: false,
});
});
test('没有原生宿主时登录能力明确失败关闭', async () => {
@@ -179,9 +158,14 @@ test('没有原生宿主时登录能力明确失败关闭', async () => {
expect(invoke).not.toHaveBeenCalled();
});
test('错误文案优先使用服务端原因,缺失时回落到调用方文案', () => {
expect(
getClientAuthErrorMessage(new Error('手机号或密码错误'), '登录失败'),
).toBe('手机号或密码错误');
expect(getClientAuthErrorMessage('', '登录失败')).toBe('登录失败');
test('结构化失败不降级成投影值,而是以 ClientAuthFailure 拒绝', async () => {
const rejection = {
type: 'authNetworkUnavailable',
message: '无法连接登录服务',
};
invoke.mockRejectedValueOnce(rejection);
await expect(refreshClientAuthSession('user-1')).rejects.toMatchObject({
payload: rejection,
context: { source: 'auth', action: 'refresh_client_auth_session' },
});
});
@@ -0,0 +1,27 @@
/**
* 把 Node 的 `unhandledRejection` 桥接成 jsdom `window` 的 `unhandledrejection` 事件。
*
* 生产 WebView 里,`void` 掉的 async handler(`onSubmit` / `onClick`)抛出的拒绝会派发
* `unhandledrejection`,被 `AuthenticatedClient` 的全局 handler 交给错误池。jsdom 既不派发
* 该事件,vitest 又会把未处理的拒绝当成用例失败;需要观察这条链路的用例用它接管 Node
* 监听器,再合成一个带 `reason` 的 window 事件。
*
* 用法:用例内 `const uninstall = installUnhandledRejectionBridge()`,在 `finally` 或
* `afterEach` 里 `uninstall()`。
*/
export function installUnhandledRejectionBridge() {
const originalListeners = process.listeners('unhandledRejection');
process.removeAllListeners('unhandledRejection');
const handleRejection = (reason: unknown) => {
const event = new Event('unhandledrejection');
Object.defineProperty(event, 'reason', { value: reason });
window.dispatchEvent(event);
};
process.on('unhandledRejection', handleRejection);
return () => {
process.removeListener('unhandledRejection', handleRejection);
for (const listener of originalListeners) {
process.on('unhandledRejection', listener);
}
};
}
@@ -8106,7 +8106,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
## 2026-08-22 AGC Tauri 命令调用可达性失败关闭
- 决策:`check-config.mjs` 的 App 调用扫描必须识别现役精确形态:裸 `invoke`、`directInvoke`、素材画布的 `invokeInput` / `invokeAuthenticatedInput` wrapper,以及对象字段 `.invoke`;不以包含 `invoke` 的任意名称、动态命令变量、注释、字符串、模板或正则文本作为可达证据。
- 决策:`check-config.mjs` 的 App 调用扫描必须识别现役精确形态:裸 `invoke`、`directInvoke`、素材画布的 `invokeInput` / `invokeAuthenticatedInput`、认证命令的 `invokeClientAuth` wrapper,以及对象字段 `.invoke`;不以包含 `invoke` 的任意名称、动态命令变量、注释、字符串、模板或正则文本作为可达证据。
- allowlist 边界:前端源码已调用的命令不得继续保留在 explicit native-only allowlist。allowlist 只承载确实由原生窗口或原生侧流程触发、App 源码不直接调用的 handler;源码调用与 allowlist 必须互斥。
- 门禁:逐文件使用仓库锁定的 TypeScript AST 解析,设置文件数量、单文件 / 总源码长度、命令长度和调用数量上限。回归测试同时锁定直接、wrapper、对象字段的正例与诱饵 / 动态 / 畸形输入的反例,并证明删除真实 wrapper 调用后 handler 可达性检查失败,不能由错误 allowlist 继续误绿。