## Context См. мотивацию в `proposal.md` и поведение в `specs/user-authentication/spec.md`. Baseline содержит пустую Prisma schema, NestJS с глобальным prefix `/api/v1`, OpenAPI и общим error filter, а также React Router, React Query, Mantine и typed OpenAPI client. Текущие auth-страницы и `/settings` являются placeholder-экранами. ## Goals / Non-Goals **Goals:** - Создать user-scoped SQLite-модель, отзываемые refresh-сессии и API-контракт авторизации. - Обеспечить короткоживущий access JWT без долгоживущего секрета в JavaScript. - Сделать frontend-сессию восстанавливаемой, маршруты защищёнными и формы доступными во время обычной и ошибочной отправки. - Заменить baseline error envelope единым минимальным RFC 9457 Problem Details контрактом. **Non-Goals:** - Email verification, password recovery, смена email или пароля, удаление User, список устройств и logout всех устройств. - Server-side отзыв access JWT, rate limiting login, ключевая rotation JWT, refresh grace-period и серверная защита от параллельных refresh-запросов. - Production deployment, публичный docs endpoint для Problem Details или отдельный cross-site CSRF механизм. ## Decisions ### Модель User и Session Prisma добавляет `User` и `Session` с UUID string primary keys, генерируемыми приложением. `User` содержит уникальный нормализованный email, bcrypt password hash и relation к Session. `Session` содержит user id, SHA-256 hash текущего refresh JWT, `expiresAt`, `revokedAt`, timestamps. Истёкшие записи удаляются лениво при обработке refresh; ограничение числа активных Session отсутствует. `Session` хранит hash, а не refresh JWT: JWT остаётся bearer-secret только в cookie. SHA-256 достаточен для высокоэнтропийного JWT и сохраняет простое сравнение. Пароль хешируется асинхронным `bcrypt` cost 12; DTO ограничивает исходный пароль 12--72 UTF-8 байт, исключая тихое bcrypt-усечение. Неизвестный email проходит сравнение с заранее подготовленным bcrypt dummy hash, чтобы вход не раскрывал существование User по времени. Альтернативы: один refresh hash у User запрещает независимые устройства; хранение raw refresh token раскрывает секрет БД; bcrypt для refresh token не нужен; sequence IDs допускают угадывание публичного идентификатора. ### JWT и cookie lifecycle `JWT_SECRET` обязателен, имеет минимум 32 символа и используется для HS256 access и refresh JWT. Access payload содержит `sub`, `sid`, `type`, `iat`, `exp`; refresh добавляет уникальный `jti`, чтобы два refresh в одну секунду не получили идентичный token. Access живёт 15 минут, refresh 7 дней. Refresh включает `Session.id`; при refresh backend загружает active Session, сверяет hash, подписывает новый refresh JWT и перезаписывает hash и expiry. Если JWT, Session или hash невалидны, backend удаляет cookie и отвечает `401 UNAUTHORIZED`; если hash не совпал у ещё активной Session, она отзывается как reuse. Cookie называется `refresh_token`, имеет `httpOnly`, `sameSite: 'lax'`, path `/api/v1/auth`, без `Domain`; `secure` включён вне development. Logout идемпотентно очищает cookie и отзывает Session только при валидном cookie. Access JWT не сверяется с Session, поэтому после logout остаётся допустимым до 15 минут согласно принятой policy. Альтернативы: refresh JWT в localStorage раскрывает его XSS; один access-only JWT не даёт бесшовного восстановления; DB lookup access token на каждом запросе снимает 15-минутное окно, но добавляет SQLite-зависимость всем защищённым запросам. ### NestJS auth boundary `AuthModule` инкапсулирует registration, login, refresh, logout, текущего пользователя, DTO и cookie lifecycle. `@nestjs/passport` с `passport-jwt` используется только для access JWT: strategy извлекает RFC 6750 Bearer token, ограничивает алгоритм HS256, проверяет expiry и принимает только claim `type: 'access'`. `JwtAuthGuard` защищает `GET /auth/me` и будущие прикладные controllers; refresh/logout читают cookie без access guard. Passport local strategy не нужна, поскольку credential verification является одной операцией AuthService и не применяется к нескольким endpoints. Все baseline non-auth API удаляются, чтобы `/auth` оставался единственной публичной API boundary до будущих защищённых capabilities. `cookie-parser` подключается до controllers. CORS включает `credentials: true` только для валидированного `FRONTEND_ORIGIN`. ### Problem Details contract Глобальный filter заменяет baseline envelope на `application/problem+json`. Каждый ответ содержит `{ status, code }`; custom auth exceptions добавляют code `EMAIL_ALREADY_REGISTERED`, `INVALID_CREDENTIALS` или `UNAUTHORIZED`. `ValidationPipe({ transform: true, whitelist: true })` запускает class-validator DTO; filter превращает его ошибки в `400 VALIDATION_ERROR` и `errors: [{ pointer, detail }]`. Остальные HTTP ошибки получают status-derived code `BAD_REQUEST`, `NOT_FOUND`, `UNSUPPORTED_MEDIA_TYPE` или `INTERNAL_SERVER_ERROR` и не раскрывают internal detail. RFC 9457 допускает не передавать `type`, `title`, `detail` и `instance`; это оставляет контракт минимальным. Поле `code` и `errors` являются extension members, которые frontend использует без парсинга текста. ### Frontend session and transport Новая FSD feature `auth` владеет in-memory access token и profile. При старте она вызывает `POST /auth/refresh`: `401 UNAUTHORIZED` означает anonymous state, network/5xx показывает retryable startup error. Typed client всегда использует `credentials: 'include'`; authenticated request добавляет Bearer access token. При `UNAUTHORIZED` защищённый request запускает один refresh, повторяет request ровно раз и при неуспехе очищает feature state и `QueryClient` cache. Refresh в одной вкладке и между вкладками сериализуется single-flight promise и `navigator.locks` (Web Locks API). Это предотвращает штатное повторное использование cookie при одновременном истечении access token. Backend намеренно не имеет conditional update или grace-period: гонки нескольких независимых API-клиентов остаются ограничением MVP. Альтернативы: localStorage переживает reload, но нарушает границу доступа JS к refresh secret; отдельный `GET /me` на startup дублирует payload refresh; server grace-period усложняет обнаружение reuse. ### Forms, routing and UI Auth forms следуют паттерну `references/frontend-ai-template`: `react-hook-form`, `zod`, `zodResolver`, `FormProvider` и отдельное представление. Shared `customEmail()` возвращает декларативный Zod string schema с согласованным email правилом; не расширяет mutable namespace `z`. Register включает client-only `passwordConfirmation` с object-level проверкой; API получает только email и password. Во время submit view получает `isPending` и отключает `
`, submit и навигационные ссылки, сохраняя значения на network error. Router ждёт startup session restore до public/protected branching. Все маршруты, кроме `/login` и `/register`, требуют authenticated state. Original internal `pathname + search` сохраняется как return path; auth-path и невалидный путь заменяются `/accounts`. Authenticated user с `/login` или `/register` перенаправляется на `/accounts`. `/settings` заменяет placeholder на logout action. ## Risks / Trade-offs - Refresh races из независимых клиентов могут отозвать Session → Web Locks покрывает вкладки; conditional update и grace-period добавляются только при появлении такого клиента. - Украденный access JWT действует до 15 минут после logout → короткий TTL является принятой policy; DB-backed access revocation потребует отдельного change. - Нет rate limit и email verification → локальный MVP не является готовым для публичного доступа; добавить их до deployment. - `wealthledger.local` и публичная документация Problem Details не добавляются → `code` остаётся единственным стабильным client discriminator до deployment. ## Migration Plan 1. Добавить зависимости, environment validation и Prisma migration для User/Session. 2. Добавить AuthModule, Passport strategy, global validation/Problem Details и сгенерировать OpenAPI. 3. Запустить backend, regenerate typed frontend API client из `/api/docs-json`. 4. Добавить frontend auth feature, формы, router guards, startup restore и settings logout. 5. Выполнить минимальные backend flow и frontend form/router tests, затем lint, typecheck, build и OpenSpec validation. Rollback до release заключается в откате приложения и Prisma migration; MVP содержит только новые таблицы без существующих пользовательских данных.