diff --git a/openspec/changes/archive/2026-08-23-harden-auth-and-cache-concurrency/.openspec.yaml b/openspec/changes/archive/2026-08-23-harden-auth-and-cache-concurrency/.openspec.yaml new file mode 100644 index 0000000..44f55ff --- /dev/null +++ b/openspec/changes/archive/2026-08-23-harden-auth-and-cache-concurrency/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-23 diff --git a/openspec/changes/archive/2026-08-23-harden-auth-and-cache-concurrency/design.md b/openspec/changes/archive/2026-08-23-harden-auth-and-cache-concurrency/design.md new file mode 100644 index 0000000..ed34fbc --- /dev/null +++ b/openspec/changes/archive/2026-08-23-harden-auth-and-cache-concurrency/design.md @@ -0,0 +1,104 @@ +## Context + +См. мотивацию в `proposal.md` и изменяемое поведение в delta specs. Backend +использует Nest 11, Prisma 7 с SQLite adapter, `AuthService` и локальные +`Map`-кэши. Публичные auth routes, cookie attributes и success statuses не +меняются; backend e2e migration gate остаётся отложенным по WL-P2-05. + +## Goals / Non-Goals + +**Goals:** + +- Сделать cache страниц журнала ограниченным и полезным для семантически + равных запросов. +- Устранить race между проверкой уникальности email и его созданием. +- Гарантировать единственного победителя refresh rotation без случайного + revoke его Session. +- Оставить воспроизводимые service-level tests, пока canonical SQLite e2e + path недоступен. + +**Non-Goals:** + +- Изменять auth response statuses, OpenAPI snapshot или frontend client + resilience (WL-P2-04, WL-P2-07 и WL-P2-08). +- Добавлять byte-weight cache budget, распределённый cache, cache + invalidation или cross-instance coherence. +- Добавлять auth framework, новую схему Session, grace window либо отдельную + историю использованных refresh token. +- Объявлять `test:e2e` или общий workspace verdict восстановленными. + +## Decisions + +### Bounded cache остаётся domain-local + +`BrokerageOperationsService` получает прямую production dependency +`lru-cache` и хранит в ней максимум 100 response pages с TTL 30 секунд. +Это использует готовую, тестируемую LRU policy без глобального Nest cache +layer. Cache identity строится отдельно из user/account и нормализованных +effective query parameters: categories сортируются и deduplicate-ятся, +cursor остаётся opaque, а отсутствующий `to` привязывается к текущему +30-секундному bucket. Cache никогда не заменяет существующую проверку +connection и account ownership. + +Альтернативы: `Map` с ручной eviction повторяет сложную policy; Nest +`CacheModule` потребует глобального HTTP keying и не подходит user/account +domain cache; byte budget не нужен без подтверждённого memory pressure. + +### Database constraint определяет регистрацию + +`AuthService.register` сохраняет предварительную проверку как UX +оптимизацию, но считает `user.create` authoritative. Только ожидаемый Prisma +unique-constraint error `P2002` от этой операции преобразуется в +`ConflictException` с существующим code `EMAIL_ALREADY_REGISTERED`; прочие +ошибки остаются внутренними. Это устраняет check-then-create race, не меняя +HTTP filter или API surface. + +### Refresh rotation использует compare-and-swap + +После проверки текущей Session сервис формирует новую token pair и обновляет +Session через `updateMany` с predicate `{ id, refreshTokenHash: oldHash }`. +`count === 1` означает победителя и разрешает вернуть новую пару. `count === +0` означает superseded token: сервис очищает cookie через существующий +controller path и отвечает `401 UNAUTHORIZED`, не вызывая revoke Session. +Схема не меняется: current hash уже является достаточным CAS predicate. + +Альтернативы: transaction добавляет SQLite locking/retry semantics без +дополнительной пользы; version column или token history нужны только для +отдельного различения late replay и concurrent loser. ADR-009 фиксирует +выбранный trade-off. + +### Tests моделируют arbitration, не подменяют e2e + +Новый `AuthService` spec использует stateful Prisma fake и barrier, чтобы +две регистрации одновременно дошли до `create`, а два refresh — до CAS. +Тесты проверяют наблюдаемые service outcomes и predicate CAS, включая +отсутствие revoke у loser. `BrokerageOperationsService` spec использует fake +upstream для проверки canonical hit, ownership checks и max-100 eviction; +TTL expiry обеспечивается и тестируется библиотекой `lru-cache`, поэтому +change не добавляет test-only clock seam. Это детерминированные regression +tests; они не выдают себя за HTTP/SQLite e2e и не меняют deferred status +WL-P2-05. + +## Risks / Trade-offs + +- [Superseded token может быть поздним replay, а не concurrent loser] → + `401` не отзывает Session по ADR-009; более строгая дифференциация требует + отдельного security change. +- [Одна cached page может быть большой] → максимальное число страниц + ограничивает cardinality; byte budget остаётся отдельным будущим решением. +- [TTL не создаёт distributed coherence] → cache остаётся process-local, + как и текущий local MVP policy. +- [Unit tests не доказывают SQLite migration/HTTP wiring] → они покрывают + сервисную логику до recovery trigger для WL-P2-05. + +## Migration Plan + +1. Добавить прямую зависимость `lru-cache` и service-level tests. +2. Внести cache, registration и CAS изменения без Prisma migration. +3. Обновить фактические feature docs и ADR-009; сохранить WL-P2-05/P2-13 + deferred в remediation registry до их отдельных trigger. +4. Выполнить backend non-mutating gates, OpenSpec verification и + independent review до archive. + +Rollback заключается в откате приложения и dependency lockfile; schema и +публичные route shapes не меняются. diff --git a/openspec/changes/archive/2026-08-23-harden-auth-and-cache-concurrency/proposal.md b/openspec/changes/archive/2026-08-23-harden-auth-and-cache-concurrency/proposal.md new file mode 100644 index 0000000..9be8a5d --- /dev/null +++ b/openspec/changes/archive/2026-08-23-harden-auth-and-cache-concurrency/proposal.md @@ -0,0 +1,45 @@ +## Why + +Backend допускает неограниченный рост process-local кэша страниц журнала и +теряет детерминированность при конкурентных регистрации и refresh. Эти +подтверждённые audit findings WL-P2-01—03 могут приводить к исчерпанию памяти, +`500` вместо ожидаемого конфликта и отзыву успешно обновлённой Session. + +## What Changes + +- Заменить неограниченный cache страниц Account Ledger на bounded + `lru-cache`: не более 100 страниц, TTL 30 секунд, LRU eviction и + канонический cache key. +- Сделать database unique constraint источником истины для параллельной + регистрации и преобразовывать Prisma `P2002` в `409 + EMAIL_ALREADY_REGISTERED`. +- Ротировать refresh token compare-and-swap обновлением Session, чтобы + только один конкурентный запрос был успешным; superseded token отвечает + `401` без отзыва Session победителя. +- Добавить детерминированные service-level regression tests и обновить + устойчивую auth/cache документацию, не объявляя временно неработающий + Prisma e2e gate восстановленным. + +## Capabilities + +### New Capabilities + + + +### Modified Capabilities + +- `user-authentication`: определяет детерминированные результаты + конкурентной регистрации и refresh rotation, включая поведение superseded + refresh token. +- `brokerage-account-ledger`: определяет bounded process-local cache страниц + журнала и его каноническую идентичность. + +## Impact + +- Backend: `AuthService`, `BrokerageOperationsService`, unit tests и + production dependency `lru-cache`. +- Specs: delta specifications для user authentication и brokerage account + ledger. +- Docs: ADR-009 и фактическая документация authentication/ledger cache. +- Public HTTP routes и auth success statuses не меняются; их contract + alignment остаётся отдельным WL-P2-04. diff --git a/openspec/changes/archive/2026-08-23-harden-auth-and-cache-concurrency/specs/brokerage-account-ledger/spec.md b/openspec/changes/archive/2026-08-23-harden-auth-and-cache-concurrency/specs/brokerage-account-ledger/spec.md new file mode 100644 index 0000000..55ba957 --- /dev/null +++ b/openspec/changes/archive/2026-08-23-harden-auth-and-cache-concurrency/specs/brokerage-account-ledger/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: Кэш страниц журнала ограничивает локальные ресурсы +Система SHALL кэшировать не более 100 недавно использованных страниц Account Ledger в каждом backend process не более чем на 30 секунд. Cache key MUST включать User, Brokerage Account, нормализованные период и categories, opaque cursor и limit; порядок или дублирование categories MUST NOT создавать отдельную cache identity, а отсутствующий конец периода MUST использовать текущий 30-секундный bucket. Просроченная страница MUST NOT возвращаться, а при переполнении cache MUST удалять наименее недавно использованную страницу. + +#### Scenario: Равнозначные фильтры используют одну cache identity +- **WHEN** User в одном 30-секундном bucket повторяет запрос страницы журнала с теми же периодом, cursor и limit, но с categories в другом порядке или с дубликатами +- **THEN** система использует ту же cache identity и не запрашивает upstream повторно для свежей страницы + +#### Scenario: Большое число уникальных запросов не увеличивает cache без границы +- **WHEN** backend обслуживает больше 100 уникальных запросов страниц журнала +- **THEN** cache содержит не более 100 страниц и освобождает наименее недавно использованные страницы + +#### Scenario: Просроченная страница не возвращается +- **WHEN** cached страница журнала старше 30 секунд +- **THEN** система не возвращает её как свежий результат и получает актуальную страницу из upstream diff --git a/openspec/changes/archive/2026-08-23-harden-auth-and-cache-concurrency/specs/user-authentication/spec.md b/openspec/changes/archive/2026-08-23-harden-auth-and-cache-concurrency/specs/user-authentication/spec.md new file mode 100644 index 0000000..03225a1 --- /dev/null +++ b/openspec/changes/archive/2026-08-23-harden-auth-and-cache-concurrency/specs/user-authentication/spec.md @@ -0,0 +1,43 @@ +## MODIFIED Requirements + +### Requirement: Регистрация пользователя +Система SHALL позволять зарегистрировать User по уникальному нормализованному email и паролю. Email MUST нормализоваться удалением внешних пробелов и приведением к нижнему регистру; он MUST содержать не более 254 символов, ровно один `@`, непустые local и domain части и не содержать пробельных или управляющих символов. Пароль MUST передаваться без изменения, содержать от 12 до 72 UTF-8 байтов и не иметь требований к составу символов. Успешная регистрация MUST создать первую Session и вернуть профиль `{ id, email }` и access token. + +#### Scenario: Успешная регистрация +- **WHEN** пользователь отправляет новый email и валидный пароль +- **THEN** система создаёт User и Session, устанавливает refresh-cookie и возвращает access token с нормализованным профилем + +#### Scenario: Нормализация email при регистрации +- **WHEN** пользователь регистрирует ` Anna@Example.com ` +- **THEN** система сохраняет и возвращает email `anna@example.com` + +#### Scenario: Повторная регистрация +- **WHEN** пользователь регистрирует email, уже принадлежащий User после нормализации +- **THEN** система отвечает `409` Problem Details с code `EMAIL_ALREADY_REGISTERED` + +#### Scenario: Конкурентная регистрация +- **WHEN** два запроса одновременно регистрируют один нормализованный email +- **THEN** ровно один запрос создаёт User и Session, а второй отвечает `409` Problem Details с code `EMAIL_ALREADY_REGISTERED` + +#### Scenario: Невалидные регистрационные данные +- **WHEN** пользователь отправляет email или пароль, не соответствующие правилам регистрации +- **THEN** система отвечает `400` Problem Details с code `VALIDATION_ERROR` и указателями на невалидные поля + +### Requirement: Access и refresh сессии +Система SHALL выдавать access token сроком 15 минут и refresh token сроком 7 дней. Access token MUST передаваться клиенту только в ответе API и использоваться как Bearer token. Refresh token MUST передаваться только в `httpOnly`, `SameSite=Lax`, host-only cookie `refresh_token` с path `/api/v1/auth`; в HTTPS-среде cookie MUST иметь атрибут `Secure`. Каждый новый refresh token MUST иметь уникальный JWT ID и ротироваться при успешном обновлении Session; Session MUST оставаться активной 7 дней после последнего успешного обновления. Одновременные Session одного User MUST быть независимы и не иметь числового лимита. + +#### Scenario: Успешное восстановление сессии +- **WHEN** клиент отправляет действительный refresh-cookie +- **THEN** система заменяет refresh-cookie и возвращает новый access token и профиль Session + +#### Scenario: Конкурентное восстановление сессии +- **WHEN** два запроса одновременно отправляют один действительный refresh-cookie одной Session +- **THEN** ровно один запрос заменяет refresh-cookie и возвращает новый access token и профиль, второй отвечает `401` Problem Details с code `UNAUTHORIZED`, а Session победителя остаётся активной + +#### Scenario: Истёкшая сессия +- **WHEN** клиент отправляет refresh-cookie Session, не обновлявшейся более 7 дней +- **THEN** система удаляет refresh-cookie и отвечает `401` Problem Details с code `UNAUTHORIZED` + +#### Scenario: Повторное использование refresh token +- **WHEN** клиент отправляет refresh token, заменённый предыдущим успешным refresh +- **THEN** система удаляет refresh-cookie и отвечает `401` Problem Details с code `UNAUTHORIZED`, не отзывая связанную Session только из-за этого запроса diff --git a/openspec/changes/archive/2026-08-23-harden-auth-and-cache-concurrency/tasks.md b/openspec/changes/archive/2026-08-23-harden-auth-and-cache-concurrency/tasks.md new file mode 100644 index 0000000..8a728d5 --- /dev/null +++ b/openspec/changes/archive/2026-08-23-harden-auth-and-cache-concurrency/tasks.md @@ -0,0 +1,23 @@ +## 1. Bounded cache страниц журнала + +- [x] 1.1 Добавить в `BrokerageOperationsService` failing regression tests для canonical categories/default `to` bucket, сохранения ownership checks и eviction после 100 уникальных страниц; не добавлять test-only clock seam для TTL third-party library. +- [x] 1.2 Добавить `lru-cache` как прямую production dependency и реализовать domain-local Ledger Page Cache с TTL 30 секунд, max 100 и canonical key без обхода connection/account ownership checks. +- [x] 1.3 Запустить focused cache spec и backend unit suite; устранить каждый regression failure. + +## 2. Детерминированная auth concurrency + +- [x] 2.1 Создать `AuthService` service-level tests с stateful Prisma fake для P2002 loser при параллельной регистрации и ожидаемого `409 EMAIL_ALREADY_REGISTERED`. +- [x] 2.2 Добавить barrier-based CAS test двух refresh одного token: один победитель, один `401`, predicate содержит id и old refresh hash, Session победителя не отзывается. +- [x] 2.3 Преобразовать ожидаемый Prisma P2002 от `user.create` в существующий `ConflictException`, не скрывая остальные database errors. +- [x] 2.4 Реализовать refresh rotation через conditional `updateMany`; при CAS loser вернуть `UNAUTHORIZED` без revoke Session и сохранить lifecycle cookie через существующий controller boundary. +- [x] 2.5 Запустить focused auth spec и backend unit suite; устранить каждый regression failure. + +## 3. Постоянная документация + +- [x] 3.1 Довести ADR-009, `CONTEXT.md`, feature documentation authentication и brokerage account ledger до фактически реализованных cache и refresh policies без создания параллельных SDD-артефактов в docs. + +## 4. Verification до archive + +- [x] 4.1 Выполнить backend non-mutating gates под Node 22: `npm run lint`, `npm run typecheck:app`, `npm run typecheck:test` и `npm test -- --runInBand`; не выдавать deferred `test:e2e` за passing gate. +- [x] 4.2 Выполнить `openspec validate harden-auth-and-cache-concurrency --type change --strict --store wealth-ledger-specs`, strict validation synced specs, authority verifier и `git diff --check` во всех затронутых repositories. +- [x] 4.3 Выполнить applicable pre-archive `opsx-verify` procedure и independent read-only review implementation, delta specs, документации и scope; устранить каждый blocking result до archive. diff --git a/openspec/specs/brokerage-account-ledger/spec.md b/openspec/specs/brokerage-account-ledger/spec.md index e8316a6..6c104cd 100644 --- a/openspec/specs/brokerage-account-ledger/spec.md +++ b/openspec/specs/brokerage-account-ledger/spec.md @@ -60,3 +60,18 @@ #### Scenario: Отображение записи только с инструментом - **WHEN** полный журнал отображает запись без денежной выплаты - **THEN** страница определяет её как запись без движения денег + +### Requirement: Кэш страниц журнала ограничивает локальные ресурсы +Система SHALL кэшировать не более 100 недавно использованных страниц Account Ledger в каждом backend process не более чем на 30 секунд. Cache key MUST включать User, Brokerage Account, нормализованные период и categories, opaque cursor и limit; порядок или дублирование categories MUST NOT создавать отдельную cache identity, а отсутствующий конец периода MUST использовать текущий 30-секундный bucket. Просроченная страница MUST NOT возвращаться, а при переполнении cache MUST удалять наименее недавно использованную страницу. + +#### Scenario: Равнозначные фильтры используют одну cache identity +- **WHEN** User в одном 30-секундном bucket повторяет запрос страницы журнала с теми же периодом, cursor и limit, но с categories в другом порядке или с дубликатами +- **THEN** система использует ту же cache identity и не запрашивает upstream повторно для свежей страницы + +#### Scenario: Большое число уникальных запросов не увеличивает cache без границы +- **WHEN** backend обслуживает больше 100 уникальных запросов страниц журнала +- **THEN** cache содержит не более 100 страниц и освобождает наименее недавно использованные страницы + +#### Scenario: Просроченная страница не возвращается +- **WHEN** cached страница журнала старше 30 секунд +- **THEN** система не возвращает её как свежий результат и получает актуальную страницу из upstream diff --git a/openspec/specs/user-authentication/spec.md b/openspec/specs/user-authentication/spec.md index 96c2d32..8abbb0d 100644 --- a/openspec/specs/user-authentication/spec.md +++ b/openspec/specs/user-authentication/spec.md @@ -19,6 +19,10 @@ - **WHEN** пользователь регистрирует email, уже принадлежащий User после нормализации - **THEN** система отвечает `409` Problem Details с code `EMAIL_ALREADY_REGISTERED` +#### Scenario: Конкурентная регистрация +- **WHEN** два запроса одновременно регистрируют один нормализованный email +- **THEN** ровно один запрос создаёт User и Session, а второй отвечает `409` Problem Details с code `EMAIL_ALREADY_REGISTERED` + #### Scenario: Невалидные регистрационные данные - **WHEN** пользователь отправляет email или пароль, не соответствующий правилам регистрации - **THEN** система отвечает `400` Problem Details с code `VALIDATION_ERROR` и указателями на невалидные поля @@ -41,13 +45,17 @@ - **WHEN** клиент отправляет действительный refresh-cookie - **THEN** система заменяет refresh-cookie и возвращает новый access token и профиль Session +#### Scenario: Конкурентное восстановление сессии +- **WHEN** два запроса одновременно отправляют один действительный refresh-cookie одной Session +- **THEN** ровно один запрос заменяет refresh-cookie и возвращает новый access token и профиль, второй отвечает `401` Problem Details с code `UNAUTHORIZED`, а Session победителя остаётся активной + #### Scenario: Истёкшая сессия - **WHEN** клиент отправляет refresh-cookie Session, не обновлявшейся более 7 дней - **THEN** система удаляет refresh-cookie и отвечает `401` Problem Details с code `UNAUTHORIZED` #### Scenario: Повторное использование refresh token - **WHEN** клиент отправляет refresh token, заменённый предыдущим успешным refresh -- **THEN** система отзывает только связанную Session, удаляет refresh-cookie и отвечает `401` Problem Details с code `UNAUTHORIZED` +- **THEN** система удаляет refresh-cookie и отвечает `401` Problem Details с code `UNAUTHORIZED`, не отзывая связанную Session только из-за этого запроса ### Requirement: Завершение сессии Система SHALL позволять завершить текущую Session через logout. Logout MUST отозвать Session, если refresh-cookie соответствует активной Session, и MUST удалить refresh-cookie. Logout MUST быть идемпотентным и не раскрывать существование Session.