Sergey Krylov 5b9d7f3a27
All checks were successful
CI / ci (pull_request) Successful in 3m20s
CI / ci (push) Successful in 3m1s
docs: fix historical files to new structure and update
2026-06-18 22:04:09 +03:00

35 KiB
Raw Blame History

Auth System Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add full authentication (register/login/logout/refresh) and role-based authorization (user/admin) to MoexVibe.

Architecture: Backend — NestJS + Prisma (SQLite) for user storage, JWT access tokens (15m) + httpOnly refresh cookies (7d) with rotation. Frontend — React context for auth state, TanStack Query for API, auto-refresh on 401 with retry.

Tech Stack: @prisma/client, @nestjs/jwt, bcrypt, cookie-parser (backend); React Context + fetch (frontend)


Task 1: Backend dependencies + Prisma init

Files:

  • Modify: apps/backend/package.json

  • Create: apps/backend/prisma/schema.prisma

  • Create: apps/backend/.gitignore (or modify root)

  • Create: apps/backend/src/modules/prisma/prisma.service.ts

  • Create: apps/backend/src/modules/prisma/prisma.module.ts

  • Step 1: Install dependencies

npm install @prisma/client @nestjs/jwt bcrypt cookie-parser -w apps/backend
npm install -D prisma @types/bcrypt @types/cookie-parser -w apps/backend
  • Step 2: Init Prisma with SQLite
npx prisma init --datasource-provider sqlite

Update the generated apps/backend/prisma/schema.prisma:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model User {
  id           Int      @id @default(autoincrement())
  email        String   @unique
  password     String
  name         String?
  role         String   @default("user")
  refreshToken String?
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt
}
  • Step 3: Add DATABASE_URL to .env
DATABASE_URL="file:./dev.db"

Add .env to backend .gitignore:

node_modules/
dist/
.env
*.db
*.db-journal
  • Step 4: PrismaService
// apps/backend/src/modules/prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}
  • Step 5: PrismaModule
// apps/backend/src/modules/prisma/prisma.module.ts
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}
  • Step 6: Run migration
npx prisma migrate dev --name init
  • Step 7: Commit
git add apps/backend/prisma/ apps/backend/src/modules/prisma/ apps/backend/package.json apps/backend/.env apps/backend/.gitignore
git commit -m "feat: add Prisma ORM with SQLite and User model"

Task 2: AuthService — register, login, refresh, logout (TDD)

Files:

  • Create: apps/backend/src/modules/auth/interfaces/jwt-payload.interface.ts

  • Create: apps/backend/src/modules/auth/auth.service.spec.ts

  • Create: apps/backend/src/modules/auth/auth.service.ts

  • Create: apps/backend/src/modules/auth/dto/register.dto.ts

  • Create: apps/backend/src/modules/auth/dto/login.dto.ts

  • Create: apps/backend/src/modules/auth/dto/update-profile.dto.ts

  • Step 1: Write JwtPayload interface

// apps/backend/src/modules/auth/interfaces/jwt-payload.interface.ts
export interface JwtPayload {
  sub: number;
  email: string;
  role: string;
}
  • Step 2: Write DTOs
