Skip to content

Commit a7269ef

Browse files
committed
feat(server): version check
1 parent d490e76 commit a7269ef

17 files changed

Lines changed: 369 additions & 10 deletions

File tree

packages/backend/server/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
"react-dom": "19.0.0",
9393
"reflect-metadata": "^0.2.2",
9494
"rxjs": "^7.8.1",
95+
"semver": "^7.6.3",
9596
"ses": "^1.10.0",
9697
"socket.io": "^4.8.1",
9798
"stripe": "^17.4.0",
@@ -119,6 +120,7 @@
119120
"@types/on-headers": "^1.0.3",
120121
"@types/react": "^19.0.1",
121122
"@types/react-dom": "^19.0.2",
123+
"@types/semver": "^7.5.8",
122124
"@types/sinon": "^17.0.3",
123125
"@types/supertest": "^6.0.2",
124126
"ava": "^6.2.0",
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { Controller, Get } from '@nestjs/common';
2+
import test from 'ava';
3+
import Sinon from 'sinon';
4+
5+
import { AppModule } from '../app.module';
6+
import { Runtime, UseNamedGuard } from '../base';
7+
import { Public } from '../core/auth/guard';
8+
import { VersionService } from '../core/version/service';
9+
import { createTestingApp, TestingApp } from './utils';
10+
11+
@Public()
12+
@Controller('/guarded')
13+
class GuardedController {
14+
@UseNamedGuard('version')
15+
@Get('/test')
16+
test() {
17+
return 'test';
18+
}
19+
}
20+
21+
let app: TestingApp;
22+
let runtime: Sinon.SinonStubbedInstance<Runtime>;
23+
let version: VersionService;
24+
25+
function checkVersion(enabled = true) {
26+
runtime.fetch.withArgs('client/versionControl.enabled').resolves(enabled);
27+
28+
runtime.fetch
29+
.withArgs('client/versionControl.requiredVersion')
30+
.resolves('>=0.20.0');
31+
}
32+
33+
test.before(async () => {
34+
app = await createTestingApp({
35+
imports: [AppModule],
36+
controllers: [GuardedController],
37+
tapModule: m => {
38+
m.overrideProvider(Runtime).useValue(Sinon.createStubInstance(Runtime));
39+
},
40+
});
41+
42+
runtime = app.get(Runtime);
43+
version = app.get(VersionService, { strict: false });
44+
});
45+
46+
test.beforeEach(async () => {
47+
Sinon.reset();
48+
49+
checkVersion(true);
50+
});
51+
52+
test.after.always(async () => {
53+
await app.close();
54+
});
55+
56+
test('should passthrough if version check is not enabled', async t => {
57+
checkVersion(false);
58+
59+
const spy = Sinon.spy(version, 'checkVersion');
60+
61+
let res = await app.GET('/guarded/test');
62+
63+
t.is(res.status, 200);
64+
65+
res = await app.GET('/guarded/test').set('x-affine-version', '0.20.0');
66+
67+
t.is(res.status, 200);
68+
69+
res = await app.GET('/guarded/test').set('x-affine-version', 'invalid');
70+
71+
t.is(res.status, 200);
72+
t.true(spy.notCalled);
73+
spy.restore();
74+
});
75+
76+
test('should passthrough is version range is invalid', async t => {
77+
runtime.fetch
78+
.withArgs('client/versionControl.requiredVersion')
79+
.resolves('invalid');
80+
81+
let res = await app.GET('/guarded/test').set('x-affine-version', 'invalid');
82+
83+
t.is(res.status, 200);
84+
});
85+
86+
test('should pass if client version is allowed', async t => {
87+
let res = await app.GET('/guarded/test').set('x-affine-version', '0.20.0');
88+
89+
t.is(res.status, 200);
90+
91+
res = await app.GET('/guarded/test').set('x-affine-version', '0.21.0');
92+
93+
t.is(res.status, 200);
94+
95+
runtime.fetch
96+
.withArgs('client/versionControl.requiredVersion')
97+
.resolves('>=0.19.0');
98+
99+
res = await app.GET('/guarded/test').set('x-affine-version', '0.19.0');
100+
101+
t.is(res.status, 200);
102+
});
103+
104+
test('should fail if client version is not set or invalid', async t => {
105+
let res = await app.GET('/guarded/test');
106+
107+
t.is(res.status, 403);
108+
t.is(
109+
res.body.message,
110+
'Unsupported client with version [unset_or_invalid], required version is [>=0.20.0].'
111+
);
112+
113+
res = await app.GET('/guarded/test').set('x-affine-version', 'invalid');
114+
115+
t.is(res.status, 403);
116+
t.is(
117+
res.body.message,
118+
'Unsupported client with version [invalid], required version is [>=0.20.0].'
119+
);
120+
});
121+
122+
test('should tell upgrade if client version is lower than allowed', async t => {
123+
runtime.fetch
124+
.withArgs('client/versionControl.requiredVersion')
125+
.resolves('>=0.21.0 <=0.22.0');
126+
127+
let res = await app.GET('/guarded/test').set('x-affine-version', '0.20.0');
128+
129+
t.is(res.status, 403);
130+
t.is(
131+
res.body.message,
132+
'Unsupported client with version [0.20.0], required version is [>=0.21.0 <=0.22.0].'
133+
);
134+
});
135+
136+
test('should tell downgrade if client version is higher than allowed', async t => {
137+
runtime.fetch
138+
.withArgs('client/versionControl.requiredVersion')
139+
.resolves('>=0.20.0 <=0.22.0');
140+
141+
let res = await app.GET('/guarded/test').set('x-affine-version', '0.23.0');
142+
143+
t.is(res.status, 403);
144+
t.is(
145+
res.body.message,
146+
'Unsupported client with version [0.23.0], required version is [>=0.20.0 <=0.22.0].'
147+
);
148+
});

