# 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** ```bash 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** ```bash npx prisma init --datasource-provider sqlite ``` Update the generated `apps/backend/prisma/schema.prisma`: ```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** ```env DATABASE_URL="file:./dev.db" ``` Add `.env` to backend `.gitignore`: ```gitignore node_modules/ dist/ .env *.db *.db-journal ``` - [ ] **Step 4: PrismaService** ```typescript // 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** ```typescript // 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** ```bash npx prisma migrate dev --name init ``` - [ ] **Step 7: Commit** ```bash 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** ```typescript // apps/backend/src/modules/auth/interfaces/jwt-payload.interface.ts export interface JwtPayload { sub: number; email: string; role: string; } ``` - [ ] **Step 2: Write DTOs** ```typescript // 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; } ``` ```typescript // 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; } ``` ```typescript // 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** ```typescript // 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 = { 'auth.jwtSecret': 'test-secret', 'auth.jwtAccessExpires': '15m', 'auth.jwtRefreshExpires': '7d', }; return config[key]; }), }, }, ], }).compile(); service = module.get(AuthService); prisma = module.get(PrismaService); jwtService = module.get(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** ```bash 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** ```typescript // 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** ```bash 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** ```typescript // 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** ```typescript // 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** ```typescript // 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** ```typescript // 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 { const isPublic = this.reflector.getAllAndOverride(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(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** ```typescript // 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(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** ```typescript // 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** ```typescript // 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** ```typescript // 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** ```typescript // 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** ```typescript // 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** ```typescript // 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** ```typescript // 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( path: string, params?: Record, 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 = {}; 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** ```typescript // 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('/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('/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('/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('/api/v1/auth/me'); } export async function updateProfile(data: { name?: string }) { return request('/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: ```typescript async function request( path: string, params?: Record | { 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: ```typescript interface RequestOptions { params?: Record; 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|> <||DSML||tool_calls> <||DSML||invoke name="todowrite"> <||DSML||parameter name="todos" string="false">[{"priority":"high","content":"Save implementation plan to docs/superpowers/plans/","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"}]