// apps/backend/src/modules/auth/dto/register.dto.ts
import { IsEmail, IsString, MinLength, MaxLength, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class RegisterDto {
  @ApiProperty({ example: 'user@example.com' })
  @IsEmail()
  email: string;

  @ApiProperty({ example: 'securePass123' })
  @IsString()
  @MinLength(6)
  @MaxLength(100)
  password: string;

  @ApiPropertyOptional({ example: 'John' })
  @IsString()
  @IsOptional()
  @MinLength(1)
  @MaxLength(100)
  name?: string;
}
// apps/backend/src/modules/auth/dto/login.dto.ts
import { IsEmail, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class LoginDto {
  @ApiProperty({ example: 'user@example.com' })
  @IsEmail()
  email: string;

  @ApiProperty({ example: 'securePass123' })
  @IsString()
  password: string;
}
// apps/backend/src/modules/auth/dto/update-profile.dto.ts
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';

export class UpdateProfileDto {
  @ApiPropertyOptional({ example: 'John Doe' })
  @IsString()
  @IsOptional()
  @MinLength(1)
  @MaxLength(100)
  name?: string;
}
  • Step 3: Write failing auth service tests
// apps/backend/src/modules/auth/auth.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule, JwtService } from '@nestjs/jwt';
import { AuthService } from './auth.service';
import { PrismaService } from '../prisma/prisma.service';
import * as bcrypt from 'bcrypt';
import { ConflictException, UnauthorizedException } from '@nestjs/common';

describe('AuthService', () => {
  let service: AuthService;
  let prisma: PrismaService;
  let jwtService: JwtService;

  const testUser = {
    email: 'test@example.com',
    password: 'testPass123',
    name: 'Test User',
  };

  beforeAll(async () => {
    const module: TestingModule = await Test.createTestingModule({
      imports: [
        JwtModule.register({
          secret: 'test-secret',
          signOptions: { expiresIn: '15m' },
        }),
      ],
      providers: [
        AuthService,
        {
          provide: PrismaService,
          useValue: {
            user: {
              findUnique: vi.fn(),
              create: vi.fn(),
              update: vi.fn(),
            },
          },
        },
        {
          provide: ConfigService,
          useValue: {
            get: vi.fn((key: string) => {
              const config: Record<string, string> = {
                'auth.jwtSecret': 'test-secret',
                'auth.jwtAccessExpires': '15m',
                'auth.jwtRefreshExpires': '7d',
              };
              return config[key];
            }),
          },
        },
      ],
    }).compile();

    service = module.get<AuthService>(AuthService);
    prisma = module.get<PrismaService>(PrismaService);
    jwtService = module.get<JwtService>(JwtService);
  });

  afterEach(() => {
    vi.clearAllMocks();
  });

  describe('register', () => {
    it('should register a new user and return tokens', async () => {
      const hashedPassword = await bcrypt.hash(testUser.password, 12);
      const mockUser = {
        id: 1,
        email: testUser.email,
        password: hashedPassword,
        name: testUser.name,
        role: 'user',
        refreshToken: null,
        createdAt: new Date(),
        updatedAt: new Date(),
      };

      vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
      vi.mocked(prisma.user.create).mockResolvedValue(mockUser);

      const result = await service.register(testUser);

      expect(result.user.id).toBe(1);
      expect(result.user.email).toBe(testUser.email);
      expect(result.user.name).toBe(testUser.name);
      expect(result.user.role).toBe('user');
      expect(result.accessToken).toBeDefined();
      expect(result.refreshToken).toBeDefined();
      expect(prisma.user.findUnique).toHaveBeenCalledWith({
        where: { email: testUser.email },
      });
      expect(prisma.user.create).toHaveBeenCalledWith({
        data: {
          email: testUser.email,
          password: expect.any(String),
          name: testUser.name,
        },
      });
    });

    it('should throw ConflictException if email already exists', async () => {
      const mockUser = { id: 1, email: testUser.email, password: 'hash', name: 'Test', role: 'user', refreshToken: null, createdAt: new Date(), updatedAt: new Date() };
      vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);

      await expect(service.register(testUser)).rejects.toThrow(ConflictException);
      expect(prisma.user.create).not.toHaveBeenCalled();
    });

    it('should register a user without name', async () => {
      const hashedPassword = await bcrypt.hash(testUser.password, 12);
      const mockUser = {
        id: 2,
        email: 'noname@example.com',
        password: hashedPassword,
        name: null,
        role: 'user',
        refreshToken: null,
        createdAt: new Date(),
        updatedAt: new Date(),
      };

      vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
      vi.mocked(prisma.user.create).mockResolvedValue(mockUser);

      const result = await service.register({ email: 'noname@example.com', password: testUser.password });

      expect(result.user.name).toBeNull();
      expect(result.accessToken).toBeDefined();
    });
  });

  describe('login', () => {
    it('should login with valid credentials', async () => {
      const passwordHash = await bcrypt.hash(testUser.password, 12);
      const mockUser = {
        id: 1,
        email: testUser.email,
        password: passwordHash,
        name: testUser.name,
        role: 'user',
        refreshToken: null,
        createdAt: new Date(),
        updatedAt: new Date(),
      };

      vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
      vi.mocked(prisma.user.update).mockResolvedValue({ ...mockUser, refreshToken: 'some-hash' });

      const result = await service.login({ email: testUser.email, password: testUser.password });

      expect(result.user.id).toBe(1);
      expect(result.accessToken).toBeDefined();
      expect(result.refreshToken).toBeDefined();
    });

    it('should throw UnauthorizedException for wrong password', async () => {
      const mockUser = {
        id: 1,
        email: testUser.email,
        password: await bcrypt.hash(testUser.password, 12),
        name: testUser.name,
        role: 'user',
        refreshToken: null,
        createdAt: new Date(),
        updatedAt: new Date(),
      };

      vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);

      await expect(service.login({ email: testUser.email, password: 'wrongPassword' })).rejects.toThrow(UnauthorizedException);
    });

    it('should throw UnauthorizedException for non-existent email', async () => {
      vi.mocked(prisma.user.findUnique).mockResolvedValue(null);

      await expect(service.login({ email: 'nonexistent@example.com', password: 'pass' })).rejects.toThrow(UnauthorizedException);
    });
  });

  describe('refresh', () => {
    it('should rotate tokens on valid refresh', async () => {
      const passwordHash = await bcrypt.hash(testUser.password, 12);
      const oldRefreshToken = 'valid-refresh-token-jwt';
      const oldRefreshHash = await bcrypt.hash(oldRefreshToken, 12);

      const mockUser = {
        id: 1,
        email: testUser.email,
        password: passwordHash,
        name: testUser.name,
        role: 'user',
        refreshToken: oldRefreshHash,
        createdAt: new Date(),
        updatedAt: new Date(),
      };

      vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
      vi.mocked(prisma.user.update).mockResolvedValue(mockUser);

      const decoded = { sub: 1, jti: 'some-jti' };
      vi.spyOn(jwtService, 'verifyAsync').mockResolvedValue(decoded as any);

      const result = await service.refresh(oldRefreshToken);

      expect(result.accessToken).toBeDefined();
      expect(result.refreshToken).toBeDefined();
      expect(result.refreshToken).not.toBe(oldRefreshToken);
      expect(prisma.user.update).toHaveBeenCalled();
    });

    it('should throw on invalid refresh token', async () => {
      vi.spyOn(jwtService, 'verifyAsync').mockRejectedValue(new Error('jwt expired'));

      await expect(service.refresh('bad-token')).rejects.toThrow(UnauthorizedException);
    });
  });

  describe('logout', () => {
    it('should clear refreshToken', async () => {
      vi.mocked(prisma.user.update).mockResolvedValue({
        id: 1,
        email: testUser.email,
        password: 'hash',
        name: testUser.name,
        role: 'user',
        refreshToken: null,
        createdAt: new Date(),
        updatedAt: new Date(),
      });

      await service.logout(1);

      expect(prisma.user.update).toHaveBeenCalledWith({
        where: { id: 1 },
        data: { refreshToken: null },
      });
    });
  });

  describe('updateProfile', () => {
    it('should update user name', async () => {
      const mockUser = {
        id: 1,
        email: testUser.email,
        password: 'hash',
        name: 'Updated Name',
        role: 'user',
        refreshToken: null,
        createdAt: new Date(),
        updatedAt: new Date(),
      };

      vi.mocked(prisma.user.update).mockResolvedValue(mockUser);

      const result = await service.updateProfile(1, { name: 'Updated Name' });

      expect(result.name).toBe('Updated Name');
    });

    it('should return user without changes if no fields provided', async () => {
      const mockUser = {
        id: 1,
        email: testUser.email,
        password: 'hash',
        name: 'Test User',
        role: 'user',
        refreshToken: null,
        createdAt: new Date(),
        updatedAt: new Date(),
      };

      vi.mocked(prisma.user.update).mockResolvedValue(mockUser);

      const result = await service.updateProfile(1, {});

      expect(result.name).toBe('Test User');
    });
  });
});
  • Step 4: Run test to verify it fails
