fix(core): handle unsupported server error (#15164)

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)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-06-29 00:03:02 +08:00
committed by GitHub
parent 0a422aa158
commit 1b9e21f2de
13 changed files with 140 additions and 7 deletions
@@ -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: {
+2 -1
View File
@@ -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;
@@ -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);
@@ -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) => (
<div>
<p
@@ -78,7 +78,11 @@ export const AddSelfhostedStep = ({
console.error(err);
const userFriendlyError = UserFriendlyError.fromAny(err);
setError(true);
if (userFriendlyError.is('TOO_MANY_REQUEST')) {
if (userFriendlyError.is('UNSUPPORTED_SERVER_VERSION')) {
setErrorHint(
t[`error.${userFriendlyError.name}`](userFriendlyError.data)
);
} else if (userFriendlyError.is('TOO_MANY_REQUEST')) {
setErrorHint(t['error.TOO_MANY_REQUEST']());
} else if (
userFriendlyError.is('NETWORK_ERROR') ||
@@ -146,6 +146,7 @@ export function configureCloudModule(framework: Framework) {
UrlService,
GlobalDialogService,
NbstoreService,
ServerService,
])
.store(AuthStore, [
FetchService,
@@ -1,5 +1,5 @@
import { UserFriendlyError } from '@affine/error';
import type { OAuthProviderType } from '@affine/graphql';
import { type OAuthProviderType, ServerDeploymentType } from '@affine/graphql';
import { track } from '@affine/track';
import { OnEvent, Service } from '@toeverything/infra';
import { nanoid } from 'nanoid';
@@ -15,7 +15,9 @@ import { AccountLoggedIn } from '../events/account-logged-in';
import { AccountLoggedOut } from '../events/account-logged-out';
import { ServerStarted } from '../events/server-started';
import type { AuthStore } from '../stores/auth';
import { assertSupportedServerVersion } from '../stores/server-config';
import type { FetchService } from './fetch';
import type { ServerService } from './server';
@OnEvent(ApplicationFocused, e => 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<Record<string, string>> {
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);
}
}
@@ -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',
});
}
});
});
@@ -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)
) {
@@ -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,
+6
View File
@@ -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.`
*/
@@ -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.",
@@ -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": "你不能提及你自己。",