Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(server): client version check #9205

Merged
merged 1 commit into from
Feb 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/backend/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"react-dom": "19.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"semver": "^7.6.3",
"ses": "^1.10.0",
"socket.io": "^4.8.1",
"stripe": "^17.4.0",
Expand Down Expand Up @@ -119,6 +120,7 @@
"@types/on-headers": "^1.0.3",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"@types/semver": "^7.5.8",
"@types/sinon": "^17.0.3",
"@types/supertest": "^6.0.2",
"ava": "^6.2.0",
Expand Down
148 changes: 148 additions & 0 deletions packages/backend/server/src/__tests__/version.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { Controller, Get } from '@nestjs/common';
import test from 'ava';
import Sinon from 'sinon';

import { AppModule } from '../app.module';
import { Runtime, UseNamedGuard } from '../base';
import { Public } from '../core/auth/guard';
import { VersionService } from '../core/version/service';
import { createTestingApp, TestingApp } from './utils';

@Public()
@Controller('/guarded')
class GuardedController {
@UseNamedGuard('version')
@Get('/test')
test() {
return 'test';
}
}

let app: TestingApp;
let runtime: Sinon.SinonStubbedInstance<Runtime>;
let version: VersionService;

function checkVersion(enabled = true) {
runtime.fetch.withArgs('client/versionControl.enabled').resolves(enabled);

runtime.fetch
.withArgs('client/versionControl.requiredVersion')
.resolves('>=0.20.0');
}

test.before(async () => {
app = await createTestingApp({
imports: [AppModule],
controllers: [GuardedController],
tapModule: m => {
m.overrideProvider(Runtime).useValue(Sinon.createStubInstance(Runtime));
},
});

runtime = app.get(Runtime);
version = app.get(VersionService, { strict: false });
});

test.beforeEach(async () => {
Sinon.reset();

checkVersion(true);
});

test.after.always(async () => {
await app.close();
});

test('should passthrough if version check is not enabled', async t => {
checkVersion(false);

const spy = Sinon.spy(version, 'checkVersion');

let res = await app.GET('/guarded/test');

t.is(res.status, 200);

res = await app.GET('/guarded/test').set('x-affine-version', '0.20.0');

t.is(res.status, 200);

res = await app.GET('/guarded/test').set('x-affine-version', 'invalid');

t.is(res.status, 200);
t.true(spy.notCalled);
spy.restore();
});

test('should passthrough is version range is invalid', async t => {
runtime.fetch
.withArgs('client/versionControl.requiredVersion')
.resolves('invalid');

let res = await app.GET('/guarded/test').set('x-affine-version', 'invalid');

t.is(res.status, 200);
});

test('should pass if client version is allowed', async t => {
let res = await app.GET('/guarded/test').set('x-affine-version', '0.20.0');

t.is(res.status, 200);

res = await app.GET('/guarded/test').set('x-affine-version', '0.21.0');

t.is(res.status, 200);

runtime.fetch
.withArgs('client/versionControl.requiredVersion')
.resolves('>=0.19.0');

res = await app.GET('/guarded/test').set('x-affine-version', '0.19.0');

t.is(res.status, 200);
});

test('should fail if client version is not set or invalid', async t => {
let res = await app.GET('/guarded/test');

t.is(res.status, 403);
t.is(
res.body.message,
'Unsupported client with version [unset_or_invalid], required version is [>=0.20.0].'
);

res = await app.GET('/guarded/test').set('x-affine-version', 'invalid');

t.is(res.status, 403);
t.is(
res.body.message,
'Unsupported client with version [invalid], required version is [>=0.20.0].'
);
});

test('should tell upgrade if client version is lower than allowed', async t => {
runtime.fetch
.withArgs('client/versionControl.requiredVersion')
.resolves('>=0.21.0 <=0.22.0');

let res = await app.GET('/guarded/test').set('x-affine-version', '0.20.0');

t.is(res.status, 403);
t.is(
res.body.message,
'Unsupported client with version [0.20.0], required version is [>=0.21.0 <=0.22.0].'
);
});

test('should tell downgrade if client version is higher than allowed', async t => {
runtime.fetch
.withArgs('client/versionControl.requiredVersion')
.resolves('>=0.20.0 <=0.22.0');

let res = await app.GET('/guarded/test').set('x-affine-version', '0.23.0');

t.is(res.status, 403);
t.is(
res.body.message,
'Unsupported client with version [0.23.0], required version is [>=0.20.0 <=0.22.0].'
);
});
2 changes: 2 additions & 0 deletions packages/backend/server/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { SelfhostModule } from './core/selfhost';
import { StorageModule } from './core/storage';
import { SyncModule } from './core/sync';
import { UserModule } from './core/user';
import { VersionModule } from './core/version';
import { WorkspaceModule } from './core/workspaces';
import { ModelsModule } from './models';
import { REGISTERED_PLUGINS } from './plugins';
Expand Down Expand Up @@ -225,6 +226,7 @@ export function buildAppModule() {
// graphql server only
.useIf(
config => config.flavor.graphql,
VersionModule,
GqlModule,
StorageModule,
ServerConfigModule,
Expand Down
11 changes: 11 additions & 0 deletions packages/backend/server/src/base/error/def.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,4 +724,15 @@ export const USER_FRIENDLY_ERRORS = {
message: ({ limit }) =>
`You cannot downgrade the workspace from team workspace because there are more than ${limit} members that are currently active.`,
},

// version errors
unsupported_client_version: {
type: 'action_forbidden',
args: {
clientVersion: 'string',
requiredVersion: 'string',
},
message: ({ clientVersion, requiredVersion }) =>
`Unsupported client with version [${clientVersion}], required version is [${requiredVersion}].`,
},
} satisfies Record<string, UserFriendlyErrorOptions>;
16 changes: 14 additions & 2 deletions packages/backend/server/src/base/error/errors.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,17 @@ export class WorkspaceMembersExceedLimitToDowngrade extends UserFriendlyError {
super('bad_request', 'workspace_members_exceed_limit_to_downgrade', message, args);
}
}
@ObjectType()
class UnsupportedClientVersionDataType {
@Field() clientVersion!: string
@Field() requiredVersion!: string
}