npx vitest run apps/backend/src/modules/auth/auth.service.spec.ts -w apps/backend

Expected: FAIL — "AuthService not defined" or similar.

  • Step 5: Write minimal AuthService
// apps/backend/src/modules/auth/auth.service.ts
import { Injectable, ConflictException, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { JwtPayload } from './interfaces/jwt-payload.interface';

const SALT_ROUNDS = 12;

@Injectable()
export class AuthService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly jwtService: JwtService,
    private readonly configService: ConfigService,
  ) {}

  async register(dto: RegisterDto) {
    const existing = await this.prisma.user.findUnique({ where: { email: dto.email } });
    if (existing) {
      throw new ConflictException('Email already registered');
    }

    const passwordHash = await bcrypt.hash(dto.password, SALT_ROUNDS);
    const user = await this.prisma.user.create({
      data: {
        email: dto.email,
        password: passwordHash,
        name: dto.name ?? null,
      },
    });

    return this.generateTokens(user);
  }

  async login(dto: LoginDto) {
    const user = await this.prisma.user.findUnique({ where: { email: dto.email } });
    if (!user) {
      throw new UnauthorizedException('Invalid email or password');
    }

    const isValid = await bcrypt.compare(dto.password, user.password);
    if (!isValid) {
      throw new UnauthorizedException('Invalid email or password');
    }

    return this.generateTokens(user);
  }

  async refresh(refreshToken: string) {
    try {
      const payload = await this.jwtService.verifyAsync<{ sub: number; jti: string }>(refreshToken, {
        secret: this.configService.get('auth.jwtRefreshSecret'),
      });

      const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
      if (!user?.refreshToken) {
        throw new UnauthorizedException('Invalid refresh token');
      }

      const isValid = await bcrypt.compare(refreshToken, user.refreshToken);
      if (!isValid) {
        throw new UnauthorizedException('Invalid refresh token');
      }

      return this.generateTokens(user);
    } catch {
      throw new UnauthorizedException('Invalid refresh token');
    }
  }

  async logout(userId: number) {
    await this.prisma.user.update({
      where: { id: userId },
      data: { refreshToken: null },
    });
  }

  async getProfile(userId: number) {
    const user = await this.prisma.user.findUnique({ where: { id: userId } });
    if (!user) {
      throw new UnauthorizedException('User not found');
    }
    return this.sanitizeUser(user);
  }

  async updateProfile(userId: number, dto: UpdateProfileDto) {
    const user = await this.prisma.user.update({
      where: { id: userId },
      data: {
        ...(dto.name !== undefined && { name: dto.name }),
      },
    });
    return this.sanitizeUser(user);
  }

  private async generateTokens(user: { id: number; email: string; role: string }) {
    const accessPayload: JwtPayload = { sub: user.id, email: user.email, role: user.role };
    const accessToken = await this.jwtService.signAsync(accessPayload, {
      secret: this.configService.get('auth.jwtSecret'),
      expiresIn: this.configService.get('auth.jwtAccessExpires'),
    });

    const jti = crypto.randomUUID();
    const refreshPayload = { sub: user.id, jti };
    const refreshToken = await this.jwtService.signAsync(refreshPayload, {
      secret: this.configService.get('auth.jwtRefreshSecret'),
      expiresIn: this.configService.get('auth.jwtRefreshExpires'),
    });

    const refreshHash = await bcrypt.hash(refreshToken, SALT_ROUNDS);
    await this.prisma.user.update({
      where: { id: user.id },
      data: { refreshToken: refreshHash },
    });

    return {
      user: this.sanitizeUser(user),
      accessToken,
      refreshToken,
    };
  }

  private sanitizeUser(user: { id: number; email: string; name: string | null; role: string }) {
    return {
      id: user.id,
      email: user.email,
      name: user.name,
      role: user.role,
    };
  }
}
  • Step 6: Run test to verify it passes
