diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx
index 314453e7..354cf205 100644
--- a/apps/web/src/app/App.tsx
+++ b/apps/web/src/app/App.tsx
@@ -15,7 +15,7 @@ import {
getCachedDesktopSession,
saveDesktopApiBaseUrl,
} from "@/lib/api";
-import { classifyLoginError, type LoginProblem } from "@/lib/login-error";
+import { classifyLoginError, getLoginProblemMessageKey } from "@/lib/login-error";
import { EVERNOTE_MIGRATION_PATH } from "@/lib/routes";
import { isBrowserOffline } from "@/lib/network-status";
import type { AuthSession } from "@edgeever/shared";
@@ -160,38 +160,12 @@ const AuthenticatedWorkspace = () => {
: sessionQuery.error
? classifyLoginError(sessionQuery.error, "session")
: null;
- const problemMessage = (value: LoginProblem) => {
- switch (value.kind) {
- case "invalidInstanceUrl":
- return t("login.desktopInstanceUrlInvalid");
- case "instanceUnreachable":
- return t("login.instanceUnreachable");
- case "instanceApiNotFound":
- return t("login.instanceApiNotFound");
- case "invalidCredentials":
- return t("login.invalidCredentials");
- case "sessionExpired":
- return t("login.sessionExpired");
- case "loginRateLimited":
- return t("login.loginRateLimited");
- case "authNotConfigured":
- return t("login.authNotConfigured");
- case "databaseNotReady":
- return t("login.databaseNotReady");
- case "passwordHashInvalid":
- return t("login.passwordHashInvalid");
- case "instanceServerError":
- return t("login.instanceServerError", { status: value.status });
- case "requestRejected":
- return t("login.requestRejected", { status: value.status });
- case "invalidResponse":
- return t("login.invalidInstanceResponse");
- case "unexpected":
- return t("login.unexpectedError");
- }
- };
const loginError = problem
- ? { message: problemMessage(problem), diagnosticCode: problem.diagnosticCode }
+ ? {
+ message: t(getLoginProblemMessageKey(problem), "status" in problem ? { status: problem.status } : undefined),
+ diagnosticCode: problem.diagnosticCode,
+ rayId: problem.rayId,
+ }
: null;
if (desktopBridge?.isAvailable && !desktopScopeReady) {
diff --git a/apps/web/src/components/LoginScreen.tsx b/apps/web/src/components/LoginScreen.tsx
index 7373d8f1..777d5abe 100644
--- a/apps/web/src/components/LoginScreen.tsx
+++ b/apps/web/src/components/LoginScreen.tsx
@@ -6,7 +6,7 @@ import { GitHubRepositoryLink } from "@/components/GitHubRepositoryLink";
import { Input } from "@/components/ui/input";
interface LoginScreenProps {
- error: { message: string; diagnosticCode: string } | null;
+ error: { message: string; diagnosticCode: string; rayId?: string } | null;
instanceUrl?: string;
isSubmitting: boolean;
onSubmit: (payload: { instanceUrl?: string; username: string; password: string }) => void;
@@ -71,6 +71,11 @@ export const LoginScreen = ({ error, instanceUrl: initialInstanceUrl, isSubmitti
{t("login.diagnosticCode", { code: error.diagnosticCode })}
+ {error.rayId ? (
+
+ {t("login.cloudflareRayId", { id: error.rayId })}
+
+ ) : null}
) : null}
diff --git a/apps/web/src/lib/api.test.mjs b/apps/web/src/lib/api.test.mjs
index 2d29c693..0e12e3e0 100644
--- a/apps/web/src/lib/api.test.mjs
+++ b/apps/web/src/lib/api.test.mjs
@@ -62,6 +62,7 @@ afterAll(() => {
});
const {
+ ApiRequestError,
DESKTOP_API_BASE_URL_STORAGE_KEY,
api,
cacheDesktopSession,
@@ -315,4 +316,30 @@ describe("desktop instance setup", () => {
authorization: "Bearer replacement-session-token",
});
});
+
+ test("preserves Cloudflare response diagnostics for login failures", async () => {
+ globalThis.fetch = async () => new Response("challenge", {
+ status: 403,
+ headers: {
+ "CF-Mitigated": "challenge",
+ "CF-Ray": "abc123-SJC",
+ "Content-Type": "text/html",
+ },
+ });
+
+ try {
+ await api.login({ username: "admin", password: "secret" });
+ throw new Error("Expected login to fail");
+ } catch (error) {
+ expect(error).toBeInstanceOf(ApiRequestError);
+ expect(error).toMatchObject({
+ status: 403,
+ responseDiagnostics: {
+ cloudflareMitigated: true,
+ isEdgeEverApiError: false,
+ rayId: "abc123-SJC",
+ },
+ });
+ }
+ });
});
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 69fd06d9..ad1dd463 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -258,16 +258,30 @@ export class ApiRequestError extends Error {
status: number;
code?: string;
details?: unknown;
+ responseDiagnostics?: ApiResponseDiagnostics;
- constructor(message: string, status: number, code?: string, details?: unknown) {
+ constructor(
+ message: string,
+ status: number,
+ code?: string,
+ details?: unknown,
+ responseDiagnostics?: ApiResponseDiagnostics,
+ ) {
super(message);
this.name = "ApiRequestError";
this.status = status;
this.code = code;
this.details = details;
+ this.responseDiagnostics = responseDiagnostics;
}
}
+export type ApiResponseDiagnostics = {
+ cloudflareMitigated: boolean;
+ isEdgeEverApiError: boolean;
+ rayId?: string;
+};
+
let desktopSessionRejected = false;
const isDesktopAuthenticationRequest = (path: string) =>
@@ -299,13 +313,20 @@ const request = async (path: string, init?: RequestInit): Promise => {
if (!response.ok) {
const body = await response.json().catch(() => null);
+ const rayId = response.headers.get("cf-ray")?.trim();
const error = body && typeof body === "object" && "error" in body
? (body as { error?: { code?: string; message?: string; details?: unknown } }).error
: undefined;
+ const isEdgeEverApiError = Boolean(error && typeof error === "object");
const message =
body && typeof body === "object" && "error" in body
? error?.message
: response.statusText;
+ const responseDiagnostics: ApiResponseDiagnostics = {
+ cloudflareMitigated: response.headers.get("cf-mitigated") === "challenge",
+ isEdgeEverApiError,
+ ...(rayId ? { rayId } : {}),
+ };
if (response.status === 401) {
if (isDesktop) {
@@ -319,7 +340,13 @@ const request = async (path: string, init?: RequestInit): Promise => {
}
}
- throw new ApiRequestError(message || "Request failed", response.status, error?.code, error?.details);
+ throw new ApiRequestError(
+ message || "Request failed",
+ response.status,
+ error?.code,
+ error?.details,
+ responseDiagnostics,
+ );
}
const body = await response.json() as T;
diff --git a/apps/web/src/lib/login-error.test.mjs b/apps/web/src/lib/login-error.test.mjs
index c85ec3af..81de10e8 100644
--- a/apps/web/src/lib/login-error.test.mjs
+++ b/apps/web/src/lib/login-error.test.mjs
@@ -33,6 +33,44 @@ describe("login error classification", () => {
});
});
+ test("identifies Cloudflare challenges and preserves the Ray ID", () => {
+ const challenge = new ApiRequestError("Forbidden", 403, undefined, undefined, {
+ cloudflareMitigated: true,
+ isEdgeEverApiError: false,
+ rayId: "abc123-SJC",
+ });
+
+ expect(classifyLoginError(challenge, "login")).toEqual({
+ kind: "cloudflareChallenge",
+ diagnosticCode: "cloudflare_challenge",
+ rayId: "abc123-SJC",
+ });
+ });
+
+ test("distinguishes an edge security block from an EdgeEver forbidden response", () => {
+ const blocked = new ApiRequestError("Forbidden", 403, undefined, undefined, {
+ cloudflareMitigated: false,
+ isEdgeEverApiError: false,
+ rayId: "def456-NRT",
+ });
+ const forbidden = new ApiRequestError("Forbidden", 403, "forbidden", undefined, {
+ cloudflareMitigated: false,
+ isEdgeEverApiError: true,
+ rayId: "ghi789-LAX",
+ });
+
+ expect(classifyLoginError(blocked, "login")).toEqual({
+ kind: "securityPolicyBlocked",
+ diagnosticCode: "security_policy_blocked",
+ rayId: "def456-NRT",
+ });
+ expect(classifyLoginError(forbidden, "login")).toEqual({
+ kind: "requestForbidden",
+ diagnosticCode: "forbidden",
+ rayId: "ghi789-LAX",
+ });
+ });
+
test("identifies responses that are not EdgeEver JSON", () => {
expect(classifyLoginError(new SyntaxError("Unexpected token"), "login")).toEqual({
kind: "invalidResponse",
diff --git a/apps/web/src/lib/login-error.ts b/apps/web/src/lib/login-error.ts
index dc5e8c51..26f5c5e9 100644
--- a/apps/web/src/lib/login-error.ts
+++ b/apps/web/src/lib/login-error.ts
@@ -2,7 +2,12 @@ import { ApiRequestError, DesktopInstanceUrlError } from "@/lib/api";
export type LoginErrorPhase = "session" | "login";
-export type LoginProblem =
+type LoginProblemDetails = {
+ diagnosticCode: string;
+ rayId?: string;
+};
+
+export type LoginProblem = LoginProblemDetails & (
| { kind: "invalidInstanceUrl"; diagnosticCode: "instance_url_invalid" }
| { kind: "instanceUnreachable"; diagnosticCode: "network_unreachable" }
| { kind: "instanceApiNotFound"; diagnosticCode: "edgeever_api_not_found" }
@@ -12,10 +17,58 @@ export type LoginProblem =
| { kind: "authNotConfigured"; diagnosticCode: "auth_not_configured" }
| { kind: "databaseNotReady"; diagnosticCode: "database_not_ready" }
| { kind: "passwordHashInvalid"; diagnosticCode: "password_hash_invalid" }
+ | { kind: "cloudflareChallenge"; diagnosticCode: "cloudflare_challenge" }
+ | { kind: "securityPolicyBlocked"; diagnosticCode: "security_policy_blocked" }
+ | { kind: "requestForbidden"; diagnosticCode: string }
| { kind: "instanceServerError"; diagnosticCode: string; status: number }
| { kind: "requestRejected"; diagnosticCode: string; status: number }
| { kind: "invalidResponse"; diagnosticCode: "invalid_instance_response" }
- | { kind: "unexpected"; diagnosticCode: "unexpected_login_error" };
+ | { kind: "unexpected"; diagnosticCode: "unexpected_login_error" }
+);
+
+export type LoginProblemMessageKey =
+ | "login.desktopInstanceUrlInvalid"
+ | "login.instanceUnreachable"
+ | "login.instanceApiNotFound"
+ | "login.invalidCredentials"
+ | "login.sessionExpired"
+ | "login.loginRateLimited"
+ | "login.authNotConfigured"
+ | "login.databaseNotReady"
+ | "login.passwordHashInvalid"
+ | "login.cloudflareChallenge"
+ | "login.securityPolicyBlocked"
+ | "login.requestForbidden"
+ | "login.instanceServerError"
+ | "login.requestRejected"
+ | "login.invalidInstanceResponse"
+ | "login.unexpectedError";
+
+const withRayId = (problem: T, error: ApiRequestError): T => {
+ const rayId = error.responseDiagnostics?.rayId;
+ return rayId ? { ...problem, rayId } : problem;
+};
+
+export const getLoginProblemMessageKey = (problem: LoginProblem): LoginProblemMessageKey => {
+ switch (problem.kind) {
+ case "invalidInstanceUrl": return "login.desktopInstanceUrlInvalid";
+ case "instanceUnreachable": return "login.instanceUnreachable";
+ case "instanceApiNotFound": return "login.instanceApiNotFound";
+ case "invalidCredentials": return "login.invalidCredentials";
+ case "sessionExpired": return "login.sessionExpired";
+ case "loginRateLimited": return "login.loginRateLimited";
+ case "authNotConfigured": return "login.authNotConfigured";
+ case "databaseNotReady": return "login.databaseNotReady";
+ case "passwordHashInvalid": return "login.passwordHashInvalid";
+ case "cloudflareChallenge": return "login.cloudflareChallenge";
+ case "securityPolicyBlocked": return "login.securityPolicyBlocked";
+ case "requestForbidden": return "login.requestForbidden";
+ case "instanceServerError": return "login.instanceServerError";
+ case "requestRejected": return "login.requestRejected";
+ case "invalidResponse": return "login.invalidInstanceResponse";
+ case "unexpected": return "login.unexpectedError";
+ }
+};
export const classifyLoginError = (error: unknown, phase: LoginErrorPhase): LoginProblem => {
if (error instanceof DesktopInstanceUrlError) {
@@ -23,34 +76,45 @@ export const classifyLoginError = (error: unknown, phase: LoginErrorPhase): Logi
}
if (error instanceof ApiRequestError) {
+ if (error.responseDiagnostics?.cloudflareMitigated) {
+ return withRayId({ kind: "cloudflareChallenge", diagnosticCode: "cloudflare_challenge" }, error);
+ }
if (error.code === "auth_not_configured") {
- return { kind: "authNotConfigured", diagnosticCode: "auth_not_configured" };
+ return withRayId({ kind: "authNotConfigured", diagnosticCode: "auth_not_configured" }, error);
}
if (error.code === "database_not_ready") {
- return { kind: "databaseNotReady", diagnosticCode: "database_not_ready" };
+ return withRayId({ kind: "databaseNotReady", diagnosticCode: "database_not_ready" }, error);
}
if (error.code === "password_hash_invalid") {
- return { kind: "passwordHashInvalid", diagnosticCode: "password_hash_invalid" };
+ return withRayId({ kind: "passwordHashInvalid", diagnosticCode: "password_hash_invalid" }, error);
}
if (error.code === "login_rate_limited" || error.status === 429) {
- return { kind: "loginRateLimited", diagnosticCode: "login_rate_limited" };
+ return withRayId({ kind: "loginRateLimited", diagnosticCode: "login_rate_limited" }, error);
}
if (error.code === "unauthorized" || error.status === 401) {
- return phase === "login"
+ return withRayId(phase === "login"
? { kind: "invalidCredentials", diagnosticCode: "invalid_credentials" }
- : { kind: "sessionExpired", diagnosticCode: "session_expired" };
+ : { kind: "sessionExpired", diagnosticCode: "session_expired" }, error);
}
if (error.status === 404) {
- return { kind: "instanceApiNotFound", diagnosticCode: "edgeever_api_not_found" };
+ return withRayId({ kind: "instanceApiNotFound", diagnosticCode: "edgeever_api_not_found" }, error);
+ }
+ if (error.status === 403) {
+ return withRayId(
+ error.responseDiagnostics?.isEdgeEverApiError
+ ? { kind: "requestForbidden", diagnosticCode: error.code || "http_403" }
+ : { kind: "securityPolicyBlocked", diagnosticCode: "security_policy_blocked" },
+ error,
+ );
}
if (error.status >= 500) {
- return { kind: "instanceServerError", diagnosticCode: `http_${error.status}`, status: error.status };
+ return withRayId({ kind: "instanceServerError", diagnosticCode: `http_${error.status}`, status: error.status }, error);
}
- return {
+ return withRayId({
kind: "requestRejected",
diagnosticCode: error.code || `http_${error.status}`,
status: error.status,
- };
+ }, error);
}
if (error instanceof TypeError) {
diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts
index 8baf6878..611393c7 100644
--- a/packages/shared/src/i18n/en-US.ts
+++ b/packages/shared/src/i18n/en-US.ts
@@ -56,11 +56,15 @@ export const enUS = {
invalidCredentials: "The username or password is incorrect. Check both fields and try again.",
sessionExpired: "Your login has expired. Sign in again.",
loginRateLimited: "Too many attempts. Try again later.",
+ cloudflareChallenge: "A Cloudflare browser challenge intercepted the login API and cannot run in this page. Ask the instance administrator to adjust the WAF Challenge rule for login.",
+ securityPolicyBlocked: "A security policy blocked the request before it reached EdgeEver. Ask the instance administrator to check Cloudflare Security Events, Access, WAF, or reverse-proxy rules.",
+ requestForbidden: "The instance rejected the login request (HTTP 403). Ask the instance administrator to check its access policy.",
instanceServerError: "The instance is temporarily unavailable. Try again later.",
- requestRejected: "Sign-in failed. Check the form and try again.",
+ requestRejected: "The instance rejected the login request (HTTP {{status}}). Contact the instance administrator.",
invalidInstanceResponse: "This is not a valid EdgeEver instance. Check the instance address.",
unexpectedError: "Sign-in failed. Try again.",
diagnosticCode: "Error code: {{code}}",
+ cloudflareRayId: "CF-Ray: {{id}}",
desktopScopeUnavailable: "The desktop local data scope could not be switched. The workspace is paused to protect account data.",
desktopScopeRetry: "Retry",
passwordHashInvalid: "This account is not configured correctly. Ask the administrator to reset its password.",
diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts
index 49d429c0..661105d3 100644
--- a/packages/shared/src/i18n/zh-CN.ts
+++ b/packages/shared/src/i18n/zh-CN.ts
@@ -56,11 +56,15 @@ export const zhCN = {
invalidCredentials: "账号或密码错误,请检查后重试。",
sessionExpired: "登录已过期,请重新登录。",
loginRateLimited: "尝试次数太多,请稍后再试。",
+ cloudflareChallenge: "Cloudflare 人机验证拦截了登录接口,当前页面无法完成该验证。请实例管理员调整针对登录接口的 WAF Challenge 规则。",
+ securityPolicyBlocked: "登录请求在到达 EdgeEver 前被安全策略拦截。请实例管理员检查 Cloudflare Security Events、Access、WAF 或反向代理规则。",
+ requestForbidden: "实例拒绝了登录请求(HTTP 403)。请实例管理员检查访问策略。",
instanceServerError: "实例暂时出错,请稍后再试。",
- requestRejected: "登录请求未成功,请检查填写内容后重试。",
+ requestRejected: "实例拒绝了登录请求(HTTP {{status}})。请联系实例管理员。",
invalidInstanceResponse: "这个地址不是有效的 EdgeEver 实例,请检查实例地址。",
unexpectedError: "登录失败,请重试。",
diagnosticCode: "错误代码:{{code}}",
+ cloudflareRayId: "CF-Ray:{{id}}",
desktopScopeUnavailable: "桌面本地数据空间暂时无法切换。为保护账户数据,工作区已暂停加载。",
desktopScopeRetry: "重试",
passwordHashInvalid: "账号配置异常,请联系管理员重置密码。",