export class UnsupportedClientVersion extends UserFriendlyError {
constructor(args: UnsupportedClientVersionDataType, message?: string | ((args: UnsupportedClientVersionDataType) => string)) {
super('action_forbidden', 'unsupported_client_version', message, args);
}
}
export enum ErrorNames {
INTERNAL_SERVER_ERROR,
TOO_MANY_REQUEST,
Expand Down Expand Up @@ -895,7 +906,8 @@ export enum ErrorNames {
LICENSE_NOT_FOUND,
INVALID_LICENSE_TO_ACTIVATE,
INVALID_LICENSE_UPDATE_PARAMS,
WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE
WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE,
UNSUPPORTED_CLIENT_VERSION
}
registerEnumType(ErrorNames, {
name: 'ErrorNames'
Expand All @@ -904,5 +916,5 @@ registerEnumType(ErrorNames, {
export const ErrorDataUnionType = createUnionType({
name: 'ErrorDataUnion',
types: () =>
[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,
[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,
});
15 changes: 10 additions & 5 deletions packages/backend/server/src/base/guard/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,19 @@ export class BasicGuard implements CanActivate {

async canActivate(context: ExecutionContext) {
// get registered guard name
const providerName = this.reflector.get<string>(
const providerName = this.reflector.get<string[]>(
BasicGuardSymbol,
context.getHandler()
);

const provider = GUARD_PROVIDER[providerName as NamedGuards];
if (provider) {
return await provider.canActivate(context);
if (Array.isArray(providerName) && providerName.length > 0) {
for (const name of providerName) {
const provider = GUARD_PROVIDER[name as NamedGuards];
if (provider) {
const ret = await provider.canActivate(context);
if (!ret) return false;
}
}
}

return true;
Expand All @@ -46,5 +51,5 @@ export class BasicGuard implements CanActivate {
* }
* ```
*/
export const UseNamedGuard = (name: NamedGuards) =>
export const UseNamedGuard = (...name: NamedGuards[]) =>
applyDecorators(UseGuards(BasicGuard), SetMetadata(BasicGuardSymbol, name));
4 changes: 3 additions & 1 deletion packages/backend/server/src/core/auth/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export class AuthController {
}

@Public()
@UseNamedGuard('version')
@Post('/preflight')
async preflight(
@Body() params?: { email: string }
Expand Down Expand Up @@ -108,7 +109,7 @@ export class AuthController {
}

@Public()
@UseNamedGuard('captcha')
@UseNamedGuard('version', 'captcha')
@Post('/sign-in')
@Header('content-type', 'application/json')
async signIn(
Expand Down Expand Up @@ -260,6 +261,7 @@ export class AuthController {
}

@Public()
@UseNamedGuard('version')
@Post('/magic-link')
async magicLinkSignIn(
@Req() req: Request,
Expand Down
31 changes: 31 additions & 0 deletions packages/backend/server/src/core/version/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { defineRuntimeConfig, ModuleConfig } from '../../base/config';

export interface VersionConfig {
versionControl: {
enabled: boolean;
requiredVersion: string;
};
}

declare module '../../base/config' {
interface AppConfig {
client: ModuleConfig<never, VersionConfig>;
}
}

declare module '../../base/guard' {
interface RegisterGuardName {
version: 'version';
}
}

defineRuntimeConfig('client', {
'versionControl.enabled': {
desc: 'Whether check version of client before accessing the server.',
default: false,
},
'versionControl.requiredVersion': {
desc: "Allowed version range of the app that allowed to access the server. Requires 'client/versionControl.enabled' to be true to take effect.",
default: '>=0.20.0',
},
});
40 changes: 40 additions & 0 deletions packages/backend/server/src/core/version/guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type {
CanActivate,
ExecutionContext,
OnModuleInit,
} from '@nestjs/common';
import { Injectable } from '@nestjs/common';

import {
getRequestResponseFromContext,
GuardProvider,
Runtime,
} from '../../base';
import { VersionService } from './service';

@Injectable()
export class VersionGuardProvider
extends GuardProvider
implements CanActivate, OnModuleInit
{
name = 'version' as const;

constructor(
private readonly runtime: Runtime,
private readonly version: VersionService
) {
super();
}

async canActivate(context: ExecutionContext) {
if (!(await this.runtime.fetch('client/versionControl.enabled'))) {
return true;
}

const { req } = getRequestResponseFromContext(context);

const version = req.headers['x-affine-version'] as string | undefined;

return this.version.checkVersion(version);
}
}
13 changes: 13 additions & 0 deletions packages/backend/server/src/core/version/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import './config';

import { Module } from '@nestjs/common';

import { VersionGuardProvider } from './guard';
import { VersionService } from './service';

@Module({
providers: [VersionService, VersionGuardProvider],
})
export class VersionModule {}

export type { VersionConfig } from './config';
Loading
Loading