npx vitest run apps/backend/src/modules/auth/auth.service.spec.ts -w apps/backend

Expected: PASS


Task 3: Auth guards, decorators, controller, module

Files:

  • Create: apps/backend/src/modules/auth/decorators/public.decorator.ts

  • Create: apps/backend/src/modules/auth/decorators/current-user.decorator.ts

  • Create: apps/backend/src/modules/auth/decorators/roles.decorator.ts

  • Create: apps/backend/src/modules/auth/guards/jwt-auth.guard.ts

  • Create: apps/backend/src/modules/auth/guards/roles.guard.ts

  • Create: apps/backend/src/modules/auth/auth.controller.ts

  • Create: apps/backend/src/modules/auth/auth.module.ts

  • Step 1: Public decorator

// apps/backend/src/modules/auth/decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
  • Step 2: CurrentUser decorator
// apps/backend/src/modules/auth/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const CurrentUser = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    return request.user;
  },
);
  • Step 3: Roles decorator
// apps/backend/src/modules/auth/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
  • Step 4: JwtAuthGuard
// apps/backend/src/modules/auth/guards/jwt-auth.guard.ts
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { JwtPayload } from '../interfaces/jwt-payload.interface';

@Injectable()
export class JwtAuthGuard {
  constructor(
    private readonly reflector: Reflector,
    private readonly jwtService: JwtService,
    private readonly configService: ConfigService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (isPublic) {
      return true;
    }

    const request = context.switchToHttp().getRequest();
    const token = this.extractToken(request);

    if (!token) {
      throw new UnauthorizedException('Authentication required');
    }

    try {
      const payload = await this.jwtService.verifyAsync<JwtPayload>(token, {
        secret: this.configService.get('auth.jwtSecret'),
      });
      request.user = payload;
      return true;
    } catch {
      throw new UnauthorizedException('Invalid or expired token');
    }
  }

