From 1b9e21f2debcabc45f843c3c3645b2b6631765a5 Mon Sep 17 00:00:00 2001
From: DarkSky <25152247+darkskygit@users.noreply.github.com>
Date: Mon, 29 Jun 2026 00:03:02 +0800
Subject: [PATCH] fix(core): handle unsupported server error (#15164)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
fix #15160
fix #15161
fix #15158
fix #15166
#### PR Dependency Tree
* **PR #15164** 👈
This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
## Summary by CodeRabbit
* **New Features**
* Added a “server version too old” message for self-hosted servers,
including the required upgrade version.
* Sign-in and OAuth-related preflight steps now verify server
compatibility before proceeding.
* **Bug Fixes**
* Improved error handling for missing/invalid server version responses
and schema/type mismatches, mapping them to the upgrade instruction.
* **Tests**
* Added coverage for server version guarding and the resulting
user-friendly error payload.
---
packages/backend/server/src/base/error/def.ts | 8 ++++
packages/common/error/src/index.ts | 3 +-
.../src/__tests__/auth-client-nonce.spec.ts | 13 ++++++
.../use-selfhost-login-version-guard.tsx | 3 +-
.../src/components/sign-in/add-selfhosted.tsx | 6 ++-
.../frontend/core/src/modules/cloud/index.ts | 1 +
.../core/src/modules/cloud/services/auth.ts | 16 ++++++-
.../cloud/stores/server-config.spec.ts | 43 +++++++++++++++++++
.../src/modules/cloud/stores/server-config.ts | 42 ++++++++++++++++++
.../i18n/src/i18n-completenesses.json | 4 +-
packages/frontend/i18n/src/i18n.gen.ts | 6 +++
packages/frontend/i18n/src/resources/en.json | 1 +
.../frontend/i18n/src/resources/zh-Hans.json | 1 +
13 files changed, 140 insertions(+), 7 deletions(-)
create mode 100644 packages/frontend/core/src/modules/cloud/stores/server-config.spec.ts
diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts
index be7682d564..27b9b77619 100644
--- a/packages/backend/server/src/base/error/def.ts
+++ b/packages/backend/server/src/base/error/def.ts
@@ -907,6 +907,14 @@ export const USER_FRIENDLY_ERRORS = {
message: ({ clientVersion, requiredVersion }) =>
`Unsupported client with version [${clientVersion}], required version is [${requiredVersion}].`,
},
+ unsupported_server_version: {
+ type: 'action_forbidden',
+ args: {
+ requiredVersion: 'string',
+ },
+ message: ({ requiredVersion }) =>
+ `This AFFiNE server is too old for this client. Please upgrade the server to ${requiredVersion}.`,
+ },
// Notification Errors
notification_not_found: {
diff --git a/packages/common/error/src/index.ts b/packages/common/error/src/index.ts
index d24af347f2..044ac7fcae 100644
--- a/packages/common/error/src/index.ts
+++ b/packages/common/error/src/index.ts
@@ -5,7 +5,8 @@ export type ErrorName =
| keyof typeof ErrorNames
| 'NETWORK_ERROR'
| 'CONTENT_TOO_LARGE'
- | 'REQUEST_ABORTED';
+ | 'REQUEST_ABORTED'
+ | 'UNSUPPORTED_SERVER_VERSION';
export interface UserFriendlyErrorResponse {
status: number;
diff --git a/packages/frontend/core/src/__tests__/auth-client-nonce.spec.ts b/packages/frontend/core/src/__tests__/auth-client-nonce.spec.ts
index 634069893e..1054659c42 100644
--- a/packages/frontend/core/src/__tests__/auth-client-nonce.spec.ts
+++ b/packages/frontend/core/src/__tests__/auth-client-nonce.spec.ts
@@ -1,10 +1,12 @@
import { AuthSession } from '@affine/core/modules/cloud/entities/session';
import { AuthService } from '@affine/core/modules/cloud/services/auth';
import { FetchService } from '@affine/core/modules/cloud/services/fetch';
+import { ServerService } from '@affine/core/modules/cloud/services/server';
import { AuthStore } from '@affine/core/modules/cloud/stores/auth';
import { GlobalDialogService } from '@affine/core/modules/dialogs/services/dialog';
import { NbstoreService } from '@affine/core/modules/storage';
import { UrlService } from '@affine/core/modules/url/services/url';
+import { ServerDeploymentType } from '@affine/graphql';
import { Framework } from '@toeverything/infra';
import { of } from 'rxjs';
import { describe, expect, test, vi } from 'vitest';
@@ -41,12 +43,23 @@ describe('AuthService oauthPreflight', () => {
framework.service(NbstoreService, {
realtime: { subscribe: () => of() },
} as any);
+ framework.service(ServerService, {
+ server: {
+ ['config$']: {
+ value: {
+ type: ServerDeploymentType.Selfhosted,
+ version: '0.27.0',
+ },
+ },
+ },
+ } as any);
framework.service(AuthService, [
FetchService,
AuthStore,
UrlService,
GlobalDialogService,
NbstoreService,
+ ServerService,
]);
const auth = framework.provider().get(AuthService);
diff --git a/packages/frontend/core/src/components/hooks/affine/use-selfhost-login-version-guard.tsx b/packages/frontend/core/src/components/hooks/affine/use-selfhost-login-version-guard.tsx
index 03deadf703..ec4daaa872 100644
--- a/packages/frontend/core/src/components/hooks/affine/use-selfhost-login-version-guard.tsx
+++ b/packages/frontend/core/src/components/hooks/affine/use-selfhost-login-version-guard.tsx
@@ -1,11 +1,12 @@
import type { Server } from '@affine/core/modules/cloud';
+import { MIN_SUPPORTED_SERVER_VERSION } from '@affine/core/modules/cloud/stores/server-config';
import { useLiveData } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import semver from 'semver';
const rules = [
{
- min: '0.23.0',
+ min: MIN_SUPPORTED_SERVER_VERSION,
tip: (receivedVersion: string, requiredVersion: string) => (
e.onApplicationFocused)
@OnEvent(ServerStarted, e => e.onServerStarted)
@@ -28,7 +30,8 @@ export class AuthService extends Service {
private readonly store: AuthStore,
private readonly urlService: UrlService,
private readonly dialogService: GlobalDialogService,
- private readonly nbstoreService: NbstoreService
+ private readonly nbstoreService: NbstoreService,
+ private readonly serverService: ServerService
) {
super();
@@ -97,6 +100,7 @@ export class AuthService extends Service {
challenge?: string,
redirectUrl?: string // url to redirect to after signed-in
) {
+ this.assertSupportedServerVersion();
track.$.$.auth.signIn({ method: 'magic-link' });
// Only native clients use `client_nonce` for magic-link/otp sign-in.
// Web needs to keep cross-device magic-link compatibility.
@@ -156,6 +160,7 @@ export class AuthService extends Service {
client: string,
/** @deprecated*/ redirectUrl?: string
): Promise> {
+ this.assertSupportedServerVersion();
// OAuth callback requires `client_nonce` for all clients (including web).
const clientNonce = this.setClientNonce();
try {
@@ -233,6 +238,7 @@ export class AuthService extends Service {
verifyToken?: string;
challenge?: string;
}) {
+ this.assertSupportedServerVersion();
track.$.$.auth.signIn({ method: 'password' });
try {
const user = await this.store.signInPassword(credential);
@@ -285,4 +291,10 @@ export class AuthService extends Service {
this.store.setClientNonce(nonce);
return nonce;
}
+
+ private assertSupportedServerVersion() {
+ const config = this.serverService.server.config$.value;
+ if (config.type !== ServerDeploymentType.Selfhosted) return;
+ assertSupportedServerVersion(config.version);
+ }
}
diff --git a/packages/frontend/core/src/modules/cloud/stores/server-config.spec.ts b/packages/frontend/core/src/modules/cloud/stores/server-config.spec.ts
new file mode 100644
index 0000000000..0aa67afb86
--- /dev/null
+++ b/packages/frontend/core/src/modules/cloud/stores/server-config.spec.ts
@@ -0,0 +1,43 @@
+import { UserFriendlyError } from '@affine/error';
+import { describe, expect, test } from 'vitest';
+
+import {
+ assertSupportedServerVersion,
+ MIN_SUPPORTED_SERVER_VERSION,
+} from './server-config';
+
+describe('server config version guard', () => {
+ test('accepts supported server versions', () => {
+ expect(() => assertSupportedServerVersion('0.27.0')).not.toThrow();
+ expect(() => assertSupportedServerVersion('0.28.0')).not.toThrow();
+ });
+
+ test('rejects old server versions', () => {
+ expect(() => assertSupportedServerVersion('0.26.9')).toThrow(
+ UserFriendlyError
+ );
+ });
+
+ test('rejects missing or invalid server versions', () => {
+ for (const version of [undefined, null, '', 'not-a-version']) {
+ expect(() => assertSupportedServerVersion(version)).toThrow(
+ UserFriendlyError
+ );
+ }
+ });
+
+ test('reports the required server version', () => {
+ expect.assertions(2);
+
+ try {
+ assertSupportedServerVersion('0.26.0');
+ } catch (error) {
+ const userFriendlyError = UserFriendlyError.fromAny(error);
+ expect(userFriendlyError.name).toBe('UNSUPPORTED_SERVER_VERSION');
+ expect(userFriendlyError.data).toMatchObject({
+ requiredVersion: `>=${MIN_SUPPORTED_SERVER_VERSION}`,
+ serverVersion: '0.26.0',
+ });
+ }
+ });
+});
diff --git a/packages/frontend/core/src/modules/cloud/stores/server-config.ts b/packages/frontend/core/src/modules/cloud/stores/server-config.ts
index 7e2a8533b1..d4085f6e04 100644
--- a/packages/frontend/core/src/modules/cloud/stores/server-config.ts
+++ b/packages/frontend/core/src/modules/cloud/stores/server-config.ts
@@ -8,10 +8,13 @@ import {
ServerFeature,
} from '@affine/graphql';
import { Store } from '@toeverything/infra';
+import semver from 'semver';
export type ServerConfigType = ServerConfigQuery['serverConfig'] &
OauthProvidersQuery['serverConfig'];
+export const MIN_SUPPORTED_SERVER_VERSION = '0.27.0';
+
const NETWORK_ERROR_PATTERNS = [
/failed to fetch/i,
/network request failed/i,
@@ -24,6 +27,40 @@ const NETWORK_ERROR_PATTERNS = [
/err_[a-z_]+/i,
];
+const MISSING_SERVER_VERSION_PATTERNS = [
+ /cannot query field ["']?version["']? on type ["']?serverconfigtype["']?/i,
+ /field ["']?version["']? is not defined by type ["']?serverconfigtype["']?/i,
+];
+
+export function createUnsupportedServerVersionError(version?: string | null) {
+ const receivedVersion = version || 'unknown';
+ return new UserFriendlyError({
+ status: 426,
+ code: 'UNSUPPORTED_SERVER_VERSION',
+ type: 'UNSUPPORTED_SERVER_VERSION',
+ name: 'UNSUPPORTED_SERVER_VERSION',
+ message: `Unsupported server with version [${receivedVersion}], required version is [>=${MIN_SUPPORTED_SERVER_VERSION}].`,
+ data: {
+ serverVersion: receivedVersion,
+ requiredVersion: `>=${MIN_SUPPORTED_SERVER_VERSION}`,
+ },
+ });
+}
+
+export function assertSupportedServerVersion(version?: string | null) {
+ if (!version) {
+ throw createUnsupportedServerVersionError(version);
+ }
+
+ const normalized = semver.valid(version, { loose: true });
+ if (
+ !normalized ||
+ semver.lt(normalized, MIN_SUPPORTED_SERVER_VERSION, { loose: true })
+ ) {
+ throw createUnsupportedServerVersionError(version);
+ }
+}
+
function mapServerConfigError(error: unknown) {
const userFriendlyError = UserFriendlyError.fromAny(error);
if (
@@ -36,6 +73,10 @@ function mapServerConfigError(error: unknown) {
if (error instanceof Error) {
const detail = `${error.name}: ${error.message}`;
+ if (MISSING_SERVER_VERSION_PATTERNS.some(pattern => pattern.test(detail))) {
+ return createUnsupportedServerVersionError();
+ }
+
if (NETWORK_ERROR_PATTERNS.some(pattern => pattern.test(detail))) {
return new UserFriendlyError({
status: 504,
@@ -74,6 +115,7 @@ export class ServerConfigStore extends Store {
},
},
});
+ assertSupportedServerVersion(serverConfigData.serverConfig.version);
if (
serverConfigData.serverConfig.features.includes(ServerFeature.OAuth)
) {
diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json
index 5fd462405b..2f8d498007 100644
--- a/packages/frontend/i18n/src/i18n-completenesses.json
+++ b/packages/frontend/i18n/src/i18n-completenesses.json
@@ -7,9 +7,9 @@
"en": 100,
"es-AR": 93,
"es-CL": 94,
- "es": 93,
+ "es": 92,
"fa": 92,
- "fr": 97,
+ "fr": 96,
"hi": 1,
"it": 94,
"ja": 92,
diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts
index b1e7056556..cd587934c3 100644
--- a/packages/frontend/i18n/src/i18n.gen.ts
+++ b/packages/frontend/i18n/src/i18n.gen.ts
@@ -9692,6 +9692,12 @@ export function useAFFiNEI18N(): {
clientVersion: string;
requiredVersion: string;
}>): string;
+ /**
+ * `This AFFiNE server is too old for this client. Please upgrade the server to {{requiredVersion}}.`
+ */
+ ["error.UNSUPPORTED_SERVER_VERSION"](options: {
+ readonly requiredVersion: string;
+ }): string;
/**
* `Notification not found.`
*/
diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json
index a7bb6ed134..1b1053b6c5 100644
--- a/packages/frontend/i18n/src/resources/en.json
+++ b/packages/frontend/i18n/src/resources/en.json
@@ -2393,6 +2393,7 @@
"error.INVALID_LICENSE_UPDATE_PARAMS": "Invalid license update params. {{reason}}",
"error.LICENSE_EXPIRED": "License has expired.",
"error.UNSUPPORTED_CLIENT_VERSION": "Unsupported client with version [{{clientVersion}}], required version is [{{requiredVersion}}].",
+ "error.UNSUPPORTED_SERVER_VERSION": "This AFFiNE server is too old for this client. Please upgrade the server to {{requiredVersion}}.",
"error.NOTIFICATION_NOT_FOUND": "Notification not found.",
"error.MENTION_USER_DOC_ACCESS_DENIED": "Mentioned user can not access doc {{docId}}.",
"error.MENTION_USER_ONESELF_DENIED": "You can not mention yourself.",
diff --git a/packages/frontend/i18n/src/resources/zh-Hans.json b/packages/frontend/i18n/src/resources/zh-Hans.json
index aaf84ea868..1bb75e6be2 100644
--- a/packages/frontend/i18n/src/resources/zh-Hans.json
+++ b/packages/frontend/i18n/src/resources/zh-Hans.json
@@ -2393,6 +2393,7 @@
"error.INVALID_LICENSE_UPDATE_PARAMS": "无效的许可证更新参数。{{reason}}",
"error.LICENSE_EXPIRED": "许可证已过期。",
"error.UNSUPPORTED_CLIENT_VERSION": "不支持的客户端版本 [{{clientVersion}}],所需版本为 [{{requiredVersion}}]。",
+ "error.UNSUPPORTED_SERVER_VERSION": "此 AFFiNE 服务器版本过旧,当前客户端需要连接 {{requiredVersion}} 的服务器。请升级服务器后重试。",
"error.NOTIFICATION_NOT_FOUND": "未找到通知。",
"error.MENTION_USER_DOC_ACCESS_DENIED": "提到的用户无法访问文档 {{docId}}。",
"error.MENTION_USER_ONESELF_DENIED": "你不能提及你自己。",