packages/backend/server/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { SelfhostModule } from './core/selfhost';
4949
import { StorageModule } from './core/storage';
5050
import { SyncModule } from './core/sync';
5151
import { UserModule } from './core/user';
52+
import { VersionModule } from './core/version';
5253
import { WorkspaceModule } from './core/workspaces';
5354
import { ModelsModule } from './models';
5455
import { REGISTERED_PLUGINS } from './plugins';
@@ -225,6 +226,7 @@ export function buildAppModule() {
225226
// graphql server only
226227
.useIf(
227228
config => config.flavor.graphql,
229+
VersionModule,
228230
GqlModule,
229231
StorageModule,
230232
ServerConfigModule,

packages/backend/server/src/base/error/def.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,4 +724,15 @@ export const USER_FRIENDLY_ERRORS = {
724724
message: ({ limit }) =>
725725
`You cannot downgrade the workspace from team workspace because there are more than ${limit} members that are currently active.`,
726726
},
727+
728+
// version errors
729+
unsupported_client_version: {
730+
type: 'action_forbidden',
731+
args: {
732+
clientVersion: 'string',
733+
requiredVersion: 'string',
734+
},
735+
message: ({ clientVersion, requiredVersion }) =>
736+
`Unsupported client with version [${clientVersion}], required version is [${requiredVersion}].`,
737+
},
727738
} satisfies Record<string, UserFriendlyErrorOptions>;

packages/backend/server/src/base/error/errors.gen.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,17 @@ export class WorkspaceMembersExceedLimitToDowngrade extends UserFriendlyError {
794794
super('bad_request', 'workspace_members_exceed_limit_to_downgrade', message, args);
795795
}
796796
}
797+
@ObjectType()
798+
class UnsupportedClientVersionDataType {
799+
@Field() clientVersion!: string
800+
@Field() requiredVersion!: string
801+
}
802+
803+
export class UnsupportedClientVersion extends UserFriendlyError {
804+
constructor(args: UnsupportedClientVersionDataType, message?: string | ((args: UnsupportedClientVersionDataType) => string)) {
805+
super('action_forbidden', 'unsupported_client_version', message, args);
806+
}
807+
}
797808
export enum ErrorNames {
798809
INTERNAL_SERVER_ERROR,
799810
TOO_MANY_REQUEST,
@@ -895,7 +906,8 @@ export enum ErrorNames {
895906
LICENSE_NOT_FOUND,
896907
INVALID_LICENSE_TO_ACTIVATE,
897908
INVALID_LICENSE_UPDATE_PARAMS,
898-
WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE
909+
WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE,
910+
UNSUPPORTED_CLIENT_VERSION
899911
}
900912
registerEnumType(ErrorNames, {
901913
name: 'ErrorNames'
@@ -904,5 +916,5 @@ registerEnumType(ErrorNames, {
904916
export const ErrorDataUnionType = createUnionType({
905917
name: 'ErrorDataUnion',
906918
types: () =>
907-
[GraphqlBadRequestDataType, QueryTooLongDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseUpdateParamsDataType, WorkspaceMembersExceedLimitToDowngradeDataType] as const,
919+
[GraphqlBadRequestDataType, QueryTooLongDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseUpdateParamsDataType, WorkspaceMembersExceedLimitToDowngradeDataType, UnsupportedClientVersionDataType] as const,
908920
});

packages/backend/server/src/base/guard/guard.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,19 @@ export class BasicGuard implements CanActivate {
1818

1919
async canActivate(context: ExecutionContext) {
2020
// get registered guard name
21-
const providerName = this.reflector.get<string>(
21+
const providerName = this.reflector.get<string[]>(
2222
BasicGuardSymbol,
2323
context.getHandler()
2424
);
2525

26-
const provider = GUARD_PROVIDER[providerName as NamedGuards];
27-
if (provider) {
28-
return await provider.canActivate(context);
26+
if (Array.isArray(providerName) && providerName.length > 0) {
27+
for (const name of providerName) {
28+
const provider = GUARD_PROVIDER[name as NamedGuards];
29+
if (provider) {
30+
const ret = await provider.canActivate(context);
31+
if (!ret) return false;
32+
}
33+
}
2934
}
3035

3136
return true;
@@ -46,5 +51,5 @@ export class BasicGuard implements CanActivate {
4651
* }
4752
* ```
4853
*/
49-
export const UseNamedGuard = (name: NamedGuards) =>
54+
export const UseNamedGuard = (...name: NamedGuards[]) =>
5055
applyDecorators(UseGuards(BasicGuard), SetMetadata(BasicGuardSymbol, name));

packages/backend/server/src/core/auth/controller.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export class AuthController {
7979
}
8080

8181
@Public()
82+
@UseNamedGuard('version')
8283
@Post('/preflight')
8384
async preflight(
8485
@Body() params?: { email: string }
@@ -108,7 +109,7 @@ export class AuthController {
108109
}
109110

110111
@Public()
111-
@UseNamedGuard('captcha')
112+
@UseNamedGuard('version', 'captcha')
112113
@Post('/sign-in')
113114
@Header('content-type', 'application/json')
114115
async signIn(
@@ -260,6 +261,7 @@ export class AuthController {
260261
}
261262

262263
@Public()
264+
@UseNamedGuard('version')
263265
@Post('/magic-link')
264266
async magicLinkSignIn(
265267
@Req() req: Request,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { defineRuntimeConfig, ModuleConfig } from '../../base/config';
2+
3+
export interface VersionConfig {
4+
versionControl: {
5+
enabled: boolean;
6+
requiredVersion: string;
7+
};
8+
}
9+
10+
declare module '../../base/config' {
11+
interface AppConfig {
12+
client: ModuleConfig<never, VersionConfig>;
13+
}
14+
}
15+
16+
declare module '../../base/guard' {
17+
interface RegisterGuardName {
18+
version: 'version';
19+
}
20+
}
21+
22+
defineRuntimeConfig('client', {
23+
'versionControl.enabled': {
24+
desc: 'Whether check version of client before accessing the server.',
25+
default: false,
26+
},
27+
'versionControl.requiredVersion': {
28+
desc: "Allowed version range of the app that allowed to access the server. Requires 'client/versionControl.enabled' to be true to take effect.",
29+
default: '>=0.20.0',
30+
},
31+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type {
2+
CanActivate,
3+
ExecutionContext,
4+
OnModuleInit,
5+
} from '@nestjs/common';
6+
import { Injectable } from '@nestjs/common';
7+
8+
import {
9+
getRequestResponseFromContext,
10+
GuardProvider,
11+
Runtime,
12+
} from '../../base';
13+
import { VersionService } from './service';
14+
15+
@Injectable()
16+
export class VersionGuardProvider
17+
extends GuardProvider
18+
implements CanActivate, OnModuleInit
19+
{
20+
name = 'version' as const;
21+
22+
constructor(
23+
private readonly runtime: Runtime,
24+
private readonly version: VersionService
25+
) {
26+
super();
27+
}
28+
29+
async canActivate(context: ExecutionContext) {
30+
if (!(await this.runtime.fetch('client/versionControl.enabled'))) {
31+
return true;
32+
}
33+
34+
const { req } = getRequestResponseFromContext(context);
35+
36+
const version = req.headers['x-affine-version'] as string | undefined;
37+
38+
return this.version.checkVersion(version);
39+
}
40+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import './config';
2+
3+
import { Module } from '@nestjs/common';
4+
5+
import { VersionGuardProvider } from './guard';
6+
import { VersionService } from './service';
7+
8+
@Module({
9+
providers: [VersionService, VersionGuardProvider],
10+
})
11+
export class VersionModule {}
12+
13+
export type { VersionConfig } from './config';

0 commit comments

Comments
 (0)