  private extractToken(request: any): string | null {
    const auth = request.headers?.authorization;
    if (!auth) return null;
    const [type, token] = auth.split(' ');
    return type === 'Bearer' ? token : null;
  }
}
  • Step 5: RolesGuard
// apps/backend/src/modules/auth/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (!requiredRoles || requiredRoles.length === 0) {
      return true;
    }

    const request = context.switchToHttp().getRequest();
    return requiredRoles.includes(request.user?.role);
  }
}
  • Step 6: AuthController
// apps/backend/src/modules/auth/auth.controller.ts
import { Controller, Post, Get, Patch, Body, Req, Res, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { CurrentUser } from './decorators/current-user.decorator';
import { Public } from './decorators/public.decorator';
import { JwtPayload } from './interfaces/jwt-payload.interface';

const REFRESH_COOKIE = 'refresh_token';
const COOKIE_OPTIONS = {
  httpOnly: true,
  sameSite: 'lax' as const,
  secure: process.env.NODE_ENV === 'production',
  path: '/api/v1/auth',
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
};

@ApiTags('Auth')
@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Public()
  @Post('register')
  @ApiOperation({ summary: 'Register new user' })
  async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
    const result = await this.authService.register(dto);
    res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
    return {
      data: {
        user: result.user,
        accessToken: result.accessToken,
      },
      meta: { fromCache: false, cachedAt: null },
    };
  }

  @Public()
  @Post('login')
  @ApiOperation({ summary: 'Login with email and password' })
  async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
    const result = await this.authService.login(dto);
    res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
    return {
      data: {
        user: result.user,
        accessToken: result.accessToken,
      },
      meta: { fromCache: false, cachedAt: null },
    };
  }

  @Public()
  @Post('refresh')
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: 'Refresh access token' })
  async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
    const token = req.cookies?.[REFRESH_COOKIE];
    const result = await this.authService.refresh(token);
    res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
    return {
      data: {
        user: result.user,
        accessToken: result.accessToken,
      },
      meta: { fromCache: false, cachedAt: null },
    };
  }

  @Post('logout')
  @HttpCode(HttpStatus.OK)
  @ApiBearerAuth()
  @ApiOperation({ summary: 'Logout (clear refresh token)' })
  async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) {
    await this.authService.logout(user.sub);
    res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' });
    return {
      data: { message: 'Logged out successfully' },
      meta: { fromCache: false, cachedAt: null },
    };
  }

  @Get('me')
  @ApiBearerAuth()
  @ApiOperation({ summary: 'Get current user profile' })
  async getProfile(@CurrentUser() user: JwtPayload) {
    const profile = await this.authService.getProfile(user.sub);
    return {
      data: profile,
      meta: { fromCache: false, cachedAt: null },
    };
  }

  @Patch('me')
  @ApiBearerAuth()
  @ApiOperation({ summary: 'Update current user profile' })
  async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) {
    const profile = await this.authService.updateProfile(user.sub, dto);
    return {
      data: profile,
      meta: { fromCache: false, cachedAt: null },
    };
  }
}
  • Step 7: AuthModule
// apps/backend/src/modules/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { RolesGuard } from './guards/roles.guard';

