diff --git a/AGENTS.md b/AGENTS.md index deed358..f659aa5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,13 +37,20 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr | `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) | | `CACHE_SECURITY_TTL` | 86400 | TTL спецификации (с) | | `CACHE_SEARCH_TTL` | 3600 | TTL результатов поиска (с) | +| `DATABASE_URL` | `file:./dev.db` | URL SQLite для Prisma | +| `JWT_SECRET` | `dev-jwt-secret-...` | Secret для access token | +| `JWT_REFRESH_SECRET` | `dev-refresh-secret-...` | Secret для refresh token | +| `JWT_ACCESS_EXPIRES` | `15m` | TTL access token | +| `JWT_REFRESH_EXPIRES` | `7d` | TTL refresh token | ## Архитектура - **Бэкенд** — единственный клиент MOEX. Фронтенд никогда не обращается к MOEX напрямую. -- Feature-модули: `MoexClientModule` (глобальный), `CacheModule` (глобальный), `SharesModule`, `BondsModule`, `SecuritiesModule`, `CandlesModule`, `HealthModule`. +- Feature-модули: `PrismaModule` (глобальный), `MoexClientModule` (глобальный), `CacheModule` (глобальный), `AuthModule`, `SharesModule`, `BondsModule`, `SecuritiesModule`, `CandlesModule`, `HealthModule`. - `MoexClientService` использует p-queue (rate limiter) + circuit breaker (5 ошибок → 30s открыт). - In-memory кеш через `@nestjs/cache-manager`. Путь миграции на Redis описан (см. ADR-002). +- Аутентификация: JWT access token (15m, в памяти) + refresh token (7d, httpOnly cookie, bcrypt hash в БД). Глобальный `JwtAuthGuard` (`@Public()` для открытых эндпоинтов). +- БД: SQLite через Prisma ORM. Prisma client генерируется в `src/generated/prisma/`. - Глобальный префикс NestJS: `/api/v1`. Swagger: `/api/docs`. - Глобальный ValidationPipe (`transform: true, whitelist: true`), `HttpExceptionFilter`, `TransformInterceptor`, middleware логирования запросов. - Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`. diff --git a/apps/backend/.gitignore b/apps/backend/.gitignore new file mode 100644 index 0000000..97bce4b --- /dev/null +++ b/apps/backend/.gitignore @@ -0,0 +1,10 @@ +node_modules +# Keep environment variables out of version control +.env + +# Prisma generated client +node_modules/.prisma + +# SQLite database +*.db +*.db-journal diff --git a/apps/backend/package.json b/apps/backend/package.json index 520478a..292b1c5 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -11,17 +11,23 @@ "test:watch": "vitest" }, "dependencies": { + "@libsql/client": "^0.17.3", "@nestjs/axios": "^3.0.0", "@nestjs/cache-manager": "^2.0.0", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^11.0.2", "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^7.0.0", + "@prisma/adapter-libsql": "^7.8.0", + "@prisma/client": "^7.8.0", "axios": "^1.6.0", + "bcrypt": "^6.0.0", "cache-manager": "^5.0.0", "class-transformer": "^0.5.0", "class-validator": "^0.14.0", + "cookie-parser": "^1.4.7", "p-queue": "^7.3.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0", @@ -32,11 +38,14 @@ "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", "@swc/core": "^1.15.41", + "@types/bcrypt": "^6.0.0", + "@types/cookie-parser": "^1.4.10", "@types/express": "^4.17.0", "@types/node": "^20.0.0", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.0.0", + "prisma": "^7.8.0", "typescript": "^5.3.0", "unplugin-swc": "^1.5.9", "vitest": "^1.0.0" diff --git a/apps/backend/prisma.config.ts b/apps/backend/prisma.config.ts new file mode 100644 index 0000000..831a20f --- /dev/null +++ b/apps/backend/prisma.config.ts @@ -0,0 +1,14 @@ +// This file was generated by Prisma, and assumes you have installed the following: +// npm install --save-dev prisma dotenv +import "dotenv/config"; +import { defineConfig } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + }, + datasource: { + url: process.env["DATABASE_URL"], + }, +}); diff --git a/apps/backend/prisma/migrations/20260613190219_init/migration.sql b/apps/backend/prisma/migrations/20260613190219_init/migration.sql new file mode 100644 index 0000000..537d186 --- /dev/null +++ b/apps/backend/prisma/migrations/20260613190219_init/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "name" TEXT, + "role" TEXT NOT NULL DEFAULT 'user', + "refreshToken" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/apps/backend/prisma/migrations/migration_lock.toml b/apps/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/apps/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma new file mode 100644 index 0000000..335aed0 --- /dev/null +++ b/apps/backend/prisma/schema.prisma @@ -0,0 +1,18 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" +} + +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 +} diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 4865672..0c6646d 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -7,14 +7,18 @@ import { SecuritiesModule } from './modules/securities/securities.module'; import { SharesModule } from './modules/shares/shares.module'; import { BondsModule } from './modules/bonds/bonds.module'; import { CandlesModule } from './modules/candles/candles.module'; +import { PrismaModule } from './modules/prisma/prisma.module'; +import { AuthModule } from './modules/auth/auth.module'; import configuration from './config/configuration'; @Module({ imports: [ - ConfigModule.forRoot({ load: [configuration], isGlobal: true }), + ConfigModule.forRoot({ load: [configuration], isGlobal: true, envFilePath: '.env' }), + PrismaModule, CacheModule, MoexClientModule, HealthModule, + AuthModule, SecuritiesModule, SharesModule, BondsModule, diff --git a/apps/backend/src/config/configuration.ts b/apps/backend/src/config/configuration.ts index cd9bc47..028b210 100644 --- a/apps/backend/src/config/configuration.ts +++ b/apps/backend/src/config/configuration.ts @@ -2,6 +2,9 @@ import { registerAs } from '@nestjs/config'; export default registerAs('app', () => ({ port: parseInt(process.env.PORT || '3000', 10), + database: { + url: process.env.DATABASE_URL || 'file:./dev.db', + }, moex: { baseUrl: process.env.MOEX_BASE_URL || 'https://iss.moex.com/iss', rateLimit: parseInt(process.env.MOEX_RATE_LIMIT || '10', 10), @@ -19,4 +22,10 @@ export default registerAs('app', () => ({ searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10), dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10), }, + 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', + }, })); diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 9e57aa2..7b3e8db 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -6,6 +6,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { TransformInterceptor } from './common/interceptors/transform.interceptor'; import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware'; import { ValidationPipe } from '@nestjs/common'; +import cookieParser from 'cookie-parser'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -15,12 +16,18 @@ async function bootstrap() { app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new TransformInterceptor()); + app.use(cookieParser()); + const reqLogMiddleware = new RequestLoggingMiddleware(); app.use(reqLogMiddleware.use.bind(reqLogMiddleware)); - app.enableCors(); + app.enableCors({ origin: true, credentials: true }); - const config = new DocumentBuilder().setTitle('MoexVibe API').setVersion('1.0.0').build(); + const config = new DocumentBuilder() + .setTitle('MoexVibe API') + .setVersion('1.0.0') + .addBearerAuth() + .build(); const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api/docs', app, document); diff --git a/apps/backend/src/modules/auth/auth.controller.ts b/apps/backend/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..68c58bf --- /dev/null +++ b/apps/backend/src/modules/auth/auth.controller.ts @@ -0,0 +1,107 @@ +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, +}; + +@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 user' }) + 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 }, + }; + } +} diff --git a/apps/backend/src/modules/auth/auth.module.ts b/apps/backend/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..2e89d98 --- /dev/null +++ b/apps/backend/src/modules/auth/auth.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +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, + { provide: APP_GUARD, useClass: JwtAuthGuard }, + { provide: APP_GUARD, useClass: RolesGuard }, + ], +}) +export class AuthModule {} diff --git a/apps/backend/src/modules/auth/auth.service.spec.ts b/apps/backend/src/modules/auth/auth.service.spec.ts new file mode 100644 index 0000000..c187952 --- /dev/null +++ b/apps/backend/src/modules/auth/auth.service.spec.ts @@ -0,0 +1,182 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +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; + + const testUser = { + email: 'test@example.com', + password: 'testPass123', + name: 'Test User', + }; + + const mockPrismaUser = (overrides: Record = {}) => ({ + id: 1, + email: testUser.email, + password: '', + name: testUser.name, + role: 'user', + refreshToken: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }); + + 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 = { + 'app.auth.jwtSecret': 'test-secret', + 'app.auth.jwtRefreshSecret': 'test-refresh-secret', + 'app.auth.jwtAccessExpires': '15m', + 'app.auth.jwtRefreshExpires': '7d', + }; + return config[key]; + }), + }, + }, + ], + }).compile(); + + service = module.get(AuthService); + prisma = module.get(PrismaService); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('register', () => { + it('should register a new user and return tokens', async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.create).mockResolvedValue(mockPrismaUser() as any); + + 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(result.refreshToken).not.toBe(testUser.password); + expect(prisma.user.findUnique).toHaveBeenCalledWith({ + where: { email: testUser.email }, + }); + }); + + it('should throw ConflictException if email already exists', async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(mockPrismaUser() as any); + + await expect(service.register(testUser)).rejects.toThrow(ConflictException); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + it('should register a user without name', async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.create).mockResolvedValue( + mockPrismaUser({ name: null, id: 2 }) as any, + ); + + const result = await service.register({ email: testUser.email, 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); + vi.mocked(prisma.user.findUnique).mockResolvedValue( + mockPrismaUser({ password: passwordHash }) as any, + ); + vi.mocked(prisma.user.update).mockResolvedValue(mockPrismaUser() as any); + + 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 passwordHash = await bcrypt.hash(testUser.password, 12); + vi.mocked(prisma.user.findUnique).mockResolvedValue( + mockPrismaUser({ password: passwordHash }) as any, + ); + + 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('logout', () => { + it('should clear refreshToken', async () => { + vi.mocked(prisma.user.update).mockResolvedValue(mockPrismaUser() as any); + + await service.logout(1); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { refreshToken: null }, + }); + }); + }); + + describe('updateProfile', () => { + it('should update user name', async () => { + vi.mocked(prisma.user.update).mockResolvedValue( + mockPrismaUser({ name: 'Updated Name' }) as any, + ); + + 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 () => { + vi.mocked(prisma.user.update).mockResolvedValue(mockPrismaUser() as any); + + const result = await service.updateProfile(1, {}); + + expect(result.name).toBe(testUser.name); + }); + }); +}); diff --git a/apps/backend/src/modules/auth/auth.service.ts b/apps/backend/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..3abbbe0 --- /dev/null +++ b/apps/backend/src/modules/auth/auth.service.ts @@ -0,0 +1,138 @@ +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('app.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; name: string | null; 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('app.auth.jwtSecret'), + expiresIn: this.configService.get('app.auth.jwtAccessExpires'), + }); + + const jti = crypto.randomUUID(); + const refreshPayload = { sub: user.id, jti }; + const refreshToken = await this.jwtService.signAsync(refreshPayload, { + secret: this.configService.get('app.auth.jwtRefreshSecret'), + expiresIn: this.configService.get('app.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, + }; + } +} diff --git a/apps/backend/src/modules/auth/decorators/current-user.decorator.ts b/apps/backend/src/modules/auth/decorators/current-user.decorator.ts new file mode 100644 index 0000000..12a6aa9 --- /dev/null +++ b/apps/backend/src/modules/auth/decorators/current-user.decorator.ts @@ -0,0 +1,6 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.user; +}); diff --git a/apps/backend/src/modules/auth/decorators/public.decorator.ts b/apps/backend/src/modules/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/apps/backend/src/modules/auth/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/apps/backend/src/modules/auth/decorators/roles.decorator.ts b/apps/backend/src/modules/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..e038e16 --- /dev/null +++ b/apps/backend/src/modules/auth/decorators/roles.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/apps/backend/src/modules/auth/dto/login.dto.ts b/apps/backend/src/modules/auth/dto/login.dto.ts new file mode 100644 index 0000000..1677b5d --- /dev/null +++ b/apps/backend/src/modules/auth/dto/login.dto.ts @@ -0,0 +1,12 @@ +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; +} diff --git a/apps/backend/src/modules/auth/dto/register.dto.ts b/apps/backend/src/modules/auth/dto/register.dto.ts new file mode 100644 index 0000000..f47aaaa --- /dev/null +++ b/apps/backend/src/modules/auth/dto/register.dto.ts @@ -0,0 +1,21 @@ +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; +} diff --git a/apps/backend/src/modules/auth/dto/update-profile.dto.ts b/apps/backend/src/modules/auth/dto/update-profile.dto.ts new file mode 100644 index 0000000..0338393 --- /dev/null +++ b/apps/backend/src/modules/auth/dto/update-profile.dto.ts @@ -0,0 +1,11 @@ +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; +} diff --git a/apps/backend/src/modules/auth/guards/jwt-auth.guard.ts b/apps/backend/src/modules/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..87e697d --- /dev/null +++ b/apps/backend/src/modules/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,50 @@ +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('app.auth.jwtSecret'), + }); + request.user = payload; + return true; + } catch { + throw new UnauthorizedException('Invalid or expired token'); + } + } + + private extractToken(request: { headers?: Record }): string | null { + const auth = request.headers?.authorization; + if (!auth) return null; + const [type, token] = auth.split(' '); + return type === 'Bearer' ? token : null; + } +} diff --git a/apps/backend/src/modules/auth/guards/roles.guard.ts b/apps/backend/src/modules/auth/guards/roles.guard.ts new file mode 100644 index 0000000..f398fbc --- /dev/null +++ b/apps/backend/src/modules/auth/guards/roles.guard.ts @@ -0,0 +1,22 @@ +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); + } +} diff --git a/apps/backend/src/modules/auth/interfaces/jwt-payload.interface.ts b/apps/backend/src/modules/auth/interfaces/jwt-payload.interface.ts new file mode 100644 index 0000000..53edc78 --- /dev/null +++ b/apps/backend/src/modules/auth/interfaces/jwt-payload.interface.ts @@ -0,0 +1,5 @@ +export interface JwtPayload { + sub: number; + email: string; + role: string; +} diff --git a/apps/backend/src/modules/health/health.controller.ts b/apps/backend/src/modules/health/health.controller.ts index 24296ba..e27e33b 100644 --- a/apps/backend/src/modules/health/health.controller.ts +++ b/apps/backend/src/modules/health/health.controller.ts @@ -1,10 +1,12 @@ import { Controller, Get } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { Public } from '../auth/decorators/public.decorator'; @ApiTags('Health') @Controller('health') export class HealthController { @Get() + @Public() @ApiOperation({ summary: 'Проверка состояния сервиса' }) check() { return { diff --git a/apps/backend/src/modules/prisma/prisma.module.ts b/apps/backend/src/modules/prisma/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/apps/backend/src/modules/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/apps/backend/src/modules/prisma/prisma.service.ts b/apps/backend/src/modules/prisma/prisma.service.ts new file mode 100644 index 0000000..2bcb147 --- /dev/null +++ b/apps/backend/src/modules/prisma/prisma.service.ts @@ -0,0 +1,21 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { PrismaLibSql } from '@prisma/adapter-libsql'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + constructor() { + const url = process.env.DATABASE_URL || 'file:./dev.db'; + const adapter = new PrismaLibSql({ url }); + super({ adapter }); + } + + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} + diff --git a/apps/backend/vitest.config.ts b/apps/backend/vitest.config.ts index 0d9d8f1..12b9a9e 100644 --- a/apps/backend/vitest.config.ts +++ b/apps/backend/vitest.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from 'vitest/config'; import swc from 'unplugin-swc'; +import path from 'path'; export default defineConfig({ test: { @@ -19,4 +20,9 @@ export default defineConfig({ }, }), ], + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + }, + }, }); diff --git a/apps/docs/docs/adr/ADR-008-auth-system.md b/apps/docs/docs/adr/ADR-008-auth-system.md new file mode 100644 index 0000000..d94e80b --- /dev/null +++ b/apps/docs/docs/adr/ADR-008-auth-system.md @@ -0,0 +1,61 @@ +--- +sidebar_position: 14 +title: ADR-008 +--- + +# ADR-008: Authentication & Authorization System + +## Status + +Accepted + +## Context + +Приложение MoexVibe публичное, но для персонализации и будущих функций (избранное, уведомления, подписки) требуется система аутентификации. Необходимо решение, которое: + +- Безопасно хранит учётные данные +- Поддерживает session management +- Позволяет расширять ролевую модель +- Интегрируется в существующий стек (NestJS + React) + +## Decision + +### Backend + +- **Database:** SQLite через Prisma ORM (без необходимости внешнего сервиса) +- **Auth strategy:** JWT access tokens (15m) + refresh tokens (7d) in httpOnly cookies +- **Password storage:** bcrypt with 12 salt rounds +- **Guard model:** Global `JwtAuthGuard` (все маршруты защищены по умолчанию, `@Public()` для открытых) +- **RBAC:** Роли `user` и `admin`, проверка через `RolesGuard` + +### Frontend + +- **State management:** React Context (`AuthContext`) для хранения пользователя и access token +- **Token storage:** Access token только в памяти (не localStorage), refresh token в httpOnly cookie +- **Auto-refresh:** При 401 → автоматический вызов `/auth/refresh` → повтор оригинального запроса +- **Route protection:** `ProtectedRoute` компонент, редирект на `/login` с return URL + +## Consequences + +### Positive + +- httpOnly cookie защищает refresh token от XSS +- Access token в памяти защищён от кражи через localStorage +- Prisma + SQLite не требует внешнего сервиса (zero setup) +- Refresh token rotation повышает безопасность +- Глобальный guard — безопасность по умолчанию + +### Negative + +- Access token теряется при полной перезагрузке страницы (восстанавливается через refresh cookie) +- Для production требуется генерация надёжных JWT_SECRET +- Нет rate limiting на login endpoint (TODO) +- Нет верификации email (принятое упрощение) + +## Migration Path + +Для перехода на PostgreSQL потребуется: +1. Изменить `provider` в schema.prisma на `postgresql` +2. Обновить `DATABASE_URL` +3. Выполнить `prisma migrate dev` +4. Никаких изменений кода не требуется (Prisma abstracts database) diff --git a/apps/docs/docs/architecture.md b/apps/docs/docs/architecture.md index 187b1a8..881a04f 100644 --- a/apps/docs/docs/architecture.md +++ b/apps/docs/docs/architecture.md @@ -8,22 +8,30 @@ graph TD Backend["NestJS API
:3000"] Cache["In-Memory Cache
(cache-manager)"] MOEX["MOEX ISS API
iss.moex.com"] + DB[("SQLite Database
(Prisma)")] Browser -->|"/api/v1/*"| Backend + Browser -->|"Cookie: refreshToken"| Backend + Browser -->|"Authorization: Bearer"| Backend Backend -->|"getOrFetch()"| Cache Backend -->|"GET /iss/*.json"| MOEX + Backend -->|"Prisma ORM"| DB Cache -->|"data"| Backend + DB -->|"users"| Backend MOEX -->|"raw data"| Backend subgraph Backend_Internal["Backend (NestJS)"] MoexClient["MoexClientModule
p-queue + circuit breaker"] + Auth["AuthModule
JWT + bcrypt + Guards"] Shares["SharesModule"] Bonds["BondsModule"] Candles["CandlesModule"] Securities["SecuritiesModule"] Health["HealthModule"] CacheService["CacheService
(global)"] + Prisma["PrismaService
(global)"] + Auth -->|"user CRUD"| Prisma MoexClient -->|"fetches"| Shares MoexClient -->|"fetches"| Bonds MoexClient -->|"fetches"| Candles @@ -35,7 +43,7 @@ graph TD end ``` -## Request Flow +## Request Flow (Authenticated) ```mermaid sequenceDiagram @@ -44,19 +52,22 @@ sequenceDiagram participant Backend as NestJS API participant Cache as In-Memory Cache participant MOEX as MOEX ISS + participant AuthDB as SQLite - User->>Frontend: Search / View instrument - Frontend->>Backend: GET /api/v1/securities/search?q=SBER - Backend->>Cache: getOrFetch('search:sber') + User->>Frontend: View stock SBER + Note over Frontend: Access token in memory + Frontend->>Backend: GET /securities/shares/SBER (Authorization: Bearer) + Backend->>Backend: JwtAuthGuard validates token + Backend->>Cache: getOrFetch('share:SBER') alt Cache miss Cache->>Backend: null - Backend->>MOEX: GET /iss/securities?q=SBER + Backend->>MOEX: GET /iss/.../SBER.json MOEX-->>Backend: raw data - Backend->>Cache: set('search:sber', normalized, TTL=3600) + Backend->>Cache: set('share:SBER', ..., TTL=900) else Cache hit Cache-->>Backend: cached data end - Backend-->>Frontend: { data, meta: { fromCache, cachedAt } } + Backend-->>Frontend: { data, meta } Frontend-->>User: rendered UI ``` @@ -73,6 +84,7 @@ All architectural decisions are documented as ADR in `docs/architecture/adr/`: | [ADR-005](adr/ADR-005-openapi-codegen-frontend) | OpenAPI codegen for frontend | | [ADR-006](adr/ADR-006-no-cci) | No CCI (Custom Components Infrastructure) | | [ADR-007](adr/ADR-007-two-level-caching) | Two-level caching strategy | +| [ADR-008](adr/ADR-008-auth-system) | Authentication & Authorization | ## Response Format @@ -107,4 +119,6 @@ All architectural decisions are documented as ADR in `docs/architecture/adr/`: - HttpExceptionFilter (catch-all) - TransformInterceptor (авто-обёртка в `ApiResponse`) - RequestLoggingMiddleware (логирует `METHOD /path STATUS DURATIONms`) -- CORS: разрешён для всех origins +- JwtAuthGuard (глобально, `@Public()` для открытых эндпоинтов) +- RolesGuard (проверка ролей) +- CORS: разрешён для всех origins, `credentials: true` diff --git a/apps/docs/docs/backend/api.md b/apps/docs/docs/backend/api.md index 39c22e1..8276499 100644 --- a/apps/docs/docs/backend/api.md +++ b/apps/docs/docs/backend/api.md @@ -2,6 +2,87 @@ Все эндпоинты находятся под префиксом `/api/v1`. Swagger UI: `/api/docs`. +Защищённые эндпоинты требуют заголовок `Authorization: Bearer `. + +## Auth + +### `POST /auth/register` + +Регистрация нового пользователя. + +**Request:** +```json +{ + "email": "user@example.com", + "password": "securePass123", + "name": "John" +} +``` + +**Response:** `{ data: { user, accessToken }, meta }` + `Set-Cookie: refresh_token`. + +### `POST /auth/login` + +Вход по email и паролю. + +**Request:** +```json +{ + "email": "user@example.com", + "password": "securePass123" +} +``` + +**Response:** `{ data: { user, accessToken }, meta }` + `Set-Cookie: refresh_token`. + +### `POST /auth/refresh` + +Обновление access token через refresh token из cookie. + +**Response:** `{ data: { user, accessToken }, meta }` + новый `Set-Cookie: refresh_token`. + +### `POST /auth/logout` + +Выход из системы. Удаляет refresh token из БД и чистит cookie. + +**Headers:** `Authorization: Bearer ` + +**Response:** `{ data: { message }, meta }`. + +### `GET /auth/me` + +Информация о текущем пользователе. + +**Headers:** `Authorization: Bearer ` + +**Response:** +```json +{ + "data": { + "id": 1, + "email": "user@example.com", + "name": "John", + "role": "user" + }, + "meta": { "fromCache": false, "cachedAt": null } +} +``` + +### `PATCH /auth/me` + +Обновление профиля текущего пользователя. + +**Headers:** `Authorization: Bearer ` + +**Request:** +```json +{ + "name": "John Doe" +} +``` + +**Response:** `{ data: { id, email, name, role }, meta }`. + ## Health ### `GET /health` diff --git a/apps/docs/docs/backend/auth.md b/apps/docs/docs/backend/auth.md new file mode 100644 index 0000000..22f653e --- /dev/null +++ b/apps/docs/docs/backend/auth.md @@ -0,0 +1,152 @@ +# Authentication & Authorization + +## Architecture + +### Token-Based Authentication + +Система использует два типа JWT-токенов: + +| Token | Format | TTL | Storage | Purpose | +|-------|--------|-----|---------|---------| +| Access Token | JWT `{ sub, email, role }` | 15 min | Memory (React) + `Authorization: Bearer` | Authenticate API requests | +| Refresh Token | JWT `{ sub, jti }` | 7 days | httpOnly cookie + SHA-256 hash in DB | Issue new access tokens | + +### Security + +- Passwords hashed with **bcrypt** (12 salt rounds) +- Refresh tokens stored as **bcrypt hash** in database +- httpOnly, SameSite=Lax, Secure (production) cookies +- Access token never persisted to localStorage (XSS protection) +- Auto-refresh on 401 with automatic retry of failed request +- Refresh token rotation: each refresh invalidates the old token + +### Flow + +```mermaid +sequenceDiagram + participant User + participant Frontend as React SPA + participant Backend as NestJS API + participant DB as SQLite (Prisma) + + User->>Frontend: Enter email & password + Frontend->>Backend: POST /auth/login { email, password } + Backend->>DB: Find user by email + Backend->>Backend: bcrypt.compare(password, hash) + Backend->>DB: Save refresh token hash + Backend-->>Frontend: { user, accessToken } + Set-Cookie (refreshToken) + Frontend->>Frontend: Store accessToken in memory + Frontend-->>User: Redirect to app + + Note over Frontend,Backend: Later API request + Frontend->>Backend: GET /auth/me (Authorization: Bearer ) + Backend->>Backend: Verify JWT signature & expiry + Backend-->>Frontend: { user } + + Note over Frontend,Backend: Token refresh (auto on 401) + Frontend->>Backend: POST /auth/refresh (Cookie: refreshToken) + Backend->>Backend: Verify refresh JWT + Backend->>DB: Compare refresh token hash + Backend->>DB: Rotate: save new refresh token hash + Backend-->>Frontend: { user, newAccessToken } + Set-Cookie (newRefreshToken) + Frontend->>Frontend: Update accessToken in memory + Frontend->>Backend: Retry original request + + Note over Frontend,Backend: Logout + Frontend->>Backend: POST /auth/logout + Backend->>DB: Clear refresh token hash + Backend-->>Frontend: Clear cookie + Frontend->>Frontend: Clear accessToken +``` + +## API Endpoints + +All endpoints are under `/api/v1/auth`. + +### `POST /auth/register` + +Register a new user. + +**Request:** +```json +{ + "email": "user@example.com", + "password": "securePass123", + "name": "John" +} +``` + +**Response:** `{ data: { user, accessToken }, meta }` + `Set-Cookie` with refresh token. + +### `POST /auth/login` + +Authenticate existing user. + +**Request:** +```json +{ + "email": "user@example.com", + "password": "securePass123" +} +``` + +**Response:** `{ data: { user, accessToken }, meta }` + `Set-Cookie`. + +### `POST /auth/refresh` + +Refresh access token. Reads refresh token from cookie. + +**Response:** `{ data: { user, accessToken }, meta }` + new `Set-Cookie`. + +### `POST /auth/logout` + +Invalidates refresh token. Requires `Authorization: Bearer`. + +**Response:** `{ data: { message }, meta }` + cookie cleared. + +### `GET /auth/me` + +Returns current user profile. Requires `Authorization: Bearer`. + +**Response:** +```json +{ + "data": { + "id": 1, + "email": "user@example.com", + "name": "John", + "role": "user" + }, + "meta": { "fromCache": false, "cachedAt": null } +} +``` + +### `PATCH /auth/me` + +Update current user profile. Requires `Authorization: Bearer`. + +**Request:** +```json +{ + "name": "John Doe" +} +``` + +## Authorization (RBAC) + +| Role | Permissions | +|------|-------------| +| `user` | View/edit own profile | +| `admin` | All user permissions | + +**Application level:** Global `JwtAuthGuard` protects all routes by default. Use `@Public()` decorator to bypass. `RolesGuard` checks required roles from `@Roles()` decorator. + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `JWT_SECRET` | `dev-jwt-secret-...` | Secret for access token signing | +| `JWT_REFRESH_SECRET` | `dev-refresh-secret-...` | Secret for refresh token signing | +| `JWT_ACCESS_EXPIRES` | `15m` | Access token TTL | +| `JWT_REFRESH_EXPIRES` | `7d` | Refresh token TTL | +| `DATABASE_URL` | `file:./dev.db` | SQLite database URL (Prisma format) | diff --git a/apps/docs/docs/backend/database.md b/apps/docs/docs/backend/database.md new file mode 100644 index 0000000..2168651 --- /dev/null +++ b/apps/docs/docs/backend/database.md @@ -0,0 +1,64 @@ +# Database Schema + +## Overview + +Используется **SQLite** через **Prisma ORM 7**. База данных находится в `apps/backend/dev.db`. + +## Schema + +```prisma +generator client { + provider = "prisma-client" + output = "../src/generated/prisma" +} + +datasource db { + provider = "sqlite" +} + +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 +} +``` + +## Tables + +### Users + +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| id | INTEGER | PK, AUTOINCREMENT | User ID | +| email | TEXT | UNIQUE, NOT NULL | Email address | +| password | TEXT | NOT NULL | bcrypt hash of password | +| name | TEXT | NULLABLE | Display name | +| role | TEXT | NOT NULL, DEFAULT 'user' | Role for RBAC | +| refreshToken | TEXT | NULLABLE | bcrypt hash of current refresh token | +| createdAt | DATETIME | NOT NULL | Timestamp | +| updatedAt | DATETIME | NOT NULL | Auto-updated timestamp | + +## Prisma Client + +The Prisma client is generated to `apps/backend/src/generated/prisma/` and imported via `@/generated/prisma/client`. + +Key service: `PrismaService` (`src/modules/prisma/prisma.service.ts`) — extends `PrismaClient`, handles connection lifecycle (`onModuleInit`/`onModuleDestroy`). Registered as a global module. + +## Migrations + +Migrations are stored in `apps/backend/prisma/migrations/`. To create a new migration: + +```bash +npx prisma migrate dev --name +``` + +To apply migrations in production: + +```bash +npx prisma migrate deploy +``` diff --git a/apps/docs/docs/backend/overview.md b/apps/docs/docs/backend/overview.md index ffa03b8..3b918e4 100644 --- a/apps/docs/docs/backend/overview.md +++ b/apps/docs/docs/backend/overview.md @@ -7,9 +7,11 @@ Backend — NestJS-приложение, единственная точка д `apps/backend/src/main.ts` — bootstrap: - Глобальный префикс `/api/v1` -- Swagger на `/api/docs` (title: "MoexVibe API", version: "1.0.0") +- Swagger на `/api/docs` (title: "MoexVibe API", version: "1.0.0") с Bearer auth - ValidationPipe, HttpExceptionFilter, TransformInterceptor, RequestLoggingMiddleware -- CORS включён +- JwtAuthGuard (глобально, `@Public()` для открытых эндпоинтов) +- cookie-parser (для refresh token) +- CORS включён (`credentials: true`) - Порт из `PORT` env (default: 3000) ## Root Module @@ -17,7 +19,7 @@ Backend — NestJS-приложение, единственная точка д `apps/backend/src/app.module.ts` импортирует: - `ConfigModule.forRoot` (глобальный, из `config/configuration.ts`) -- Импортируются 6 feature-модулей (Cache + MoexClient — глобальные) +- Импортируются 8 feature-модулей (Prisma + Cache + MoexClient — глобальные) ## Source Layout @@ -38,9 +40,11 @@ apps/backend/src/ │ └── middleware/ │ └── request-logging.middleware.ts └── modules/ + ├── prisma/ ├── moex-client/ ├── cache/ ├── health/ + ├── auth/ ├── securities/ ├── shares/ ├── bonds/ diff --git a/apps/docs/sidebars.ts b/apps/docs/sidebars.ts index e41fb55..a1def48 100644 --- a/apps/docs/sidebars.ts +++ b/apps/docs/sidebars.ts @@ -12,6 +12,8 @@ const sidebars: SidebarsConfig = { 'backend/overview', 'backend/modules', 'backend/api', + 'backend/auth', + 'backend/database', 'backend/configuration', 'backend/caching', 'backend/moex-client', @@ -56,6 +58,7 @@ const sidebars: SidebarsConfig = { 'adr/ADR-005-openapi-codegen-frontend', 'adr/ADR-006-no-cci', 'adr/ADR-007-two-level-caching', + 'adr/ADR-008-auth-system', ], }, ], diff --git a/apps/frontend/src/api/auth.ts b/apps/frontend/src/api/auth.ts new file mode 100644 index 0000000..4203d07 --- /dev/null +++ b/apps/frontend/src/api/auth.ts @@ -0,0 +1,56 @@ +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 }, skipAuth: true }, + ); + 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 }, skipAuth: true }, + ); + setAccessToken(result.data.accessToken); + return result.data; +} + +export async function refresh() { + const result = await request( + '/api/v1/auth/refresh', + undefined, + { method: 'POST', skipAuth: true }, + ); + setAccessToken(result.data.accessToken); + return result.data; +} + +export async function logout() { + const result = await request<{ message: string }>( + '/api/v1/auth/logout', + undefined, + { method: 'POST' }, + ); + setAccessToken(null); + return result.data; +} + +export async function getMe() { + const result = await request('/api/v1/auth/me'); + return result.data; +} + +export async function updateProfile(data: { name?: string }) { + const result = await request( + '/api/v1/auth/me', + undefined, + { method: 'PATCH', body: data }, + ); + return result.data; +} diff --git a/apps/frontend/src/api/client.ts b/apps/frontend/src/api/client.ts index 46db16f..5a505c0 100644 --- a/apps/frontend/src/api/client.ts +++ b/apps/frontend/src/api/client.ts @@ -1,6 +1,7 @@ import type { ApiEnvelope, ApiResponseMeta, + AuthResponse, ShareResponse, StockMarketData, DividendItem, @@ -15,9 +16,57 @@ import type { const BASE = ''; -async function request( +let accessToken: string | null = null; +let onUnauthorized: (() => void) | null = null; +let isRefreshing = false; +let refreshPromise: Promise | 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 refreshTokens(): Promise { + try { + const res = await fetch(`${BASE}/api/v1/auth/refresh`, { + method: 'POST', + credentials: 'include', + }); + if (!res.ok) return false; + const json: ApiEnvelope<{ data: AuthResponse; meta: ApiResponseMeta }> = await res.json(); + accessToken = json.data.data.accessToken; + return true; + } catch { + return false; + } +} + +async function handleUnauthorized(): Promise { + if (isRefreshing && refreshPromise) { + return refreshPromise; + } + + isRefreshing = true; + refreshPromise = refreshTokens().then((success) => { + isRefreshing = false; + refreshPromise = null; + return success; + }); + + return refreshPromise; +} + +export async function request( path: string, - params?: Record, + params?: Record, + options?: { method?: string; body?: unknown; skipAuth?: boolean }, ): Promise<{ data: T; meta: ApiResponseMeta }> { const url = new URL(`${BASE}${path}`, window.location.origin); if (params) { @@ -25,8 +74,45 @@ async function request( if (v !== undefined) url.searchParams.set(k, v); } } - const res = await fetch(url.toString()); - if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`); + + const headers: Record = {}; + if (!options?.skipAuth && accessToken) { + headers['Authorization'] = `Bearer ${accessToken}`; + } + if (options?.body && !(options.body instanceof FormData)) { + headers['Content-Type'] = 'application/json'; + } + + const fetchOptions: RequestInit = { + headers, + credentials: 'include' as RequestCredentials, + }; + if (options?.method) { + fetchOptions.method = options.method; + } + if (options?.body !== undefined) { + fetchOptions.body = options.body instanceof FormData ? options.body : JSON.stringify(options.body); + } + + let res = await fetch(url.toString(), fetchOptions); + + if (res.status === 401 && !options?.skipAuth) { + const refreshed = await handleUnauthorized(); + if (refreshed) { + headers['Authorization'] = `Bearer ${accessToken}`; + res = await fetch(url.toString(), { ...fetchOptions, headers }); + } else { + accessToken = null; + onUnauthorized?.(); + throw new Error('Сессия истекла'); + } + } + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Ошибка API: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`); + } + const json: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await res.json(); return json.data; } diff --git a/apps/frontend/src/api/responses.ts b/apps/frontend/src/api/responses.ts index 88e5024..a7da5ea 100644 --- a/apps/frontend/src/api/responses.ts +++ b/apps/frontend/src/api/responses.ts @@ -122,3 +122,15 @@ export interface HealthResponse { timestamp: string; uptime: number; } + +export interface UserResponse { + id: number; + email: string; + name: string | null; + role: string; +} + +export interface AuthResponse { + user: UserResponse; + accessToken: string; +} diff --git a/apps/frontend/src/components/Layout.tsx b/apps/frontend/src/components/Layout.tsx index 3047715..728682a 100644 --- a/apps/frontend/src/components/Layout.tsx +++ b/apps/frontend/src/components/Layout.tsx @@ -1,7 +1,16 @@ -import { Outlet, Link } from 'react-router-dom'; +import { Outlet, Link, useNavigate } from 'react-router-dom'; import { SearchBar } from './SearchBar'; +import { useAuth } from '../hooks/useAuth'; export function Layout() { + const { isAuthenticated, user, logout } = useAuth(); + const navigate = useNavigate(); + + async function handleLogout() { + await logout(); + navigate('/'); + } + return (
+
+ {isAuthenticated ? ( + <> + + {user?.name || user?.email} + + + + ) : ( + + Войти + + )} +
diff --git a/apps/frontend/src/components/ProtectedRoute.tsx b/apps/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..813095a --- /dev/null +++ b/apps/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,29 @@ +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuth } from '../hooks/useAuth'; +import type { ReactNode } from 'react'; + +export function ProtectedRoute({ children }: { children: ReactNode }) { + const { isAuthenticated, isLoading } = useAuth(); + const location = useLocation(); + + if (isLoading) { + return ( +
+ Загрузка... +
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +} diff --git a/apps/frontend/src/context/AuthContext.tsx b/apps/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..c124811 --- /dev/null +++ b/apps/frontend/src/context/AuthContext.tsx @@ -0,0 +1,125 @@ +import { createContext, useState, useEffect, useCallback, type ReactNode } from 'react'; +import * as authApi from '../api/auth'; +import { setOnUnauthorized } from '../api/client'; +import type { UserResponse } from '../api/responses'; + +export interface AuthContextValue { + user: UserResponse | null; + accessToken: string | null; + isAuthenticated: boolean; + isLoading: boolean; + login: (email: string, password: string) => Promise; + register: (email: string, password: string, name?: string) => Promise; + logout: () => Promise; + updateProfile: (data: { name?: string }) => Promise; +} + +export const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [accessToken, setAccessTokenState] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [initialized, setInitialized] = useState(false); + + const updateSession = useCallback((authData: { user: UserResponse; accessToken: string }) => { + setUser(authData.user); + setAccessTokenState(authData.accessToken); + }, []); + + const clearSession = useCallback(() => { + setUser(null); + setAccessTokenState(null); + }, []); + + const login = useCallback(async (email: string, password: string) => { + const result = await authApi.login(email, password); + updateSession(result); + }, [updateSession]); + + const register = useCallback(async (email: string, password: string, name?: string) => { + const result = await authApi.register(email, password, name); + updateSession(result); + }, [updateSession]); + + const logout = useCallback(async () => { + try { + await authApi.logout(); + } catch { + // ignore network errors on logout + } + clearSession(); + }, [clearSession]); + + const updateProfileFn = useCallback(async (data: { name?: string }) => { + const result = await authApi.updateProfile(data); + setUser(result); + }, []); + + // Try to restore session on mount + useEffect(() => { + let mounted = true; + + async function init() { + try { + const result = await authApi.refresh(); + if (mounted) { + updateSession(result); + } + } catch { + // No valid session + } finally { + if (mounted) { + setIsLoading(false); + setInitialized(true); + } + } + } + + init(); + + return () => { + mounted = false; + }; + }, [updateSession]); + + // Set up auto-logout on unauthorized + useEffect(() => { + setOnUnauthorized(() => { + clearSession(); + }); + }, [clearSession]); + + if (!initialized && isLoading) { + return ( +
+ Загрузка... +
+ ); + } + + return ( + + {children} + + ); +} diff --git a/apps/frontend/src/hooks/useAuth.ts b/apps/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..d46b2d3 --- /dev/null +++ b/apps/frontend/src/hooks/useAuth.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { AuthContext, type AuthContextValue } from '../context/AuthContext'; + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return ctx; +} diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index d777ffa..e515e37 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AuthProvider } from './context/AuthContext'; import App from './App'; import './styles.css'; @@ -17,7 +18,9 @@ const queryClient = new QueryClient({ ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , ); diff --git a/apps/frontend/src/pages/LoginPage.tsx b/apps/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..4138117 --- /dev/null +++ b/apps/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,96 @@ +import { useState, type FormEvent } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { useAuth } from '../hooks/useAuth'; + +export function LoginPage() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { login } = useAuth(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const redirect = searchParams.get('redirect') || '/'; + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(''); + setLoading(true); + try { + await login(email, password); + navigate(redirect); + } catch (err) { + setError(err instanceof Error ? err.message : 'Ошибка входа'); + } finally { + setLoading(false); + } + } + + return ( +
+

Вход

+
+ {error && ( +
{error}
+ )} +
+ + setEmail(e.target.value)} + required + style={inputStyle} + placeholder="email@example.com" + /> +
+
+ + setPassword(e.target.value)} + required + style={inputStyle} + placeholder="••••••••" + /> +
+ +

+ Нет аккаунта?{' '} + + Зарегистрироваться + +

+
+
+ ); +} + +const inputStyle: React.CSSProperties = { + width: '100%', + padding: '10px 12px', + border: '1px solid #e0e0e0', + borderRadius: 'var(--border-radius)', + fontSize: 16, + outline: 'none', + boxSizing: 'border-box', +}; + +const buttonStyle: React.CSSProperties = { + padding: '12px 24px', + background: 'var(--color-primary)', + color: '#fff', + border: 'none', + borderRadius: 'var(--border-radius)', + fontSize: 16, + fontWeight: 600, + cursor: 'pointer', +}; diff --git a/apps/frontend/src/pages/ProfilePage.tsx b/apps/frontend/src/pages/ProfilePage.tsx new file mode 100644 index 0000000..afae311 --- /dev/null +++ b/apps/frontend/src/pages/ProfilePage.tsx @@ -0,0 +1,97 @@ +import { useState, type FormEvent } from 'react'; +import { useAuth } from '../hooks/useAuth'; + +export function ProfilePage() { + const { user, updateProfile } = useAuth(); + const [name, setName] = useState(user?.name || ''); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(''); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setSaving(true); + setMessage(''); + try { + await updateProfile({ name: name || undefined }); + setMessage('Профиль обновлён'); + } catch { + setMessage('Не удалось обновить профиль'); + } finally { + setSaving(false); + } + } + + if (!user) return null; + + return ( +
+

Профиль

+
+
+ Почта +

{user.email}

+
+
+ Роль +

{user.role}

+
+
+
+ + setName(e.target.value)} + style={{ + width: '100%', + padding: '10px 12px', + border: '1px solid #e0e0e0', + borderRadius: 'var(--border-radius)', + fontSize: 16, + outline: 'none', + boxSizing: 'border-box', + }} + /> +
+ {message && ( +
+ {message} +
+ )} + +
+
+
+ ); +} diff --git a/apps/frontend/src/pages/RegisterPage.tsx b/apps/frontend/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..b2eb709 --- /dev/null +++ b/apps/frontend/src/pages/RegisterPage.tsx @@ -0,0 +1,130 @@ +import { useState, type FormEvent } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { useAuth } from '../hooks/useAuth'; + +export function RegisterPage() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { register } = useAuth(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [name, setName] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const redirect = searchParams.get('redirect') || '/'; + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(''); + + if (password !== confirmPassword) { + setError('Пароли не совпадают'); + return; + } + + setLoading(true); + try { + await register(email, password, name || undefined); + navigate(redirect); + } catch (err) { + setError(err instanceof Error ? err.message : 'Ошибка регистрации'); + } finally { + setLoading(false); + } + } + + return ( +
+

Регистрация

+
+ {error && ( +
{error}
+ )} +
+ + setName(e.target.value)} + style={inputStyle} + placeholder="Иван Иванов" + /> +
+
+ + setEmail(e.target.value)} + required + style={inputStyle} + placeholder="email@example.com" + /> +
+
+ + setPassword(e.target.value)} + required + minLength={6} + style={inputStyle} + placeholder="Минимум 6 символов" + /> +
+
+ + setConfirmPassword(e.target.value)} + required + style={inputStyle} + placeholder="Повторите пароль" + /> +
+ +

+ Уже есть аккаунт?{' '} + + Войти + +

+
+
+ ); +} + +const inputStyle: React.CSSProperties = { + width: '100%', + padding: '10px 12px', + border: '1px solid #e0e0e0', + borderRadius: 'var(--border-radius)', + fontSize: 16, + outline: 'none', + boxSizing: 'border-box', +}; + +const buttonStyle: React.CSSProperties = { + padding: '12px 24px', + background: 'var(--color-primary)', + color: '#fff', + border: 'none', + borderRadius: 'var(--border-radius)', + fontSize: 16, + fontWeight: 600, + cursor: 'pointer', +}; diff --git a/apps/frontend/src/routes.tsx b/apps/frontend/src/routes.tsx index f7e0e13..e64332d 100644 --- a/apps/frontend/src/routes.tsx +++ b/apps/frontend/src/routes.tsx @@ -3,6 +3,10 @@ import { Layout } from './components/Layout'; import { HomePage } from './pages/HomePage'; import { StockPage } from './pages/StockPage'; import { BondPage } from './pages/BondPage'; +import { LoginPage } from './pages/LoginPage'; +import { RegisterPage } from './pages/RegisterPage'; +import { ProfilePage } from './pages/ProfilePage'; +import { ProtectedRoute } from './components/ProtectedRoute'; export function AppRoutes() { return ( @@ -11,6 +15,16 @@ export function AppRoutes() { } /> } /> } /> + } /> + } /> + + + + } + /> ); diff --git a/docs/superpowers/plans/2026-06-13-auth-system.md b/docs/superpowers/plans/2026-06-13-auth-system.md new file mode 100644 index 0000000..f6c7a9e --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-auth-system.md @@ -0,0 +1,1186 @@ +# 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"}] \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b0557c3..28205d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,17 +18,23 @@ "name": "@moex-vibe/backend", "version": "0.0.1", "dependencies": { + "@libsql/client": "^0.17.3", "@nestjs/axios": "^3.0.0", "@nestjs/cache-manager": "^2.0.0", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^11.0.2", "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^7.0.0", + "@prisma/adapter-libsql": "^7.8.0", + "@prisma/client": "^7.8.0", "axios": "^1.6.0", + "bcrypt": "^6.0.0", "cache-manager": "^5.0.0", "class-transformer": "^0.5.0", "class-validator": "^0.14.0", + "cookie-parser": "^1.4.7", "p-queue": "^7.3.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0", @@ -39,11 +45,14 @@ "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", "@swc/core": "^1.15.41", + "@types/bcrypt": "^6.0.0", + "@types/cookie-parser": "^1.4.10", "@types/express": "^4.17.0", "@types/node": "^20.0.0", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.0.0", + "prisma": "^7.8.0", "typescript": "^5.3.0", "unplugin-swc": "^1.5.9", "vitest": "^1.0.0" @@ -4608,6 +4617,36 @@ "node": ">=14.14" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", + "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", + "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", + "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", + "devOptional": true, + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -5125,6 +5164,19 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -5374,12 +5426,199 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", "license": "MIT" }, + "node_modules/@libsql/client": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.3.tgz", + "integrity": "sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA==", + "license": "MIT", + "dependencies": { + "@libsql/core": "^0.17.3", + "@libsql/hrana-client": "^0.10.0", + "js-base64": "^3.7.5", + "libsql": "^0.5.28", + "promise-limit": "^2.7.0" + } + }, + "node_modules/@libsql/core": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@libsql/core/-/core-0.17.3.tgz", + "integrity": "sha512-2UjK1i7JBkMduJo4WdvvBxMMvVJ31pArBZNONyz/GCJJAH+1UHat2X6vn10S/WpY5fKzIT98WqYFl2vzWRLOfg==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/darwin-arm64": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.5.29.tgz", + "integrity": "sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/darwin-x64": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.5.29.tgz", + "integrity": "sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/hrana-client": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@libsql/hrana-client/-/hrana-client-0.10.0.tgz", + "integrity": "sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw==", + "license": "MIT", + "dependencies": { + "@libsql/isomorphic-ws": "^0.1.5", + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/isomorphic-ws": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@libsql/isomorphic-ws/-/isomorphic-ws-0.1.5.tgz", + "integrity": "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==", + "license": "MIT", + "dependencies": { + "@types/ws": "^8.5.4", + "ws": "^8.13.0" + } + }, + "node_modules/@libsql/isomorphic-ws/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@libsql/linux-arm-gnueabihf": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-gnueabihf/-/linux-arm-gnueabihf-0.5.29.tgz", + "integrity": "sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm-musleabihf": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-musleabihf/-/linux-arm-musleabihf-0.5.29.tgz", + "integrity": "sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-gnu": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.5.29.tgz", + "integrity": "sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-musl": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.5.29.tgz", + "integrity": "sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-gnu": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.5.29.tgz", + "integrity": "sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-musl": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.5.29.tgz", + "integrity": "sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/win32-x64-msvc": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.5.29.tgz", + "integrity": "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@ljharb/through": { "version": "2.3.14", "resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.14.tgz", @@ -5474,6 +5713,12 @@ "resolved": "apps/frontend", "link": true }, + "node_modules/@neon-rs/load": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz", + "integrity": "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==", + "license": "MIT" + }, "node_modules/@nestjs/axios": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.1.3.tgz", @@ -5640,6 +5885,19 @@ } } }, + "node_modules/@nestjs/jwt": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz", + "integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/mapped-types": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz", @@ -6080,6 +6338,369 @@ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", "license": "MIT" }, + "node_modules/@prisma/adapter-libsql": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-libsql/-/adapter-libsql-7.8.0.tgz", + "integrity": "sha512-WnBGVMMlaehdVtWyUydCopL9WjGO77FZaaX0NWbW/Puu7iom3xNM9njpz57QBySbwJSLJMqoIbBPVGcjZ6jk/A==", + "license": "Apache-2.0", + "dependencies": { + "@libsql/client": "^0.17.0", + "@prisma/driver-adapter-utils": "7.8.0", + "async-mutex": "0.5.0" + } + }, + "node_modules/@prisma/client": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", + "integrity": "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.8.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.8.0.tgz", + "integrity": "sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/config": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", + "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.3.4", + "deepmerge-ts": "7.1.5", + "effect": "3.20.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz", + "integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/dev": { + "version": "0.24.3", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", + "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.4.1", + "@electric-sql/pglite-socket": "0.1.1", + "@electric-sql/pglite-tools": "0.3.1", + "@hono/node-server": "1.19.11", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "@prisma/streams-local": "0.1.2", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "hono": "^4.12.8", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.2.0", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/dev/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", + "integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/engines": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", + "integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/fetch-engine": "7.8.0", + "@prisma/get-platform": "7.8.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", + "integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", + "integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/get-platform": "7.8.0" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/streams-local": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", + "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.12.0", + "better-result": "^2.7.0", + "env-paths": "^3.0.0", + "proper-lockfile": "^4.1.2" + }, + "engines": { + "bun": ">=1.3.6", + "node": ">=22.0.0" + } + }, + "node_modules/@prisma/studio-core": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", + "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@radix-ui/react-toggle": "1.1.10", + "chart.js": "4.5.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0", + "pnpm": "8" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@redocly/ajv": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", @@ -6635,6 +7256,13 @@ "micromark-util-symbol": "^1.0.1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", @@ -7261,6 +7889,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -7299,6 +7937,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -7445,6 +8093,16 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -8481,6 +9139,15 @@ "astring": "bin/astring" } }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -8532,6 +9199,16 @@ "postcss": "^8.1.0" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/axios": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", @@ -8692,6 +9369,27 @@ "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", "license": "MIT" }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/better-result": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz", + "integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==", + "devOptional": true, + "license": "MIT" + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -8973,6 +9671,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -8999,6 +9703,114 @@ "node": ">= 0.8" } }, + "node_modules/c12": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" + }, + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/c12/node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/c12/node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/c12/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/c12/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/c12/node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/c12/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -9281,6 +10093,19 @@ "dev": true, "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/check-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", @@ -9792,6 +10617,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -10589,6 +11433,16 @@ "node": ">=0.10.0" } }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/default-gateway": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", @@ -10749,6 +11603,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/del": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/del/-/del-6.1.1.tgz", @@ -10780,6 +11641,16 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -10798,6 +11669,13 @@ "node": ">=6" } }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -10808,6 +11686,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -11068,12 +11955,32 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/effect": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", + "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.372", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", @@ -11111,6 +12018,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -11145,6 +12062,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -11886,6 +12816,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -11925,6 +12862,29 @@ "integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==", "license": "MIT" }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -12223,7 +13183,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -12425,6 +13385,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -12474,6 +13444,13 @@ "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", "license": "ISC" }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "devOptional": true, + "license": "MIT" + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -12500,6 +13477,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/giget": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.0.tgz", + "integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==", + "devOptional": true, + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" + } + }, "node_modules/github-slugger": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.5.0.tgz", @@ -12699,6 +13686,13 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -12706,6 +13700,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/gray-matter": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", @@ -13046,6 +14047,16 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/hono": { + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hpack.js": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", @@ -13343,6 +14354,13 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/http2-wrapper": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", @@ -13853,6 +14871,13 @@ "license": "MIT", "peer": true }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "devOptional": true, + "license": "MIT" + }, "node_modules/is-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", @@ -14050,6 +15075,12 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, "node_modules/js-levenshtein": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", @@ -14146,6 +15177,49 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -14227,6 +15301,38 @@ "integrity": "sha512-NdB6O6QvlGMCoG003m0YIKG2+Xw7DjmCZhmc1RH+K6HncADUbRf8TZeLegxBBN1VFyPHcNpPTKpIhYLXzJVy1Q==", "license": "MIT" }, + "node_modules/libsql": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/libsql/-/libsql-0.5.29.tgz", + "integrity": "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==", + "cpu": [ + "x64", + "arm64", + "wasm32", + "arm" + ], + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "@neon-rs/load": "^0.0.4", + "detect-libc": "2.0.2" + }, + "optionalDependencies": { + "@libsql/darwin-arm64": "0.5.29", + "@libsql/darwin-x64": "0.5.29", + "@libsql/linux-arm-gnueabihf": "0.5.29", + "@libsql/linux-arm-musleabihf": "0.5.29", + "@libsql/linux-arm64-gnu": "0.5.29", + "@libsql/linux-arm64-musl": "0.5.29", + "@libsql/linux-x64-gnu": "0.5.29", + "@libsql/linux-x64-musl": "0.5.29", + "@libsql/win32-x64-msvc": "0.5.29" + } + }, "node_modules/lightweight-charts": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-4.2.3.tgz", @@ -14341,6 +15447,42 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -14354,6 +15496,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -14377,6 +15525,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "devOptional": true, + "license": "Apache-2.0" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -14436,6 +15591,22 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "devOptional": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", @@ -16979,6 +18150,57 @@ "dev": true, "license": "ISC" }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -17037,6 +18259,15 @@ "dev": true, "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz", + "integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-emoji": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", @@ -17076,6 +18307,17 @@ "node": ">= 6.13.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-releases": { "version": "2.0.47", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", @@ -17229,6 +18471,13 @@ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "license": "MIT" }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -17776,6 +19025,13 @@ "node": "*" } }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "devOptional": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -19457,6 +20713,20 @@ "postcss": "^8.4.31" } }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "devOptional": true, + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -19543,6 +20813,40 @@ "react": ">=16.0.0" } }, + "node_modules/prisma": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", + "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "7.8.0", + "@prisma/dev": "0.24.3", + "@prisma/engines": "7.8.0", + "@prisma/studio-core": "0.27.3", + "mysql2": "3.15.3", + "postgres": "3.4.7" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -19567,6 +20871,12 @@ "node": ">=16" } }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "license": "ISC" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -19597,6 +20907,35 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true, + "license": "ISC" + }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", @@ -19659,6 +20998,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -19772,6 +21128,17 @@ "node": ">=0.10.0" } }, + "node_modules/rc9": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.6", + "destr": "^2.0.5" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -20581,6 +21948,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, "node_modules/renderkid": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", @@ -21188,6 +22565,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", + "devOptional": true + }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -21633,7 +23016,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": ">=14" @@ -21821,6 +23204,16 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/srcset": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/srcset/-/srcset-4.0.0.tgz", @@ -23146,6 +24539,21 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/validator": { "version": "13.15.35", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", @@ -24360,6 +25768,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",