@Module({
  imports: [
    JwtModule.register({}),
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
    JwtAuthGuard,
    RolesGuard,
  ],
  exports: [JwtAuthGuard, RolesGuard],
})
export class AuthModule {}

Task 4: Integrate AuthModule into app

Files:

  • Modify: apps/backend/src/app.module.ts

  • Modify: apps/backend/src/main.ts

  • Modify: apps/backend/src/config/configuration.ts

  • Modify: apps/backend/package.json

  • Step 1: Update configuration.ts

// apps/backend/src/config/configuration.ts — add auth section
import { registerAs } from '@nestjs/config';

export default registerAs('app', () => ({
  port: parseInt(process.env.PORT || '3000', 10),
  // ... existing config ...
  auth: {
    jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
    jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-change-in-production',
    jwtAccessExpires: process.env.JWT_ACCESS_EXPIRES || '15m',
    jwtRefreshExpires: process.env.JWT_REFRESH_EXPIRES || '7d',
  },
  // ... rest of config ...
}));
  • Step 2: Update app.module.ts
// apps/backend/src/app.module.ts — add imports
imports: [
  ConfigModule.forRoot({ load: [configuration], isGlobal: true }),
  PrismaModule,
  CacheModule,
  MoexClientModule,
  HealthModule,
  AuthModule,
  SecuritiesModule,
  SharesModule,
  BondsModule,
  CandlesModule,
],
  • Step 3: Update main.ts — add cookie-parser + Swagger bearer
// apps/backend/src/main.ts
import * as cookieParser from 'cookie-parser';

// After app creation:
app.use(cookieParser());

// Swagger config:
const config = new DocumentBuilder()
  .setTitle('MoexVibe API')
  .setVersion('1.0.0')
  .addBearerAuth()
  .build();
  • Step 4: Update .env with JWT secrets
JWT_SECRET=dev-jwt-secret-change-in-production
JWT_REFRESH_SECRET=dev-refresh-secret-change-in-production
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d

Task 5: Frontend — auth types + refactored client

Files:

  • Modify: apps/frontend/src/api/responses.ts

  • Modify: apps/frontend/src/api/client.ts

  • Create: apps/frontend/src/api/auth.ts

  • Step 1: Add auth types to responses.ts

// apps/frontend/src/api/responses.ts — add:
export interface UserResponse {
  id: number;
  email: string;
  name: string | null;
  role: string;
}

export interface AuthResponse {
  user: UserResponse;
  accessToken: string;
}
  • Step 2: Refactor client.ts — add auth interceptor pattern
// apps/frontend/src/api/client.ts
import type { ApiEnvelope, ApiResponseMeta } from './responses';

const BASE = '';

let accessToken: string | null = null;
let onUnauthorized: (() => void) | null = null;

export function setAccessToken(token: string | null) {
  accessToken = token;
}

export function getAccessToken(): string | null {
  return accessToken;
}

export function setOnUnauthorized(cb: () => void) {
  onUnauthorized = cb;
}

async function request<T>(
  path: string,
  params?: Record<string, string>,
  options?: { skipAuth?: boolean },
): Promise<{ data: T; meta: ApiResponseMeta }> {
  const url = new URL(`${BASE}${path}`, window.location.origin);
  if (params) {
    for (const [k, v] of Object.entries(params)) {
      if (v !== undefined) url.searchParams.set(k, v);
    }
  }

  const headers: Record<string, string> = {};
  if (!options?.skipAuth && accessToken) {
    headers['Authorization'] = `Bearer ${accessToken}`;
  }

  const res = await fetch(url.toString(), {
    headers,
    credentials: 'include',
  });

  if (res.status === 401 && !options?.skipAuth) {
    // Try to refresh
    try {
      const refreshRes = await fetch(`${BASE}/api/v1/auth/refresh`, {
        method: 'POST',
        credentials: 'include',
        headers: { 'Content-Type': 'application/json' },
      });

      if (refreshRes.ok) {
        const refreshJson: ApiEnvelope<{ data: AuthResponse; meta: ApiResponseMeta }> = await refreshRes.json();
        const authData = refreshJson.data.data;
        setAccessToken(authData.accessToken);
        // Retry original request with new token
        headers['Authorization'] = `Bearer ${authData.accessToken}`;
        const retryRes = await fetch(url.toString(), { headers, credentials: 'include' });
        if (!retryRes.ok) throw new Error(`API error: ${retryRes.status}`);
        const retryJson: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await retryRes.json();
        return retryJson.data;
      } else {
        setAccessToken(null);
        onUnauthorized?.();
        throw new Error('Session expired');
      }
    } catch {
      onUnauthorized?.();
      throw new Error('Session expired');
    }
  }

  if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
  const json: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await res.json();
  return json.data;
}

// Need to import AuthResponse type
import type { AuthResponse } from './responses';

// ... rest of existing functions stay the same
  • Step 3: Create auth.ts
// apps/frontend/src/api/auth.ts
import { request, setAccessToken } from './client';
import type { AuthResponse, UserResponse } from './responses';

export async function login(email: string, password: string) {
  const result = await request<AuthResponse>('/api/v1/auth/login', undefined, {
    method: 'POST',
    body: { email, password },
  });
  setAccessToken(result.data.accessToken);
  return result.data;
}

export async function register(email: string, password: string, name?: string) {
  const result = await request<AuthResponse>('/api/v1/auth/register', undefined, {
    method: 'POST',
    body: { email, password, name },
  });
  setAccessToken(result.data.accessToken);
  return result.data;
}

export async function refreshTokens() {
  const result = await request<AuthResponse>('/api/v1/auth/refresh', undefined, {
    skipAuth: true,
  });
  setAccessToken(result.data.accessToken);
  return result.data;
}

export async function logout() {
  await request<{ message: string }>('/api/v1/auth/logout', undefined, {
    method: 'POST',
  });
  setAccessToken(null);
}

export async function getMe() {
  return request<UserResponse>('/api/v1/auth/me');
}

export async function updateProfile(data: { name?: string }) {
  return request<UserResponse>('/api/v1/auth/me', undefined, {
    method: 'PATCH',
    body: data,
  });
}

Wait — I need to rethink the client.ts approach. Currently request() uses GET with query params. For POST/PATCH requests I need to handle body. Let me redesign.

Let me restructure: keep existing API functions untouched (they all use GET), and add a new authRequest function or modify request to support other methods.

I'll modify the request function signature slightly:

async function request<T>(
  path: string,
  params?: Record<string, string> | { method: string; body?: unknown },
  options?: { method?: string; body?: unknown; skipAuth?: boolean },
): Promise<{ data: T; meta: ApiResponseMeta }>

This is getting messy. Better approach: create a separate authRequest function or modify the existing request. Let me think...

Actually the cleanest approach: modify the existing request to accept an options bag with method, body, params as separate fields:

interface RequestOptions {
  params?: Record<string, string>;
  method?: string;
  body?: unknown;
  skipAuth?: boolean;
}

This way the existing callers still work with the old signature (params as second arg).

Let me plan this properly in the implementation.<end▁of▁thinking>

<DSMLtool_calls> <DSMLinvoke name="todowrite"> <DSMLparameter name="todos" string="false">[{"priority":"high","content":"Save implementation plan to docs/features/auth-system/plan.md","status":"completed"},{"priority":"high","content":"Install backend dependencies (Prisma, JWT, bcrypt, cookie-parser)","status":"in_progress"},{"priority":"high","content":"Set up Prisma schema + PrismaService + PrismaModule","status":"pending"},{"priority":"high","content":"Build AuthService (register, login, refresh, logout) with TDD","status":"pending"},{"priority":"high","content":"Build Auth guards, decorators, DTOs, controller","status":"pending"},{"priority":"high","content":"Integrate AuthModule into app.module, main.ts, configuration","status":"pending"},{"priority":"high","content":"Frontend: refactor client.ts + add auth types","status":"pending"},{"priority":"high","content":"Frontend: AuthContext + useAuth hook","status":"pending"},{"priority":"high","content":"Frontend: Login, Register, Profile pages + ProtectedRoute","status":"pending"},{"priority":"high","content":"Frontend: Layout + routing changes","status":"pending"},{"priority":"high","content":"Documentation (backend auth.md, database.md, ADR, api.md, architecture.md)","status":"pending"},{"priority":"high","content":"Verification: run tests, lint, build","status":"pending"}]